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,157 @@
1
+ """
2
+ apps.cli.src.commands.org — Organisation and membership management commands.
3
+
4
+ Phase 1 §7 Sprint 3.2: The "day in the life" demo walkthrough requires:
5
+ sov org create "Acme Corp"
6
+ sov org member-add <org_id> <user_id> [--role ADMIN]
7
+
8
+ These commands proxy requests through the Gateway to the Axis org endpoints:
9
+ POST /api/v1/org — create any node (type=ORG, USER, ...)
10
+ POST /api/v1/org/{org_id}/member — add a MEMBER_OF edge
11
+
12
+ Usage:
13
+ sov org create "Acme Corp"
14
+ sov org create "Acme Corp" --type ORG --profile staging
15
+ sov org member-add <org_id> <user_id>
16
+ sov org member-add <org_id> <user_id> --role ADMIN
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+
23
+ import httpx
24
+ import typer
25
+
26
+ from apps.cli.src.utils.auth_store import load_tokens
27
+ from apps.cli.src.utils.config import get_api_url
28
+
29
+ app = typer.Typer(help="Organisation management commands")
30
+
31
+
32
+ @app.command("create")
33
+ def create_org(
34
+ name: str = typer.Argument(..., help="Human-readable organisation name"),
35
+ node_type: str = typer.Option(
36
+ "ORG",
37
+ "--type",
38
+ "-t",
39
+ help="Node type: ORG | PROJECT | ENV",
40
+ ),
41
+ metadata: str | None = typer.Option(
42
+ None,
43
+ "--metadata",
44
+ "-m",
45
+ help='Extra metadata as a JSON string, e.g. \'{"industry": "fintech"}\'',
46
+ ),
47
+ profile: str | None = typer.Option(
48
+ None, "--profile", help="Config profile to use (dev/staging/prod)"
49
+ ),
50
+ ) -> None:
51
+ """
52
+ Create a new organisation (or PROJECT/ENV) node in the identity graph.
53
+
54
+ Example:
55
+ sov org create "Acme Corp"
56
+ sov org create "Payments" --type PROJECT --metadata '{"env": "prod"}'
57
+ """
58
+ tokens = load_tokens(profile=profile)
59
+ if not tokens:
60
+ typer.echo("Not logged in. Run: sov login", err=True)
61
+ raise typer.Exit(1)
62
+
63
+ # Parse optional JSON metadata
64
+ meta: dict = {}
65
+ if metadata:
66
+ try:
67
+ meta = json.loads(metadata)
68
+ except json.JSONDecodeError as exc:
69
+ typer.echo(f"--metadata must be valid JSON: {exc}", err=True)
70
+ raise typer.Exit(1) from exc
71
+
72
+ api_url = get_api_url(profile)
73
+ headers = {
74
+ "Authorization": f"Bearer {tokens['access_token']}",
75
+ "Content-Type": "application/json",
76
+ }
77
+ payload = {"type": node_type, "name": name, "metadata": meta}
78
+
79
+ try:
80
+ resp = httpx.post(
81
+ f"{api_url}/api/v1/org",
82
+ headers=headers,
83
+ json=payload,
84
+ timeout=30,
85
+ )
86
+ resp.raise_for_status()
87
+ except httpx.HTTPStatusError as exc:
88
+ detail = exc.response.text
89
+ try:
90
+ detail = exc.response.json().get("detail", detail)
91
+ except Exception: # pylint: disable=broad-except
92
+ pass
93
+ typer.echo(f"Failed to create {node_type}: {detail}", err=True)
94
+ raise typer.Exit(1) from exc
95
+
96
+ data = resp.json()
97
+ node_id = data.get("node_id")
98
+ typer.echo(
99
+ typer.style("✓ Created", fg=typer.colors.GREEN)
100
+ + f" {node_type} '{name}' node_id={node_id}"
101
+ )
102
+
103
+
104
+ @app.command("member-add")
105
+ def add_member(
106
+ org_id: str = typer.Argument(..., help="UUID of the ORG/PROJECT node"),
107
+ user_id: str = typer.Argument(..., help="UUID of the USER node to add"),
108
+ role: str = typer.Option(
109
+ "MEMBER",
110
+ "--role",
111
+ "-r",
112
+ help="Role to assign: ADMIN | MEMBER | VIEWER (default: MEMBER)",
113
+ ),
114
+ profile: str | None = typer.Option(
115
+ None, "--profile", help="Config profile to use (dev/staging/prod)"
116
+ ),
117
+ ) -> None:
118
+ """
119
+ Add a user as a member of an organisation (creates a MEMBER_OF edge).
120
+
121
+ Example:
122
+ sov org member-add <org_id> <user_id>
123
+ sov org member-add <org_id> <user_id> --role ADMIN
124
+ """
125
+ tokens = load_tokens(profile=profile)
126
+ if not tokens:
127
+ typer.echo("Not logged in. Run: sov login", err=True)
128
+ raise typer.Exit(1)
129
+
130
+ api_url = get_api_url(profile)
131
+ headers = {
132
+ "Authorization": f"Bearer {tokens['access_token']}",
133
+ "Content-Type": "application/json",
134
+ }
135
+ payload = {"user_id": user_id, "role": role}
136
+
137
+ try:
138
+ resp = httpx.post(
139
+ f"{api_url}/api/v1/org/{org_id}/member",
140
+ headers=headers,
141
+ json=payload,
142
+ timeout=30,
143
+ )
144
+ resp.raise_for_status()
145
+ except httpx.HTTPStatusError as exc:
146
+ detail = exc.response.text
147
+ try:
148
+ detail = exc.response.json().get("detail", detail)
149
+ except Exception: # pylint: disable=broad-except
150
+ pass
151
+ typer.echo(f"Failed to add member: {detail}", err=True)
152
+ raise typer.Exit(1) from exc
153
+
154
+ typer.echo(
155
+ typer.style("✓ Member added", fg=typer.colors.GREEN)
156
+ + f" user={user_id} org={org_id} role={role}"
157
+ )
@@ -0,0 +1,317 @@
1
+ """Guard policy commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TypedDict
6
+ from urllib.parse import quote
7
+
8
+ import typer
9
+
10
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api, parse_json_arg, print_json
11
+
12
+ app = typer.Typer(help="Guard policy engine commands")
13
+
14
+
15
+ class ScopeOpts(TypedDict):
16
+ org_id: str | None
17
+ project_id: str | None
18
+ env_id: str | None
19
+
20
+
21
+ def _scope_opts(org_id: str | None, project_id: str | None, env_id: str | None) -> ScopeOpts:
22
+ return {
23
+ "org_id": org_id,
24
+ "project_id": project_id,
25
+ "env_id": env_id,
26
+ }
27
+
28
+
29
+ def _is_conflict_error(exc: CLIRequestError) -> bool:
30
+ status_code = getattr(exc, "status_code", None)
31
+ message = str(exc).lower()
32
+ return status_code == 409 or "already exists" in message
33
+
34
+
35
+ @app.command("templates")
36
+ def templates(
37
+ profile: str | None = typer.Option(None, "--profile"),
38
+ ) -> None:
39
+ """List built-in SPL policy templates."""
40
+ try:
41
+ result = call_api(
42
+ method="GET",
43
+ path="/api/v1/policies/templates",
44
+ profile=profile,
45
+ )
46
+ except CLIRequestError as exc:
47
+ typer.echo(str(exc), err=True)
48
+ raise typer.Exit(1) from exc
49
+ typer.echo(print_json(result))
50
+
51
+
52
+ @app.command("list")
53
+ def list_policies(
54
+ profile: str | None = typer.Option(None, "--profile"),
55
+ org_id: str | None = typer.Option(None, "--org"),
56
+ project_id: str | None = typer.Option(None, "--project"),
57
+ env_id: str | None = typer.Option(None, "--env"),
58
+ ) -> None:
59
+ """List active policies in scope."""
60
+ try:
61
+ result = call_api(
62
+ method="GET",
63
+ path="/api/v1/policies/",
64
+ profile=profile,
65
+ require_scope=False,
66
+ **_scope_opts(org_id, project_id, env_id),
67
+ )
68
+ except CLIRequestError as exc:
69
+ typer.echo(str(exc), err=True)
70
+ raise typer.Exit(1) from exc
71
+ typer.echo(print_json(result))
72
+
73
+
74
+ @app.command("get")
75
+ def get_policy(
76
+ policy_id: str = typer.Argument(...),
77
+ profile: str | None = typer.Option(None, "--profile"),
78
+ org_id: str | None = typer.Option(None, "--org"),
79
+ project_id: str | None = typer.Option(None, "--project"),
80
+ env_id: str | None = typer.Option(None, "--env"),
81
+ ) -> None:
82
+ """Fetch a policy by ID."""
83
+ safe_id = quote(policy_id, safe="")
84
+ try:
85
+ result = call_api(
86
+ method="GET",
87
+ path=f"/api/v1/policies/{safe_id}",
88
+ profile=profile,
89
+ require_scope=False,
90
+ **_scope_opts(org_id, project_id, env_id),
91
+ )
92
+ except CLIRequestError as exc:
93
+ typer.echo(str(exc), err=True)
94
+ raise typer.Exit(1) from exc
95
+ typer.echo(print_json(result))
96
+
97
+
98
+ @app.command("upsert")
99
+ def upsert_policy(
100
+ policy_id: str = typer.Argument(...),
101
+ name: str = typer.Option(..., "--name"),
102
+ spl_source: str = typer.Option(..., "--spl"),
103
+ org_id: str = typer.Option(..., "--org", help="Owning org UUID"),
104
+ profile: str | None = typer.Option(None, "--profile"),
105
+ project_id: str | None = typer.Option(None, "--project"),
106
+ env_id: str | None = typer.Option(None, "--env"),
107
+ ) -> None:
108
+ """Create or update a policy (tries create then update)."""
109
+ try:
110
+ create_result = call_api(
111
+ method="POST",
112
+ path="/api/v1/policies/",
113
+ profile=profile,
114
+ body={
115
+ "policy_id": policy_id,
116
+ "name": name,
117
+ "spl_source": spl_source,
118
+ "org_id": org_id,
119
+ },
120
+ require_scope=False,
121
+ **_scope_opts(org_id, project_id, env_id),
122
+ )
123
+ typer.echo(print_json(create_result))
124
+ return
125
+ except CLIRequestError as exc:
126
+ if not _is_conflict_error(exc):
127
+ typer.echo(str(exc), err=True)
128
+ raise typer.Exit(1) from exc
129
+
130
+ safe_id = quote(policy_id, safe="")
131
+ try:
132
+ update_result = call_api(
133
+ method="PUT",
134
+ path=f"/api/v1/policies/{safe_id}",
135
+ profile=profile,
136
+ body={"name": name, "spl_source": spl_source},
137
+ require_scope=False,
138
+ **_scope_opts(org_id, project_id, env_id),
139
+ )
140
+ except CLIRequestError as exc:
141
+ typer.echo(str(exc), err=True)
142
+ raise typer.Exit(1) from exc
143
+
144
+ typer.echo(print_json(update_result))
145
+
146
+
147
+ @app.command("delete")
148
+ def delete_policy(
149
+ policy_id: str = typer.Argument(...),
150
+ profile: str | None = typer.Option(None, "--profile"),
151
+ org_id: str | None = typer.Option(None, "--org"),
152
+ project_id: str | None = typer.Option(None, "--project"),
153
+ env_id: str | None = typer.Option(None, "--env"),
154
+ ) -> None:
155
+ """Delete (deactivate) a policy."""
156
+ safe_id = quote(policy_id, safe="")
157
+ try:
158
+ result = call_api(
159
+ method="DELETE",
160
+ path=f"/api/v1/policies/{safe_id}",
161
+ profile=profile,
162
+ require_scope=False,
163
+ **_scope_opts(org_id, project_id, env_id),
164
+ )
165
+ except CLIRequestError as exc:
166
+ typer.echo(str(exc), err=True)
167
+ raise typer.Exit(1) from exc
168
+ typer.echo(print_json(result))
169
+
170
+
171
+ @app.command("history")
172
+ def policy_history(
173
+ policy_id: str = typer.Argument(...),
174
+ profile: str | None = typer.Option(None, "--profile"),
175
+ org_id: str | None = typer.Option(None, "--org"),
176
+ project_id: str | None = typer.Option(None, "--project"),
177
+ env_id: str | None = typer.Option(None, "--env"),
178
+ ) -> None:
179
+ """List policy version history."""
180
+ safe_id = quote(policy_id, safe="")
181
+ try:
182
+ result = call_api(
183
+ method="GET",
184
+ path=f"/api/v1/policies/{safe_id}/history",
185
+ profile=profile,
186
+ require_scope=False,
187
+ **_scope_opts(org_id, project_id, env_id),
188
+ )
189
+ except CLIRequestError as exc:
190
+ typer.echo(str(exc), err=True)
191
+ raise typer.Exit(1) from exc
192
+ typer.echo(print_json(result))
193
+
194
+
195
+ @app.command("rollback")
196
+ def rollback_policy(
197
+ policy_id: str = typer.Argument(...),
198
+ version: int = typer.Option(..., "--version"),
199
+ profile: str | None = typer.Option(None, "--profile"),
200
+ org_id: str | None = typer.Option(None, "--org"),
201
+ project_id: str | None = typer.Option(None, "--project"),
202
+ env_id: str | None = typer.Option(None, "--env"),
203
+ ) -> None:
204
+ """Rollback policy to a historical version."""
205
+ safe_id = quote(policy_id, safe="")
206
+ try:
207
+ result = call_api(
208
+ method="POST",
209
+ path=f"/api/v1/policies/{safe_id}/rollback",
210
+ profile=profile,
211
+ body={"version": version},
212
+ require_scope=False,
213
+ **_scope_opts(org_id, project_id, env_id),
214
+ )
215
+ except CLIRequestError as exc:
216
+ typer.echo(str(exc), err=True)
217
+ raise typer.Exit(1) from exc
218
+ typer.echo(print_json(result))
219
+
220
+
221
+ @app.command("evaluate")
222
+ def evaluate_policy(
223
+ policy_id: str = typer.Argument(...),
224
+ context: str = typer.Option(..., "--context", help="JSON object"),
225
+ profile: str | None = typer.Option(None, "--profile"),
226
+ org_id: str | None = typer.Option(None, "--org"),
227
+ project_id: str | None = typer.Option(None, "--project"),
228
+ env_id: str | None = typer.Option(None, "--env"),
229
+ ) -> None:
230
+ """Evaluate policy against an explicit context."""
231
+ safe_id = quote(policy_id, safe="")
232
+ try:
233
+ parsed_context = parse_json_arg(context, label="--context")
234
+ if not isinstance(parsed_context, dict):
235
+ raise CLIRequestError("--context must be a JSON object")
236
+
237
+ result = call_api(
238
+ method="POST",
239
+ path=f"/api/v1/policies/{safe_id}/evaluate",
240
+ profile=profile,
241
+ body={"context": parsed_context},
242
+ require_scope=False,
243
+ **_scope_opts(org_id, project_id, env_id),
244
+ )
245
+ except CLIRequestError as exc:
246
+ typer.echo(str(exc), err=True)
247
+ raise typer.Exit(1) from exc
248
+
249
+ typer.echo(print_json(result))
250
+
251
+
252
+ @app.command("check")
253
+ def check_policy(
254
+ action: str = typer.Option(..., "--action", help="Action, e.g. sql:select or nosql:write"),
255
+ resource: str = typer.Option(..., "--resource", help="Resource name, table, or collection"),
256
+ context: str | None = typer.Option(None, "--context", help="Optional JSON context object"),
257
+ profile: str | None = typer.Option(None, "--profile"),
258
+ org_id: str | None = typer.Option(None, "--org"),
259
+ project_id: str | None = typer.Option(None, "--project"),
260
+ env_id: str | None = typer.Option(None, "--env"),
261
+ ) -> None:
262
+ """Preflight an action/resource against active Guard policies."""
263
+ try:
264
+ parsed_context = parse_json_arg(context, label="--context") if context else {}
265
+ if not isinstance(parsed_context, dict):
266
+ raise CLIRequestError("--context must be a JSON object")
267
+ if not org_id or not project_id:
268
+ raise CLIRequestError("--org and --project are required for policy check")
269
+
270
+ result = call_api(
271
+ method="POST",
272
+ path="/api/v1/policies/check",
273
+ profile=profile,
274
+ body={
275
+ "org_id": org_id,
276
+ "project_id": project_id,
277
+ "action": action,
278
+ "resource": resource,
279
+ "context": parsed_context,
280
+ },
281
+ require_scope=False,
282
+ **_scope_opts(org_id, project_id, env_id),
283
+ )
284
+ except CLIRequestError as exc:
285
+ typer.echo(str(exc), err=True)
286
+ raise typer.Exit(1) from exc
287
+
288
+ typer.echo(print_json(result))
289
+
290
+
291
+ @app.command("simulate")
292
+ def simulate_policy(
293
+ spl_source: str = typer.Option(..., "--spl", help="SPL policy source to dry-run"),
294
+ context: str = typer.Option("{}", "--context", help="JSON evaluation context"),
295
+ profile: str | None = typer.Option(None, "--profile"),
296
+ org_id: str | None = typer.Option(None, "--org"),
297
+ project_id: str | None = typer.Option(None, "--project"),
298
+ env_id: str | None = typer.Option(None, "--env"),
299
+ ) -> None:
300
+ """Simulate SPL policy evaluation without persisting a policy."""
301
+ try:
302
+ parsed_context = parse_json_arg(context, label="--context")
303
+ if parsed_context is None or not isinstance(parsed_context, dict):
304
+ raise CLIRequestError("--context must be a JSON object")
305
+ result = call_api(
306
+ method="POST",
307
+ path="/api/v1/policies/simulate",
308
+ profile=profile,
309
+ body={"spl_source": spl_source, "context": parsed_context},
310
+ require_scope=True,
311
+ **_scope_opts(org_id, project_id, env_id),
312
+ )
313
+ except CLIRequestError as exc:
314
+ typer.echo(str(exc), err=True)
315
+ raise typer.Exit(1) from exc
316
+
317
+ typer.echo(print_json(result))
@@ -0,0 +1,130 @@
1
+ """Project scaffold and linkage commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ from sovaria.models import SovariaError
10
+ from sovaria.project import (
11
+ DEFAULT_CONFIG_FILENAME,
12
+ create_project_from_config_file,
13
+ write_project_config_template,
14
+ )
15
+
16
+ from apps.cli.src.utils.config import set_scope
17
+ from apps.cli.src.utils.display import print_json, print_success
18
+ from apps.cli.src.utils.project_context import (
19
+ find_project_config,
20
+ link_project_to_profile,
21
+ project_status,
22
+ )
23
+
24
+ app = typer.Typer(help="Project scaffold and linkage commands")
25
+
26
+
27
+ @app.command("init-config")
28
+ def init_config(
29
+ name: str = typer.Argument("sovaria-app", help="Project name used in the generated config"),
30
+ output: str = typer.Option(
31
+ DEFAULT_CONFIG_FILENAME,
32
+ "--output",
33
+ "-o",
34
+ help="Path to write the template config JSON",
35
+ ),
36
+ force: bool = typer.Option(False, "--force", help="Overwrite existing config file if present"),
37
+ ) -> None:
38
+ """Create a template sovaria.config.json file."""
39
+ try:
40
+ path = write_project_config_template(output, name=name, overwrite=force)
41
+ except SovariaError as exc:
42
+ typer.echo(str(exc), err=True)
43
+ raise typer.Exit(1) from exc
44
+ typer.echo(f"Config template written: {path}")
45
+
46
+
47
+ @app.command("create")
48
+ def create(
49
+ config: str = typer.Option(
50
+ DEFAULT_CONFIG_FILENAME,
51
+ "--config",
52
+ "-c",
53
+ help="Path to sovaria project config JSON",
54
+ ),
55
+ directory: str = typer.Option(
56
+ ".",
57
+ "--dir",
58
+ help="Parent directory where the project folder will be created",
59
+ ),
60
+ force: bool = typer.Option(
61
+ False,
62
+ "--force",
63
+ help="Allow scaffold to overwrite existing files when possible",
64
+ ),
65
+ ) -> None:
66
+ """Create a project from sovaria.config.json."""
67
+ try:
68
+ result = create_project_from_config_file(config, target_dir=directory, force=force)
69
+ except SovariaError as exc:
70
+ typer.echo(str(exc), err=True)
71
+ raise typer.Exit(1) from exc
72
+
73
+ typer.echo(f"Project created: {result.project_root}")
74
+ typer.echo(f"Config copied: {result.config_path}")
75
+
76
+
77
+ @app.command("status")
78
+ def status() -> None:
79
+ """Show local sovaria.config.json linkage and resolved scope."""
80
+ print_json(project_status())
81
+
82
+
83
+ @app.command("link")
84
+ def link(
85
+ org_id: str = typer.Option(..., "--org", help="Organisation node ID"),
86
+ project_id: str = typer.Option(..., "--project", help="Project node ID"),
87
+ env_id: str | None = typer.Option(None, "--env", help="Environment ID"),
88
+ profile: str | None = typer.Option(None, "--profile"),
89
+ config: Path | None = typer.Option(
90
+ None,
91
+ "--config",
92
+ "-c",
93
+ help="Path to sovaria.config.json (auto-discovered when omitted)",
94
+ ),
95
+ ) -> None:
96
+ """Write org/project scope into sovaria.config.json and the active profile."""
97
+ config_path = config or find_project_config()
98
+ if config_path is None:
99
+ typer.echo(
100
+ f"No {DEFAULT_CONFIG_FILENAME} found. Run `sov init` or `sov project init-config` first.",
101
+ err=True,
102
+ )
103
+ raise typer.Exit(1)
104
+ try:
105
+ path = link_project_to_profile(
106
+ org_id=org_id,
107
+ project_id=project_id,
108
+ env_id=env_id,
109
+ profile=profile,
110
+ config_path=config_path,
111
+ )
112
+ except (FileNotFoundError, ValueError) as exc:
113
+ typer.echo(str(exc), err=True)
114
+ raise typer.Exit(1) from exc
115
+ print_success(f"Linked project scope in {path}")
116
+
117
+
118
+ @app.command("use")
119
+ def use(
120
+ org_id: str = typer.Argument(..., help="Organisation node ID"),
121
+ project_id: str = typer.Argument(..., help="Project node ID"),
122
+ env_id: str | None = typer.Option(None, "--env"),
123
+ profile: str | None = typer.Option(None, "--profile"),
124
+ ) -> None:
125
+ """Set default scope for CLI commands (profile only, no config file write)."""
126
+ set_scope(org_id=org_id, project_id=project_id, env_id=env_id, profile=profile)
127
+ print_success("Active scope updated.")
128
+ typer.echo(f" org_id : {org_id}")
129
+ typer.echo(f" project_id : {project_id}")
130
+ typer.echo(f" env_id : {env_id or '-'}")