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.
- solwyn_cli/__init__.py +3 -0
- solwyn_cli/_vendor/__init__.py +1 -0
- solwyn_cli/_vendor/validation.py +21 -0
- solwyn_cli/_vendor/wire.py +185 -0
- solwyn_cli/artifacts/__init__.py +727 -0
- solwyn_cli/artifacts/_atomic.py +79 -0
- solwyn_cli/auth.py +252 -0
- solwyn_cli/client.py +279 -0
- solwyn_cli/commands/__init__.py +1 -0
- solwyn_cli/commands/_shared.py +49 -0
- solwyn_cli/commands/agent_context.py +19 -0
- solwyn_cli/commands/budget.py +976 -0
- solwyn_cli/commands/config.py +110 -0
- solwyn_cli/commands/costs.py +186 -0
- solwyn_cli/commands/init.py +659 -0
- solwyn_cli/commands/keys.py +386 -0
- solwyn_cli/commands/login.py +205 -0
- solwyn_cli/commands/mcp.py +28 -0
- solwyn_cli/commands/projects.py +449 -0
- solwyn_cli/commands/status.py +173 -0
- solwyn_cli/commands/whoami.py +170 -0
- solwyn_cli/config.py +209 -0
- solwyn_cli/errors.py +114 -0
- solwyn_cli/exit_codes.py +56 -0
- solwyn_cli/help.py +19 -0
- solwyn_cli/main.py +135 -0
- solwyn_cli/mcp/__init__.py +5 -0
- solwyn_cli/mcp/server.py +728 -0
- solwyn_cli/output.py +263 -0
- solwyn_cli/runtime.py +115 -0
- solwyn_cli/spec/__init__.py +1 -0
- solwyn_cli/spec/openapi_snapshot.json +12357 -0
- solwyn_cli-0.1.0.dist-info/METADATA +60 -0
- solwyn_cli-0.1.0.dist-info/RECORD +37 -0
- solwyn_cli-0.1.0.dist-info/WHEEL +4 -0
- solwyn_cli-0.1.0.dist-info/entry_points.txt +3 -0
- solwyn_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
solwyn_cli/output.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Deterministic human and machine output rendering."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from typing import Any, TextIO
|
|
8
|
+
|
|
9
|
+
from pydantic import (
|
|
10
|
+
BaseModel,
|
|
11
|
+
ConfigDict,
|
|
12
|
+
JsonValue,
|
|
13
|
+
SecretStr,
|
|
14
|
+
TypeAdapter,
|
|
15
|
+
model_validator,
|
|
16
|
+
)
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from rich.panel import Panel
|
|
19
|
+
from rich.table import Table
|
|
20
|
+
from rich.text import Text
|
|
21
|
+
|
|
22
|
+
from solwyn_cli.errors import ApiError
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_COMMAND_SCHEMA = re.compile(r"^solwyn\.[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*\.v1$")
|
|
26
|
+
_JSON_ADAPTER = TypeAdapter(Any)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class OutputUsageError(ValueError):
|
|
30
|
+
"""An output option or payload violates the public CLI contract."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class OutputFormat(StrEnum):
|
|
34
|
+
"""The only supported presentation formats."""
|
|
35
|
+
|
|
36
|
+
TABLE = "table"
|
|
37
|
+
JSON = "json"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class OutputError(BaseModel):
|
|
41
|
+
"""Safe local error fields accepted by the output engine."""
|
|
42
|
+
|
|
43
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
44
|
+
|
|
45
|
+
code: str
|
|
46
|
+
message: str
|
|
47
|
+
hint: str | None = None
|
|
48
|
+
detail: JsonValue | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TableData(BaseModel):
|
|
52
|
+
"""Validated human table cells supplied by a command."""
|
|
53
|
+
|
|
54
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
55
|
+
|
|
56
|
+
columns: tuple[str, ...]
|
|
57
|
+
rows: tuple[tuple[Any, ...], ...]
|
|
58
|
+
right_align: tuple[str, ...] = ()
|
|
59
|
+
|
|
60
|
+
@model_validator(mode="after")
|
|
61
|
+
def validate_shape(self) -> "TableData":
|
|
62
|
+
"""Require non-empty columns and consistently shaped rows."""
|
|
63
|
+
if not self.columns:
|
|
64
|
+
raise ValueError("Tables require at least one column")
|
|
65
|
+
if len(set(self.columns)) != len(self.columns):
|
|
66
|
+
raise ValueError("Table column names must be unique")
|
|
67
|
+
if any(len(row) != len(self.columns) for row in self.rows):
|
|
68
|
+
raise ValueError("Every table row must have the same number of cells")
|
|
69
|
+
if not set(self.right_align).issubset(self.columns):
|
|
70
|
+
raise ValueError("Right-aligned columns must exist in the table")
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class OneTimeSecretPanel(BaseModel):
|
|
75
|
+
"""A redacted in-memory secret rendered once in table output."""
|
|
76
|
+
|
|
77
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
78
|
+
|
|
79
|
+
title: str
|
|
80
|
+
value: SecretStr
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _parse_format(value: str | OutputFormat, *, source: str) -> OutputFormat:
|
|
84
|
+
try:
|
|
85
|
+
return OutputFormat(value)
|
|
86
|
+
except ValueError:
|
|
87
|
+
raise OutputUsageError(f"Invalid {source} output format.") from None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def resolve_output_format(
|
|
91
|
+
*,
|
|
92
|
+
explicit_format: str | OutputFormat | None,
|
|
93
|
+
json_flag: bool,
|
|
94
|
+
environ: Mapping[str, str],
|
|
95
|
+
config_format: str | OutputFormat | None,
|
|
96
|
+
) -> OutputFormat:
|
|
97
|
+
"""Resolve explicit intent, then environment, config, and the table default."""
|
|
98
|
+
if json_flag and explicit_format is not None:
|
|
99
|
+
parsed_explicit = _parse_format(explicit_format, source="explicit")
|
|
100
|
+
if parsed_explicit is OutputFormat.TABLE:
|
|
101
|
+
raise OutputUsageError("Conflicting output format options.")
|
|
102
|
+
return OutputFormat.JSON
|
|
103
|
+
if json_flag:
|
|
104
|
+
return OutputFormat.JSON
|
|
105
|
+
if explicit_format is not None:
|
|
106
|
+
return _parse_format(explicit_format, source="explicit")
|
|
107
|
+
if "SOLWYN_FORMAT" in environ:
|
|
108
|
+
try:
|
|
109
|
+
return OutputFormat(environ["SOLWYN_FORMAT"])
|
|
110
|
+
except ValueError:
|
|
111
|
+
raise OutputUsageError("Invalid SOLWYN_FORMAT output format.") from None
|
|
112
|
+
if config_format is not None:
|
|
113
|
+
return _parse_format(config_format, source="configured")
|
|
114
|
+
return OutputFormat.TABLE
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def color_enabled(
|
|
118
|
+
*,
|
|
119
|
+
is_tty: bool,
|
|
120
|
+
no_color: bool,
|
|
121
|
+
environ: Mapping[str, str],
|
|
122
|
+
) -> bool:
|
|
123
|
+
"""Return whether Rich may emit color for one human stream."""
|
|
124
|
+
return is_tty and not no_color and "NO_COLOR" not in environ
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _json_value(value: object) -> Any:
|
|
128
|
+
return _JSON_ADAPTER.dump_python(value, mode="json")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _terminal_safe(value: object) -> str:
|
|
132
|
+
"""Neutralize terminal control characters in caller-provided human text."""
|
|
133
|
+
return "".join(
|
|
134
|
+
"\N{REPLACEMENT CHARACTER}"
|
|
135
|
+
if ord(character) < 0x20 or 0x7F <= ord(character) <= 0x9F
|
|
136
|
+
else character
|
|
137
|
+
for character in str(value)
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _success_data(data: Mapping[str, object] | BaseModel) -> dict[str, Any]:
|
|
142
|
+
serialized = _json_value(data)
|
|
143
|
+
if not isinstance(serialized, dict):
|
|
144
|
+
raise OutputUsageError("Success payload data must be an object.")
|
|
145
|
+
if "schema" in serialized:
|
|
146
|
+
raise OutputUsageError(
|
|
147
|
+
"Success payload data contains the reserved schema field."
|
|
148
|
+
)
|
|
149
|
+
return serialized
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class OutputEngine:
|
|
153
|
+
"""Write contract-safe command output to injectable streams."""
|
|
154
|
+
|
|
155
|
+
def __init__(
|
|
156
|
+
self,
|
|
157
|
+
*,
|
|
158
|
+
output_format: OutputFormat,
|
|
159
|
+
stdout: TextIO,
|
|
160
|
+
stderr: TextIO,
|
|
161
|
+
stdout_is_tty: bool,
|
|
162
|
+
stderr_is_tty: bool,
|
|
163
|
+
no_color: bool,
|
|
164
|
+
environ: Mapping[str, str],
|
|
165
|
+
width: int = 80,
|
|
166
|
+
) -> None:
|
|
167
|
+
self.output_format = output_format
|
|
168
|
+
self._stdout = stdout
|
|
169
|
+
self._stderr = stderr
|
|
170
|
+
self._stdout_color = color_enabled(
|
|
171
|
+
is_tty=stdout_is_tty,
|
|
172
|
+
no_color=no_color,
|
|
173
|
+
environ=environ,
|
|
174
|
+
)
|
|
175
|
+
self._stderr_color = color_enabled(
|
|
176
|
+
is_tty=stderr_is_tty,
|
|
177
|
+
no_color=no_color,
|
|
178
|
+
environ=environ,
|
|
179
|
+
)
|
|
180
|
+
self._width = width
|
|
181
|
+
|
|
182
|
+
def _console(self, *, error: bool) -> Console:
|
|
183
|
+
color = self._stderr_color if error else self._stdout_color
|
|
184
|
+
return Console(
|
|
185
|
+
file=self._stderr if error else self._stdout,
|
|
186
|
+
width=self._width,
|
|
187
|
+
force_terminal=color,
|
|
188
|
+
no_color=not color,
|
|
189
|
+
color_system="standard" if color else None,
|
|
190
|
+
highlight=False,
|
|
191
|
+
soft_wrap=False,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def _write_json(self, value: Mapping[str, object], *, error: bool) -> None:
|
|
195
|
+
stream = self._stderr if error else self._stdout
|
|
196
|
+
serialized = _json_value(value)
|
|
197
|
+
stream.write(
|
|
198
|
+
json.dumps(serialized, ensure_ascii=True, separators=(",", ":")) + "\n"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def write_success(
|
|
202
|
+
self,
|
|
203
|
+
*,
|
|
204
|
+
schema: str,
|
|
205
|
+
data: Mapping[str, object] | BaseModel,
|
|
206
|
+
table: TableData | None = None,
|
|
207
|
+
one_time_secret: OneTimeSecretPanel | None = None,
|
|
208
|
+
) -> None:
|
|
209
|
+
"""Write one schema-bearing JSON object or its explicit human table."""
|
|
210
|
+
if _COMMAND_SCHEMA.fullmatch(schema) is None:
|
|
211
|
+
raise OutputUsageError("Invalid command schema.")
|
|
212
|
+
payload = _success_data(data)
|
|
213
|
+
if self.output_format is OutputFormat.JSON:
|
|
214
|
+
self._write_json({"schema": schema, **payload}, error=False)
|
|
215
|
+
return
|
|
216
|
+
if table is None:
|
|
217
|
+
raise OutputUsageError("Table output requires human table data.")
|
|
218
|
+
rich_table = Table(
|
|
219
|
+
box=None,
|
|
220
|
+
padding=(0, 1),
|
|
221
|
+
pad_edge=False,
|
|
222
|
+
header_style="bold cyan",
|
|
223
|
+
)
|
|
224
|
+
for column in table.columns:
|
|
225
|
+
rich_table.add_column(
|
|
226
|
+
Text(_terminal_safe(column)),
|
|
227
|
+
justify="right" if column in table.right_align else "left",
|
|
228
|
+
)
|
|
229
|
+
for row in table.rows:
|
|
230
|
+
rich_table.add_row(*(Text(_terminal_safe(cell)) for cell in row))
|
|
231
|
+
console = self._console(error=False)
|
|
232
|
+
console.print(rich_table)
|
|
233
|
+
if one_time_secret is not None:
|
|
234
|
+
console.print(
|
|
235
|
+
Panel(
|
|
236
|
+
Text(_terminal_safe(one_time_secret.value.get_secret_value())),
|
|
237
|
+
title=Text(_terminal_safe(one_time_secret.title)),
|
|
238
|
+
expand=False,
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
def write_error(self, error: ApiError | OutputError) -> None:
|
|
243
|
+
"""Write a stable machine envelope or a detail-free human error."""
|
|
244
|
+
payload = {
|
|
245
|
+
"schema": "solwyn.error.v1",
|
|
246
|
+
"code": error.code,
|
|
247
|
+
"message": error.message,
|
|
248
|
+
"hint": error.hint,
|
|
249
|
+
"detail": error.detail,
|
|
250
|
+
}
|
|
251
|
+
if self.output_format is OutputFormat.JSON:
|
|
252
|
+
self._write_json(payload, error=True)
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
message = Text("Error ", style="bold red")
|
|
256
|
+
message.append(Text(f"[{_terminal_safe(error.code)}]: ", style="bold"))
|
|
257
|
+
message.append(Text(_terminal_safe(error.message)))
|
|
258
|
+
console = self._console(error=True)
|
|
259
|
+
console.print(message)
|
|
260
|
+
if error.hint is not None:
|
|
261
|
+
hint = Text("Hint: ", style="bold yellow")
|
|
262
|
+
hint.append(Text(_terminal_safe(error.hint)))
|
|
263
|
+
console.print(hint)
|
solwyn_cli/runtime.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Resolved global command context and output construction."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from pydantic import BaseModel, ConfigDict
|
|
9
|
+
|
|
10
|
+
from solwyn_cli.config import Config, load_config
|
|
11
|
+
from solwyn_cli.output import OutputEngine, OutputFormat, resolve_output_format
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CommandContext(BaseModel):
|
|
15
|
+
"""Non-secret global settings resolved once for a command invocation."""
|
|
16
|
+
|
|
17
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
18
|
+
|
|
19
|
+
api_url: str
|
|
20
|
+
project: str | None
|
|
21
|
+
output_format: OutputFormat
|
|
22
|
+
no_color: bool
|
|
23
|
+
environment_no_color: bool
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def resolve_command_context(
|
|
27
|
+
*,
|
|
28
|
+
api_url: str | None,
|
|
29
|
+
project: str | None,
|
|
30
|
+
output_format: str | None,
|
|
31
|
+
json_flag: bool,
|
|
32
|
+
no_color: bool,
|
|
33
|
+
environ: Mapping[str, str] | None = None,
|
|
34
|
+
) -> CommandContext:
|
|
35
|
+
"""Resolve CLI, environment, config, and default precedence."""
|
|
36
|
+
environment = os.environ if environ is None else environ
|
|
37
|
+
config = load_config()
|
|
38
|
+
resolved_api_url = api_url or environment.get("SOLWYN_API_URL") or config.api_url
|
|
39
|
+
validated_url = Config(api_url=resolved_api_url).api_url
|
|
40
|
+
return CommandContext(
|
|
41
|
+
api_url=validated_url,
|
|
42
|
+
project=(
|
|
43
|
+
project or environment.get("SOLWYN_PROJECT_ID") or config.default_project
|
|
44
|
+
),
|
|
45
|
+
output_format=resolve_output_format(
|
|
46
|
+
explicit_format=output_format,
|
|
47
|
+
json_flag=json_flag,
|
|
48
|
+
environ=environment,
|
|
49
|
+
config_format=config.format,
|
|
50
|
+
),
|
|
51
|
+
no_color=no_color,
|
|
52
|
+
environment_no_color="NO_COLOR" in environment,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def validate_output_intent(
|
|
57
|
+
*,
|
|
58
|
+
output_format: str | None,
|
|
59
|
+
json_flag: bool,
|
|
60
|
+
environ: Mapping[str, str] | None = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
"""Validate explicit and environment output intent without loading config."""
|
|
63
|
+
environment = os.environ if environ is None else environ
|
|
64
|
+
resolve_output_format(
|
|
65
|
+
explicit_format=output_format,
|
|
66
|
+
json_flag=json_flag,
|
|
67
|
+
environ=environment,
|
|
68
|
+
config_format=None,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def command_context(context: typer.Context) -> CommandContext:
|
|
73
|
+
"""Return the callback-populated context for a registered command."""
|
|
74
|
+
if not isinstance(context.obj, CommandContext):
|
|
75
|
+
raise RuntimeError("Command context was not initialized.")
|
|
76
|
+
return context.obj
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def output_engine(context: typer.Context) -> OutputEngine:
|
|
80
|
+
"""Build an output engine over the invocation's isolated streams."""
|
|
81
|
+
resolved = command_context(context)
|
|
82
|
+
return OutputEngine(
|
|
83
|
+
output_format=resolved.output_format,
|
|
84
|
+
stdout=sys.stdout,
|
|
85
|
+
stderr=sys.stderr,
|
|
86
|
+
stdout_is_tty=sys.stdout.isatty(),
|
|
87
|
+
stderr_is_tty=sys.stderr.isatty(),
|
|
88
|
+
no_color=resolved.no_color,
|
|
89
|
+
environ={"NO_COLOR": ""} if resolved.environment_no_color else {},
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def bootstrap_output_engine(
|
|
94
|
+
*,
|
|
95
|
+
output_format: str | None,
|
|
96
|
+
json_flag: bool,
|
|
97
|
+
no_color: bool,
|
|
98
|
+
environ: Mapping[str, str] | None = None,
|
|
99
|
+
) -> OutputEngine:
|
|
100
|
+
"""Build output before configuration can be safely loaded."""
|
|
101
|
+
environment = os.environ if environ is None else environ
|
|
102
|
+
return OutputEngine(
|
|
103
|
+
output_format=resolve_output_format(
|
|
104
|
+
explicit_format=output_format,
|
|
105
|
+
json_flag=json_flag,
|
|
106
|
+
environ=environment,
|
|
107
|
+
config_format=None,
|
|
108
|
+
),
|
|
109
|
+
stdout=sys.stdout,
|
|
110
|
+
stderr=sys.stderr,
|
|
111
|
+
stdout_is_tty=sys.stdout.isatty(),
|
|
112
|
+
stderr_is_tty=sys.stderr.isatty(),
|
|
113
|
+
no_color=no_color,
|
|
114
|
+
environ={"NO_COLOR": ""} if "NO_COLOR" in environment else {},
|
|
115
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Checked-in API contract snapshot used for drift detection."""
|