solwyn-cli 0.1.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.
@@ -0,0 +1,170 @@
1
+ """Current identity command."""
2
+
3
+ from typing import Literal
4
+
5
+ import typer
6
+ from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
7
+
8
+ from solwyn_cli.auth import (
9
+ AuthError,
10
+ JwtCredential,
11
+ ProjectKeyCredential,
12
+ resolve_credential,
13
+ )
14
+ from solwyn_cli.client import SolwynClient
15
+ from solwyn_cli.commands._shared import exit_with_error
16
+ from solwyn_cli.errors import ApiError
17
+ from solwyn_cli.exit_codes import ErrorSemantic
18
+ from solwyn_cli.help import help_contract
19
+ from solwyn_cli.output import OutputError, TableData
20
+ from solwyn_cli.runtime import command_context, output_engine
21
+
22
+
23
+ WHOAMI_HELP = help_contract(
24
+ endpoint=("local key identity or GET /api/v1/account/me and GET /api/v1/projects"),
25
+ auth="any",
26
+ exits="0 success; 1 generic/API; 2 usage; 3 auth",
27
+ schema="solwyn.whoami.v1",
28
+ )
29
+
30
+
31
+ class _Identity(BaseModel):
32
+ model_config = ConfigDict(extra="ignore")
33
+
34
+ user_id: str
35
+ email: str
36
+ display_name: str
37
+ subscription_tier: Literal["free", "founder", "team", "scale"]
38
+
39
+
40
+ class _Project(BaseModel):
41
+ model_config = ConfigDict(extra="ignore")
42
+
43
+ id: str
44
+ name: str
45
+
46
+
47
+ class _WhoamiData(BaseModel):
48
+ model_config = ConfigDict(extra="forbid")
49
+
50
+ auth_type: Literal["jwt", "project_key"]
51
+ auth_source: Literal["keyring", "environment"]
52
+ configured_project: str | None
53
+ user: _Identity | None
54
+ projects: list[_Project]
55
+ hint: str | None
56
+
57
+
58
+ _PROJECTS = TypeAdapter(list[_Project])
59
+
60
+
61
+ def _key_data(credential: ProjectKeyCredential, project: str | None) -> _WhoamiData:
62
+ return _WhoamiData(
63
+ auth_type="project_key",
64
+ auth_source=credential.source,
65
+ configured_project=project,
66
+ user=None,
67
+ projects=[],
68
+ hint=(
69
+ None
70
+ if project is not None
71
+ else "Choose a project with --project, SOLWYN_PROJECT_ID, or config."
72
+ ),
73
+ )
74
+
75
+
76
+ def _jwt_data(context: typer.Context, credential: JwtCredential) -> _WhoamiData:
77
+ runtime = command_context(context)
78
+ try:
79
+ with SolwynClient(
80
+ api_url=runtime.api_url,
81
+ credential=credential,
82
+ ) as client:
83
+ identity_response = client.request("GET", "/api/v1/account/me")
84
+ projects_response = client.request("GET", "/api/v1/projects")
85
+ identity = _Identity.model_validate(identity_response.json())
86
+ projects = _PROJECTS.validate_python(projects_response.json())
87
+ except ApiError as error:
88
+ exit_with_error(context, error)
89
+ except AuthError as error:
90
+ exit_with_error(
91
+ context,
92
+ OutputError(code="authentication_failed", message=str(error)),
93
+ semantic=ErrorSemantic.AUTH,
94
+ )
95
+ except (ValidationError, ValueError, TypeError):
96
+ exit_with_error(
97
+ context,
98
+ OutputError(
99
+ code="invalid_identity_response",
100
+ message="The identity response was invalid.",
101
+ ),
102
+ )
103
+ return _WhoamiData(
104
+ auth_type="jwt",
105
+ auth_source="keyring",
106
+ configured_project=None,
107
+ user=identity,
108
+ projects=projects,
109
+ hint=None,
110
+ )
111
+
112
+
113
+ def _table(data: _WhoamiData) -> TableData:
114
+ rows: list[tuple[str, str]] = [
115
+ ("Authentication", data.auth_type),
116
+ ("Source", data.auth_source),
117
+ ]
118
+ if data.auth_type == "project_key":
119
+ rows.append(("Configured project", data.configured_project or "not configured"))
120
+ if data.hint is not None:
121
+ rows.append(("Hint", data.hint))
122
+ elif data.user is not None:
123
+ rows.extend(
124
+ [
125
+ ("User", data.user.display_name),
126
+ ("Email", data.user.email),
127
+ ("Tier", data.user.subscription_tier),
128
+ ]
129
+ )
130
+ rows.extend(
131
+ ("Project", f"{project.id} ({project.name})") for project in data.projects
132
+ )
133
+ return TableData(
134
+ columns=("Field", "Value"),
135
+ rows=tuple(rows),
136
+ right_align=("Value",),
137
+ )
138
+
139
+
140
+ def whoami(context: typer.Context) -> None:
141
+ """Show the current Solwyn identity and project context."""
142
+ try:
143
+ credential = resolve_credential()
144
+ except AuthError as error:
145
+ exit_with_error(
146
+ context,
147
+ OutputError(code="authentication_failed", message=str(error)),
148
+ semantic=ErrorSemantic.AUTH,
149
+ )
150
+ if credential is None:
151
+ exit_with_error(
152
+ context,
153
+ OutputError(
154
+ code="authentication_required",
155
+ message="Authentication is required.",
156
+ hint="Set SOLWYN_API_KEY or run solwyn login.",
157
+ ),
158
+ semantic=ErrorSemantic.AUTH,
159
+ )
160
+ runtime = command_context(context)
161
+ data = (
162
+ _key_data(credential, runtime.project)
163
+ if isinstance(credential, ProjectKeyCredential)
164
+ else _jwt_data(context, credential)
165
+ )
166
+ output_engine(context).write_success(
167
+ schema="solwyn.whoami.v1",
168
+ data=data,
169
+ table=_table(data),
170
+ )
solwyn_cli/config.py ADDED
@@ -0,0 +1,209 @@
1
+ """Allowlisted, non-secret local configuration for the Solwyn CLI."""
2
+
3
+ import os
4
+ import tempfile
5
+ import tomllib
6
+ from collections.abc import Mapping
7
+ from contextlib import contextmanager
8
+ from pathlib import Path
9
+ from typing import BinaryIO, Iterator, Literal
10
+ from urllib.parse import urlsplit
11
+
12
+ import tomli_w
13
+ from platformdirs import user_config_dir
14
+ from pydantic import BaseModel, ConfigDict, ValidationError, field_validator
15
+
16
+ if os.name == "nt":
17
+ import msvcrt
18
+ else:
19
+ import fcntl
20
+
21
+
22
+ class ConfigLoadError(ValueError):
23
+ """Configuration could not be safely loaded."""
24
+
25
+
26
+ class ConfigWriteError(OSError):
27
+ """Configuration could not be safely persisted."""
28
+
29
+
30
+ class Config(BaseModel):
31
+ """Persisted CLI settings; credentials are intentionally not modeled."""
32
+
33
+ model_config = ConfigDict(extra="ignore", hide_input_in_errors=True)
34
+
35
+ api_url: str = "https://api.solwyn.ai"
36
+ default_project: str | None = None
37
+ format: Literal["table", "json"] = "table"
38
+
39
+ @field_validator("api_url")
40
+ @classmethod
41
+ def validate_api_url(cls, value: str) -> str:
42
+ """Require a credential-free absolute HTTP(S) API base URL."""
43
+ try:
44
+ parsed = urlsplit(value)
45
+ parsed.port
46
+ except ValueError:
47
+ raise ValueError("api_url must be a safe absolute HTTP(S) URL") from None
48
+
49
+ authority = parsed.netloc.rsplit("@", maxsplit=1)[-1]
50
+ if (
51
+ value != value.strip()
52
+ or "\\" in value
53
+ or any(character.isspace() for character in value)
54
+ or parsed.scheme not in {"http", "https"}
55
+ or parsed.hostname is None
56
+ or parsed.username is not None
57
+ or parsed.password is not None
58
+ or "?" in value
59
+ or "#" in value
60
+ or authority.endswith(":")
61
+ ):
62
+ raise ValueError("api_url must be a safe absolute HTTP(S) URL")
63
+ return value
64
+
65
+
66
+ CONFIG_KEYS = frozenset(Config.model_fields)
67
+
68
+
69
+ def get_config_path() -> Path:
70
+ """Return the platform-native path to the user's local configuration."""
71
+ return Path(user_config_dir("solwyn")) / "config.toml"
72
+
73
+
74
+ def load_config() -> Config:
75
+ """Load allowlisted settings, returning defaults when no file exists."""
76
+ path = get_config_path()
77
+ if not path.exists():
78
+ return Config()
79
+
80
+ try:
81
+ with path.open("rb") as config_file:
82
+ values = tomllib.load(config_file)
83
+ return Config.model_validate(values)
84
+ except (
85
+ OSError,
86
+ UnicodeError,
87
+ tomllib.TOMLDecodeError,
88
+ ValidationError,
89
+ TypeError,
90
+ ) as exc:
91
+ raise ConfigLoadError(f"Could not load configuration at {path}.") from exc
92
+
93
+
94
+ def _acquire_lock(lock_file: BinaryIO) -> None:
95
+ """Acquire an exclusive advisory lock using the host platform API."""
96
+ if os.name == "nt":
97
+ lock_file.seek(0, os.SEEK_END)
98
+ if lock_file.tell() == 0:
99
+ lock_file.write(b"\0")
100
+ lock_file.flush()
101
+ lock_file.seek(0)
102
+ msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1)
103
+ else:
104
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
105
+
106
+
107
+ def _release_lock(lock_file: BinaryIO) -> None:
108
+ """Release a lock acquired by :func:`_acquire_lock`."""
109
+ if os.name == "nt":
110
+ lock_file.seek(0)
111
+ msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
112
+ else:
113
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
114
+
115
+
116
+ @contextmanager
117
+ def _config_lock(path: Path) -> Iterator[None]:
118
+ """Hold the cross-process lock associated with a configuration path."""
119
+ lock_path = path.with_suffix(f"{path.suffix}.lock")
120
+ try:
121
+ path.parent.mkdir(parents=True, exist_ok=True)
122
+ lock_file = lock_path.open("a+b")
123
+ try:
124
+ _acquire_lock(lock_file)
125
+ except OSError:
126
+ lock_file.close()
127
+ raise
128
+ except OSError as exc:
129
+ raise ConfigWriteError(f"Could not lock configuration at {path}.") from exc
130
+
131
+ try:
132
+ yield
133
+ finally:
134
+ try:
135
+ _release_lock(lock_file)
136
+ except OSError as exc:
137
+ raise ConfigWriteError(
138
+ f"Could not unlock configuration at {path}."
139
+ ) from exc
140
+ finally:
141
+ lock_file.close()
142
+
143
+
144
+ def _fsync_parent_directory(path: Path) -> None:
145
+ """Durably record an atomic replace on POSIX; Windows has no directory fsync."""
146
+ if os.name == "nt":
147
+ return
148
+ flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
149
+ directory_fd = os.open(path.parent, flags)
150
+ try:
151
+ os.fsync(directory_fd)
152
+ finally:
153
+ os.close(directory_fd)
154
+
155
+
156
+ def save_config(config: Config, *, omit_default_api_url: bool = False) -> None:
157
+ """Atomically persist only modeled, non-null settings."""
158
+ path = get_config_path()
159
+ values = config.model_dump(include=CONFIG_KEYS, exclude_none=True)
160
+ if omit_default_api_url and config.api_url == Config().api_url:
161
+ values.pop("api_url", None)
162
+ content = tomli_w.dumps(values)
163
+ temporary_path: Path | None = None
164
+
165
+ try:
166
+ path.parent.mkdir(parents=True, exist_ok=True)
167
+ with tempfile.NamedTemporaryFile(
168
+ mode="w",
169
+ encoding="utf-8",
170
+ dir=path.parent,
171
+ prefix=f".{path.name}.",
172
+ suffix=".tmp",
173
+ delete=False,
174
+ ) as temporary_file:
175
+ temporary_path = Path(temporary_file.name)
176
+ temporary_file.write(content)
177
+ temporary_file.flush()
178
+ os.fsync(temporary_file.fileno())
179
+ os.replace(temporary_path, path)
180
+ _fsync_parent_directory(path)
181
+ except OSError as exc:
182
+ raise ConfigWriteError(f"Could not write configuration at {path}.") from exc
183
+ finally:
184
+ if temporary_path is not None:
185
+ temporary_path.unlink(missing_ok=True)
186
+
187
+
188
+ def update_config_value(key: str, value: str) -> Config:
189
+ """Atomically read, validate, and update one allowlisted setting."""
190
+ return update_config_values({key: value})
191
+
192
+
193
+ def update_config_values(
194
+ values: Mapping[str, object], *, omit_default_api_url: bool = False
195
+ ) -> Config:
196
+ """Atomically merge and persist multiple allowlisted settings under one lock."""
197
+ invalid_keys = set(values).difference(CONFIG_KEYS)
198
+ if invalid_keys:
199
+ invalid_key = sorted(invalid_keys)[0]
200
+ raise ValueError(f"Invalid configuration key: {invalid_key}")
201
+
202
+ path = get_config_path()
203
+ with _config_lock(path):
204
+ current = load_config()
205
+ updated = Config.model_validate(
206
+ {**current.model_dump(include=CONFIG_KEYS), **values}
207
+ )
208
+ save_config(updated, omit_default_api_url=omit_default_api_url)
209
+ return updated
solwyn_cli/errors.py ADDED
@@ -0,0 +1,114 @@
1
+ """Safe typed errors for HTTP and API failures."""
2
+
3
+ import unicodedata
4
+ from typing import Any, TypeAlias
5
+ from urllib.parse import urlsplit
6
+
7
+ import httpx
8
+
9
+
10
+ RawDetail: TypeAlias = str | dict[str, Any]
11
+
12
+
13
+ class ApiError(RuntimeError):
14
+ """A structured request failure whose repr does not expose raw detail."""
15
+
16
+ def __init__(
17
+ self,
18
+ *,
19
+ status_code: int | None,
20
+ code: str,
21
+ message: str,
22
+ hint: str | None,
23
+ detail: RawDetail | None,
24
+ ) -> None:
25
+ super().__init__(message)
26
+ self.status_code = status_code
27
+ self.code = code
28
+ self.message = message
29
+ self.hint = hint
30
+ self.detail = detail
31
+
32
+
33
+ def _safe_cta_path(value: object) -> str | None:
34
+ if not isinstance(value, str):
35
+ return None
36
+ if not value.startswith("/") or value.startswith("//") or "\\" in value:
37
+ return None
38
+ if any(unicodedata.category(character).startswith("C") for character in value):
39
+ return None
40
+ try:
41
+ parsed = urlsplit(value)
42
+ except ValueError:
43
+ return None
44
+ if parsed.scheme or parsed.netloc or parsed.username or parsed.password:
45
+ return None
46
+ return value
47
+
48
+
49
+ def _upgrade_hint(cta_path: str) -> str:
50
+ return f"Review upgrade options in the dashboard: {cta_path}"
51
+
52
+
53
+ def _quota_hint(cta_path: str) -> str:
54
+ return f"Review usage and plan limits in the dashboard: {cta_path}"
55
+
56
+
57
+ def _project_limit_hint(cta_path: str) -> str:
58
+ return f"Manage project limits in the dashboard: {cta_path}"
59
+
60
+
61
+ _ENTITLEMENT_HINTS = {
62
+ "upgrade_required": _upgrade_hint,
63
+ "quota_exceeded": _quota_hint,
64
+ "project_limit_reached": _project_limit_hint,
65
+ }
66
+
67
+
68
+ def _response_detail(response: httpx.Response) -> RawDetail | None:
69
+ try:
70
+ payload = response.json()
71
+ except (ValueError, TypeError):
72
+ return None
73
+ if not isinstance(payload, dict):
74
+ return None
75
+ detail = payload.get("detail")
76
+ if isinstance(detail, (str, dict)):
77
+ return detail
78
+ return None
79
+
80
+
81
+ def error_from_response(
82
+ response: httpx.Response,
83
+ *,
84
+ code_override: str | None = None,
85
+ ) -> ApiError:
86
+ """Turn an unsuccessful response into a safe structured error."""
87
+ detail = _response_detail(response)
88
+ fallback_message = f"Request failed with status {response.status_code}."
89
+ code = code_override or "http_error"
90
+ message = detail if isinstance(detail, str) else fallback_message
91
+ hint = None
92
+
93
+ if isinstance(detail, dict):
94
+ server_code = detail.get("code")
95
+ server_message = detail.get("message")
96
+ if code_override is None and isinstance(server_code, str) and server_code:
97
+ code = server_code
98
+ if isinstance(server_message, str) and server_message:
99
+ message = server_message
100
+
101
+ hint_builder = (
102
+ _ENTITLEMENT_HINTS.get(code) if response.status_code == 402 else None
103
+ )
104
+ cta_path = _safe_cta_path(detail.get("cta_path"))
105
+ if hint_builder is not None and cta_path is not None:
106
+ hint = hint_builder(cta_path)
107
+
108
+ return ApiError(
109
+ status_code=response.status_code,
110
+ code=code,
111
+ message=message,
112
+ hint=hint,
113
+ detail=detail,
114
+ )
@@ -0,0 +1,56 @@
1
+ """Stable process exit codes for the Solwyn CLI machine contract."""
2
+
3
+ from enum import IntEnum, StrEnum
4
+
5
+ from solwyn_cli.auth import AuthError
6
+ from solwyn_cli.errors import ApiError
7
+
8
+
9
+ class ExitCode(IntEnum):
10
+ """Permanently frozen exit meanings across all CLI major versions."""
11
+
12
+ SUCCESS = 0
13
+ GENERIC_ERROR = 1
14
+ USAGE_ERROR = 2
15
+ AUTH_ERROR = 3
16
+ BUDGET_DENIED = 4
17
+ NOT_FOUND = 5
18
+
19
+
20
+ class ErrorSemantic(StrEnum):
21
+ """Explicit local semantics that cannot be inferred from HTTP status alone."""
22
+
23
+ GENERIC = "generic"
24
+ USAGE = "usage"
25
+ AUTH = "auth"
26
+ BUDGET_DENIED = "budget_denied"
27
+ NOT_FOUND = "not_found"
28
+
29
+
30
+ _SEMANTIC_EXIT_CODES = {
31
+ ErrorSemantic.GENERIC: ExitCode.GENERIC_ERROR,
32
+ ErrorSemantic.USAGE: ExitCode.USAGE_ERROR,
33
+ ErrorSemantic.AUTH: ExitCode.AUTH_ERROR,
34
+ ErrorSemantic.BUDGET_DENIED: ExitCode.BUDGET_DENIED,
35
+ ErrorSemantic.NOT_FOUND: ExitCode.NOT_FOUND,
36
+ }
37
+
38
+
39
+ def exit_code_for_error(
40
+ error: BaseException | None,
41
+ *,
42
+ semantic: ErrorSemantic | None = None,
43
+ ) -> ExitCode:
44
+ """Map typed failures without interfering with Click's native usage handling."""
45
+ if semantic is not None:
46
+ return _SEMANTIC_EXIT_CODES[semantic]
47
+ if error is None:
48
+ return ExitCode.SUCCESS
49
+ if isinstance(error, AuthError):
50
+ return ExitCode.AUTH_ERROR
51
+ if isinstance(error, ApiError):
52
+ if error.status_code == 401:
53
+ return ExitCode.AUTH_ERROR
54
+ if error.status_code == 404:
55
+ return ExitCode.NOT_FOUND
56
+ return ExitCode.GENERIC_ERROR
solwyn_cli/help.py ADDED
@@ -0,0 +1,19 @@
1
+ """Stable, machine-legible CLI help contract formatting."""
2
+
3
+
4
+ def help_contract(
5
+ *,
6
+ endpoint: str,
7
+ auth: str,
8
+ exits: str,
9
+ schema: str,
10
+ guidance: tuple[str, ...] = (),
11
+ ) -> str:
12
+ """Render one semantic help contract without terminal-specific formatting."""
13
+ contract = (
14
+ f"Endpoint: {endpoint}",
15
+ f"Auth: {auth}",
16
+ f"Exit codes: {exits}",
17
+ f"JSON schema: {schema}",
18
+ )
19
+ return "\n".join((*contract, *guidance))
solwyn_cli/main.py ADDED
@@ -0,0 +1,135 @@
1
+ """Command-line entrypoint for Solwyn."""
2
+
3
+ from typing import Annotated
4
+
5
+ import typer
6
+ from pydantic import ValidationError
7
+
8
+ from solwyn_cli import __version__
9
+ from solwyn_cli.commands.agent_context import AGENT_CONTEXT_HELP, agent_context
10
+ from solwyn_cli.commands.budget import app as budget_app
11
+ from solwyn_cli.commands.config import app as config_app
12
+ from solwyn_cli.commands.costs import COSTS_HELP, costs
13
+ from solwyn_cli.commands.init import INIT_HELP, init
14
+ from solwyn_cli.commands.login import LOGIN_HELP, LOGOUT_HELP, login, logout
15
+ from solwyn_cli.commands.mcp import MCP_HELP, McpLaunchOptions, mcp
16
+ from solwyn_cli.commands.keys import app as keys_app
17
+ from solwyn_cli.commands.projects import app as projects_app
18
+ from solwyn_cli.commands.status import STATUS_HELP, status
19
+ from solwyn_cli.commands.whoami import WHOAMI_HELP, whoami
20
+ from solwyn_cli.config import ConfigLoadError
21
+ from solwyn_cli.help import help_contract
22
+ from solwyn_cli.output import OutputError, OutputUsageError
23
+ from solwyn_cli.runtime import (
24
+ bootstrap_output_engine,
25
+ resolve_command_context,
26
+ validate_output_intent,
27
+ )
28
+
29
+ app = typer.Typer(
30
+ add_completion=False,
31
+ invoke_without_command=True,
32
+ no_args_is_help=True,
33
+ epilog=help_contract(
34
+ endpoint="composite command router",
35
+ auth="varies by command",
36
+ exits="0 success; 2 usage",
37
+ schema="varies by command",
38
+ guidance=(
39
+ "Agents: run solwyn agent-context for the compact capability contract.",
40
+ ),
41
+ ),
42
+ )
43
+ app.add_typer(budget_app, name="budget")
44
+ app.add_typer(config_app, name="config")
45
+ app.add_typer(keys_app, name="keys")
46
+ app.add_typer(projects_app, name="projects")
47
+ app.command("agent-context", epilog=AGENT_CONTEXT_HELP)(agent_context)
48
+ app.command("costs", epilog=COSTS_HELP)(costs)
49
+ app.command("init", epilog=INIT_HELP)(init)
50
+ app.command("login", epilog=LOGIN_HELP)(login)
51
+ app.command("logout", epilog=LOGOUT_HELP)(logout)
52
+ app.command("mcp", epilog=MCP_HELP)(mcp)
53
+ app.command("status", epilog=STATUS_HELP)(status)
54
+ app.command("whoami", epilog=WHOAMI_HELP)(whoami)
55
+
56
+
57
+ @app.callback()
58
+ def main(
59
+ context: typer.Context,
60
+ version: Annotated[
61
+ bool,
62
+ typer.Option(
63
+ "--version", help="Show the installed version and exit.", is_eager=True
64
+ ),
65
+ ] = False,
66
+ project: Annotated[
67
+ str | None,
68
+ typer.Option("--project", "-p", help="Project ID for this invocation."),
69
+ ] = None,
70
+ api_url: Annotated[
71
+ str | None,
72
+ typer.Option("--api-url", help="Solwyn API base URL."),
73
+ ] = None,
74
+ output_format: Annotated[
75
+ str | None,
76
+ typer.Option("--format", help="Output format: table or json."),
77
+ ] = None,
78
+ json_output: Annotated[
79
+ bool,
80
+ typer.Option("--json", help="Emit schema-bearing JSON output."),
81
+ ] = False,
82
+ no_color: Annotated[
83
+ bool,
84
+ typer.Option("--no-color", help="Disable terminal color."),
85
+ ] = False,
86
+ ) -> None:
87
+ """Work with Solwyn from the command line."""
88
+ if version:
89
+ typer.echo(f"solwyn {__version__}")
90
+ raise typer.Exit()
91
+ if context.invoked_subcommand == "mcp":
92
+ context.obj = McpLaunchOptions(api_url=api_url, project_id=project)
93
+ return
94
+ if context.invoked_subcommand in {"agent-context", "config"}:
95
+ return
96
+ try:
97
+ validate_output_intent(
98
+ output_format=output_format,
99
+ json_flag=json_output,
100
+ )
101
+ except OutputUsageError:
102
+ raise typer.BadParameter(
103
+ "Invalid or conflicting output options.", param_hint="format"
104
+ ) from None
105
+ try:
106
+ context.obj = resolve_command_context(
107
+ api_url=api_url,
108
+ project=project,
109
+ output_format=output_format,
110
+ json_flag=json_output,
111
+ no_color=no_color,
112
+ )
113
+ except OutputUsageError:
114
+ raise typer.BadParameter(
115
+ "Invalid or conflicting output options.", param_hint="format"
116
+ ) from None
117
+ except ValidationError:
118
+ raise typer.BadParameter("Invalid API URL.", param_hint="api-url") from None
119
+ except ConfigLoadError:
120
+ if context.invoked_subcommand == "init":
121
+ bootstrap_output_engine(
122
+ output_format=output_format,
123
+ json_flag=json_output,
124
+ no_color=no_color,
125
+ ).write_error(
126
+ OutputError(
127
+ code="config_load_failed",
128
+ message="The existing configuration could not be loaded.",
129
+ hint="Repair or remove the configuration file, then retry init.",
130
+ )
131
+ )
132
+ raise typer.Exit(code=1) from None
133
+ raise typer.BadParameter(
134
+ "Could not load configuration.", param_hint="config"
135
+ ) from None