sanityops-cli 0.1.3__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.
- sanityops_cli/__init__.py +16 -0
- sanityops_cli/agents/__init__.py +14 -0
- sanityops_cli/agents/repair_agent/__init__.py +19 -0
- sanityops_cli/agents/repair_agent/agent.py +230 -0
- sanityops_cli/agents/repair_agent/prompts.py +100 -0
- sanityops_cli/agents/repair_agent/tools/__init__.py +19 -0
- sanityops_cli/agents/repair_agent/tools/store_repairs_tool.py +144 -0
- sanityops_cli/agents/scanner_agent/agent.py +332 -0
- sanityops_cli/agents/scanner_agent/hooks/progress_hook.py +152 -0
- sanityops_cli/agents/scanner_agent/models/finding.py +120 -0
- sanityops_cli/agents/scanner_agent/prompts.py +316 -0
- sanityops_cli/agents/scanner_agent/tools/grep_tool.py +667 -0
- sanityops_cli/agents/scanner_agent/tools/listfiles_tool.py +88 -0
- sanityops_cli/agents/scanner_agent/tools/readfile_tool.py +804 -0
- sanityops_cli/agents/scanner_agent/tools/storefindings_tool.py +178 -0
- sanityops_cli/api/__init__.py +16 -0
- sanityops_cli/api/client.py +403 -0
- sanityops_cli/commands/__init__.py +14 -0
- sanityops_cli/commands/config.py +312 -0
- sanityops_cli/commands/init.py +132 -0
- sanityops_cli/commands/inspect.py +646 -0
- sanityops_cli/constants/__init__.py +14 -0
- sanityops_cli/constants/config_defaults.py +22 -0
- sanityops_cli/constants/exit_codes.py +19 -0
- sanityops_cli/defect_checker/__init__.py +16 -0
- sanityops_cli/defect_checker/checker.py +100 -0
- sanityops_cli/defect_checker/llm_config.py +112 -0
- sanityops_cli/defect_checker/markdown_reporter.py +249 -0
- sanityops_cli/defect_checker/renderer.py +203 -0
- sanityops_cli/exceptions/__init__.py +14 -0
- sanityops_cli/exceptions/api_exceptions.py +60 -0
- sanityops_cli/exceptions/base_exceptions.py +25 -0
- sanityops_cli/help_panel.py +49 -0
- sanityops_cli/logging/__init__.py +18 -0
- sanityops_cli/logging/logger.py +108 -0
- sanityops_cli/main.py +123 -0
- sanityops_cli/progress/__init__.py +18 -0
- sanityops_cli/progress/tracker.py +159 -0
- sanityops_cli/renderers/__init__.py +14 -0
- sanityops_cli/renderers/command_renderer/inspect_command_renderer.py +87 -0
- sanityops_cli/templates/__init__.py +14 -0
- sanityops_cli/templates/inspect_config.yaml +55 -0
- sanityops_cli/utils/__init__.py +14 -0
- sanityops_cli/utils/artifact_packer.py +407 -0
- sanityops_cli/utils/config_loader.py +296 -0
- sanityops_cli/utils/config_resolver.py +358 -0
- sanityops_cli/utils/validators.py +117 -0
- sanityops_cli-0.1.3.dist-info/METADATA +213 -0
- sanityops_cli-0.1.3.dist-info/RECORD +53 -0
- sanityops_cli-0.1.3.dist-info/WHEEL +4 -0
- sanityops_cli-0.1.3.dist-info/entry_points.txt +2 -0
- sanityops_cli-0.1.3.dist-info/licenses/LICENSE +201 -0
- sanityops_cli-0.1.3.dist-info/licenses/NOTICE +5 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
# Copyright 2026 zipsonken
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
#
|
|
15
|
+
|
|
16
|
+
"""config command — Get and set sanityops configuration.
|
|
17
|
+
|
|
18
|
+
Config keys use dot notation, e.g. ``server.base_url``. They correspond to
|
|
19
|
+
the sanityops SaaS backend (NOT the LLM model config, which lives under
|
|
20
|
+
``model.*``). Precedence: project-level > global > env var > default.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import getpass
|
|
26
|
+
import os
|
|
27
|
+
import sys
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
import typer
|
|
31
|
+
from rich.console import Console
|
|
32
|
+
from rich.table import Table
|
|
33
|
+
|
|
34
|
+
from sanityops_cli.constants.config_defaults import DEFAULT_SERVER_BASE_URL
|
|
35
|
+
from sanityops_cli.constants.exit_codes import EXIT_FAILURE
|
|
36
|
+
from sanityops_cli.utils.config_resolver import ConfigResolver
|
|
37
|
+
|
|
38
|
+
console = Console()
|
|
39
|
+
|
|
40
|
+
# Create a Typer app for config subcommand
|
|
41
|
+
config_app = typer.Typer(
|
|
42
|
+
name="config",
|
|
43
|
+
help="Get and set sanityops configuration (server base URL, API key, etc.)",
|
|
44
|
+
no_args_is_help=False, # We handle help ourselves to add usage panel
|
|
45
|
+
add_help_option=False, # We handle --help ourselves to add usage panel
|
|
46
|
+
context_settings={"allow_interspersed_args": True},
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Registry of known config keys: metadata for reading defaults / masking.
|
|
50
|
+
# New config keys can be added here without touching resolve logic.
|
|
51
|
+
CONFIG_SCHEMA: dict[str, dict[str, Any]] = {
|
|
52
|
+
"server.base_url": {
|
|
53
|
+
"env": "SANITYOPS_BASE_URL",
|
|
54
|
+
"default": DEFAULT_SERVER_BASE_URL,
|
|
55
|
+
"sensitive": False,
|
|
56
|
+
},
|
|
57
|
+
"server.api_key": {
|
|
58
|
+
"env": "SANITYOPS_API_KEY",
|
|
59
|
+
"default": None,
|
|
60
|
+
"sensitive": True,
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _is_sensitive_key(key: str) -> bool:
|
|
66
|
+
"""Check if a key holds a sensitive value (masked on display)."""
|
|
67
|
+
return bool(CONFIG_SCHEMA.get(key, {}).get("sensitive"))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _mask_sensitive_value(value: Any) -> str:
|
|
71
|
+
"""Mask a sensitive value for display: first 3 + '***' + last 4 chars.
|
|
72
|
+
|
|
73
|
+
Short values (< 8 chars) are fully masked.
|
|
74
|
+
Non-string values are converted to string first.
|
|
75
|
+
This function is called from _display_value which handles None before calling.
|
|
76
|
+
"""
|
|
77
|
+
# Defensive: handle non-string values from manually-edited YAML
|
|
78
|
+
if not isinstance(value, str):
|
|
79
|
+
value = str(value)
|
|
80
|
+
if len(value) <= 7:
|
|
81
|
+
return "***"
|
|
82
|
+
return f"{value[:3]}***{value[-4:]}"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _display_value(key: str, value: Any) -> str:
|
|
86
|
+
"""Return the value as it should be shown, masking sensitive keys."""
|
|
87
|
+
if value is None:
|
|
88
|
+
return ""
|
|
89
|
+
if _is_sensitive_key(key):
|
|
90
|
+
return _mask_sensitive_value(value)
|
|
91
|
+
return str(value)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _flatten_config(data: dict[str, Any], prefix: str = "") -> list[tuple[str, Any]]:
|
|
95
|
+
"""Flatten a nested config dict into (dot.key, value) tuples."""
|
|
96
|
+
result: list[tuple[str, Any]] = []
|
|
97
|
+
for key, value in data.items():
|
|
98
|
+
full_key = f"{prefix}.{key}" if prefix else key
|
|
99
|
+
if isinstance(value, dict):
|
|
100
|
+
result.extend(_flatten_config(value, full_key))
|
|
101
|
+
else:
|
|
102
|
+
result.append((full_key, value))
|
|
103
|
+
return result
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _get_config_usage_panel():
|
|
107
|
+
"""Build the advanced usage panel for config --help."""
|
|
108
|
+
from rich.panel import Panel
|
|
109
|
+
from rich.text import Text
|
|
110
|
+
|
|
111
|
+
usage_text = f"""\
|
|
112
|
+
Configuration Priority:
|
|
113
|
+
Project-level (.sanityops/inspect_config.yaml) > Global (~/.sanityops/config)
|
|
114
|
+
> Environment Variables > Default
|
|
115
|
+
|
|
116
|
+
Available Keys:
|
|
117
|
+
server.base_url Sanityops SaaS backend URL (default: {DEFAULT_SERVER_BASE_URL})
|
|
118
|
+
server.api_key API key for authentication (sensitive, masked on display)
|
|
119
|
+
|
|
120
|
+
Environment Variables:
|
|
121
|
+
SANITYOPS_BASE_URL Override server.base_url
|
|
122
|
+
SANITYOPS_API_KEY Override server.api_key
|
|
123
|
+
|
|
124
|
+
Commands:
|
|
125
|
+
config <key> Read a config value
|
|
126
|
+
config <key> <value> Set a value (global, default)
|
|
127
|
+
config <key> <value> --local Set a project-level value
|
|
128
|
+
config --list List all config values
|
|
129
|
+
config --unset <key> Remove a config key
|
|
130
|
+
|
|
131
|
+
Examples:
|
|
132
|
+
sanityops-cli config server.base_url # Read value
|
|
133
|
+
sanityops-cli config server.base_url https://... # Set global
|
|
134
|
+
sanityops-cli config server.base_url https://... --local # Set project-level
|
|
135
|
+
sanityops-cli config server.api_key # Prompt (masked input)
|
|
136
|
+
sanityops-cli config --list # List all values
|
|
137
|
+
sanityops-cli config --unset server.base_url # Remove key\
|
|
138
|
+
"""
|
|
139
|
+
return Panel(
|
|
140
|
+
Text(usage_text, justify="left"),
|
|
141
|
+
title="Config Usage",
|
|
142
|
+
border_style="cyan",
|
|
143
|
+
padding=(0, 1),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _help_callback(help_requested: bool) -> None:
|
|
148
|
+
"""Custom help callback placeholder - actual handling in callback body."""
|
|
149
|
+
# This is a placeholder to enable -h flag; the actual help display is in config_callback
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@config_app.callback(invoke_without_command=True)
|
|
154
|
+
def config_callback(
|
|
155
|
+
ctx: typer.Context,
|
|
156
|
+
help_flag: bool = typer.Option(
|
|
157
|
+
False,
|
|
158
|
+
"--help",
|
|
159
|
+
"-h",
|
|
160
|
+
is_eager=True,
|
|
161
|
+
callback=_help_callback,
|
|
162
|
+
help="Show this message and exit",
|
|
163
|
+
),
|
|
164
|
+
key: str = typer.Argument(None, help="Config key, e.g. server.base_url"),
|
|
165
|
+
value: str = typer.Argument(None, help="Config value (omit to read)"),
|
|
166
|
+
list_all: bool = typer.Option(False, "--list", "-l", help="List all config values"),
|
|
167
|
+
unset: str = typer.Option(None, "--unset", help="Remove a config key"),
|
|
168
|
+
global_config: bool = typer.Option(
|
|
169
|
+
False,
|
|
170
|
+
"--global",
|
|
171
|
+
help="Operate on global config (default behavior)",
|
|
172
|
+
),
|
|
173
|
+
local_config: bool = typer.Option(
|
|
174
|
+
False,
|
|
175
|
+
"--local",
|
|
176
|
+
help="Operate on project-level config",
|
|
177
|
+
),
|
|
178
|
+
) -> None:
|
|
179
|
+
"""Get, set, or list sanityops configuration values."""
|
|
180
|
+
# --global and --local are mutually exclusive scope selectors.
|
|
181
|
+
# --global is the default (and is explicit); --local targets project config.
|
|
182
|
+
if global_config and local_config:
|
|
183
|
+
console.print("[red]Error: --global and --local are mutually exclusive[/red]")
|
|
184
|
+
raise typer.Exit(code=EXIT_FAILURE)
|
|
185
|
+
|
|
186
|
+
use_local = local_config # --local overrides; default is global
|
|
187
|
+
|
|
188
|
+
# Handle --help/-h
|
|
189
|
+
if help_flag:
|
|
190
|
+
console.print(ctx.get_help())
|
|
191
|
+
console.print(_get_config_usage_panel())
|
|
192
|
+
raise typer.Exit()
|
|
193
|
+
|
|
194
|
+
if list_all:
|
|
195
|
+
_list_config()
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
if unset:
|
|
199
|
+
_unset_config(unset, local=use_local)
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
if not key:
|
|
203
|
+
# Show help with additional usage documentation
|
|
204
|
+
console.print(ctx.get_help())
|
|
205
|
+
console.print(_get_config_usage_panel())
|
|
206
|
+
raise typer.Exit()
|
|
207
|
+
|
|
208
|
+
if value is None:
|
|
209
|
+
# Interactive masked input for sensitive keys on a TTY.
|
|
210
|
+
if _is_sensitive_key(key) and sys.stdin.isatty():
|
|
211
|
+
entered = getpass.getpass(f"Enter {key}: ")
|
|
212
|
+
_write_config(key, entered, local=use_local)
|
|
213
|
+
else:
|
|
214
|
+
_read_config(key)
|
|
215
|
+
else:
|
|
216
|
+
_write_config(key, value, local=use_local)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _read_config(key: str) -> None:
|
|
220
|
+
"""Read and display a config value."""
|
|
221
|
+
schema = CONFIG_SCHEMA.get(key, {})
|
|
222
|
+
resolver = ConfigResolver(
|
|
223
|
+
config_key=key,
|
|
224
|
+
env_var=schema.get("env"),
|
|
225
|
+
default=schema.get("default"),
|
|
226
|
+
)
|
|
227
|
+
resolved = resolver.resolve()
|
|
228
|
+
|
|
229
|
+
if resolved is None:
|
|
230
|
+
console.print(f"[yellow]{key} is not set[/yellow]")
|
|
231
|
+
raise typer.Exit()
|
|
232
|
+
|
|
233
|
+
console.print(_display_value(key, resolved))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _write_config(key: str, value: str, local: bool = False) -> None:
|
|
237
|
+
"""Write a config value to the global or project config file."""
|
|
238
|
+
try:
|
|
239
|
+
if local:
|
|
240
|
+
ConfigResolver.set_project(key, value)
|
|
241
|
+
console.print(f"[green]✓[/green] Set {key} in project config")
|
|
242
|
+
else:
|
|
243
|
+
ConfigResolver.set_global(key, value)
|
|
244
|
+
console.print(f"[green]✓[/green] Set {key} in global config")
|
|
245
|
+
except Exception as e:
|
|
246
|
+
console.print(f"[red]✗ Failed to write config: {e}[/red]")
|
|
247
|
+
raise typer.Exit(code=EXIT_FAILURE) from None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _unset_config(key: str, local: bool = False) -> None:
|
|
251
|
+
"""Remove a config key from the global or project config file."""
|
|
252
|
+
try:
|
|
253
|
+
removed = ConfigResolver.unset_project(key) if local else ConfigResolver.unset_global(key)
|
|
254
|
+
if removed:
|
|
255
|
+
location = "project config" if local else "global config"
|
|
256
|
+
console.print(f"[green]✓[/green] Removed {key} from {location}")
|
|
257
|
+
else:
|
|
258
|
+
console.print(f"[yellow]{key} was not set[/yellow]")
|
|
259
|
+
except Exception as e:
|
|
260
|
+
console.print(f"[red]✗ Failed to unset config: {e}[/red]")
|
|
261
|
+
raise typer.Exit(code=EXIT_FAILURE) from None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _list_config() -> None:
|
|
265
|
+
"""List all config values from global and project configs."""
|
|
266
|
+
global_config = ConfigResolver.list_global()
|
|
267
|
+
project_config = ConfigResolver.list_project()
|
|
268
|
+
|
|
269
|
+
if not global_config and not project_config:
|
|
270
|
+
console.print("[yellow]No configuration found[/yellow]")
|
|
271
|
+
console.print("\n Run: sanityops-cli config server.base_url <url>")
|
|
272
|
+
console.print(" Run: sanityops-cli config server.api_key <key>")
|
|
273
|
+
return
|
|
274
|
+
|
|
275
|
+
table = Table(title="[bold]Sanityops Configuration[/]", border_style="blue")
|
|
276
|
+
table.add_column("Key", style="cyan", no_wrap=True)
|
|
277
|
+
table.add_column("Global", style="white")
|
|
278
|
+
table.add_column("Project", style="green")
|
|
279
|
+
|
|
280
|
+
# Merge keys from both configs.
|
|
281
|
+
items: dict[str, tuple[Any, Any]] = {}
|
|
282
|
+
for k, v in _flatten_config(global_config):
|
|
283
|
+
items.setdefault(k, (None, None))
|
|
284
|
+
items[k] = (v, items[k][1])
|
|
285
|
+
for k, v in _flatten_config(project_config):
|
|
286
|
+
items.setdefault(k, (None, None))
|
|
287
|
+
items[k] = (items[k][0], v)
|
|
288
|
+
|
|
289
|
+
for key in sorted(items):
|
|
290
|
+
global_val, project_val = items[key]
|
|
291
|
+
table.add_row(
|
|
292
|
+
key,
|
|
293
|
+
_display_value(key, global_val),
|
|
294
|
+
_display_value(key, project_val),
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
console.print(table)
|
|
298
|
+
|
|
299
|
+
# Report which known env vars are set.
|
|
300
|
+
env_set: list[tuple[str, str]] = []
|
|
301
|
+
for config_key, schema in CONFIG_SCHEMA.items():
|
|
302
|
+
env_name = schema.get("env")
|
|
303
|
+
if not env_name:
|
|
304
|
+
continue
|
|
305
|
+
os_val = os.environ.get(env_name)
|
|
306
|
+
if os_val:
|
|
307
|
+
env_set.append((env_name, _display_value(config_key, os_val)))
|
|
308
|
+
|
|
309
|
+
if env_set:
|
|
310
|
+
console.print("\n[dim]Environment variables set:[/dim]")
|
|
311
|
+
for env_name, val in env_set:
|
|
312
|
+
console.print(f" [dim]{env_name}={val}[/dim]")
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# Copyright 2026 zipsonken
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
#
|
|
15
|
+
|
|
16
|
+
"""Init command — Initialize inspect configuration."""
|
|
17
|
+
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import typer
|
|
21
|
+
from rich.console import Console
|
|
22
|
+
|
|
23
|
+
from sanityops_cli.commands.inspect import inspect_app
|
|
24
|
+
from sanityops_cli.constants.exit_codes import EXIT_FAILURE
|
|
25
|
+
|
|
26
|
+
console = Console()
|
|
27
|
+
|
|
28
|
+
# Default paths
|
|
29
|
+
DEFAULT_CONFIG_DIR = ".sanityops"
|
|
30
|
+
DEFAULT_CONFIG_FILE = "inspect_config.yaml"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@inspect_app.command("init")
|
|
34
|
+
def init_config() -> None:
|
|
35
|
+
"""Initialize .sanityops/inspect_config.yaml with a template.
|
|
36
|
+
|
|
37
|
+
Creates a new config file with a placeholder project UUID. The project
|
|
38
|
+
stays unbound until the first inspect upload auto-creates it and writes
|
|
39
|
+
the real project id/name back to this file.
|
|
40
|
+
If a config already exists, prompts to backup and replace.
|
|
41
|
+
"""
|
|
42
|
+
from datetime import datetime
|
|
43
|
+
|
|
44
|
+
config_dir = Path.cwd() / DEFAULT_CONFIG_DIR
|
|
45
|
+
config_file = config_dir / DEFAULT_CONFIG_FILE
|
|
46
|
+
|
|
47
|
+
# Handle existing config file
|
|
48
|
+
if config_file.exists():
|
|
49
|
+
console.print(
|
|
50
|
+
f"[yellow]![/yellow] Config file already exists at {config_file}"
|
|
51
|
+
)
|
|
52
|
+
if not typer.confirm("Backup and create new?", default=False):
|
|
53
|
+
console.print("[red]✗[/red] Operation cancelled. Existing config preserved.")
|
|
54
|
+
return # Using return instead of typer.Exit(0) for test compatibility
|
|
55
|
+
|
|
56
|
+
# Backup existing file
|
|
57
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
58
|
+
backup_file = config_dir / f"inspect_config.yaml.bak.{timestamp}"
|
|
59
|
+
try:
|
|
60
|
+
config_file.rename(backup_file)
|
|
61
|
+
console.print(f"[green]✓[/green] Backed up to {backup_file}")
|
|
62
|
+
except OSError as e:
|
|
63
|
+
console.print(f"[red]✗ Cannot backup existing config: {e}[/red]")
|
|
64
|
+
raise typer.Exit(code=EXIT_FAILURE) from None
|
|
65
|
+
|
|
66
|
+
# Ensure directory exists
|
|
67
|
+
try:
|
|
68
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
except OSError as e:
|
|
70
|
+
console.print(f"[red]✗ Cannot create .sanityops directory: {e}[/red]")
|
|
71
|
+
raise typer.Exit(code=EXIT_FAILURE) from None
|
|
72
|
+
|
|
73
|
+
# Load template and generate UUID
|
|
74
|
+
try:
|
|
75
|
+
template_content = _load_template()
|
|
76
|
+
except (FileNotFoundError, ModuleNotFoundError, OSError) as e:
|
|
77
|
+
console.print(f"[red]✗ Internal error: template not found ({e})[/red]")
|
|
78
|
+
raise typer.Exit(code=EXIT_FAILURE) from None
|
|
79
|
+
|
|
80
|
+
# The placeholder UUID is intentionally left as-is so that the first
|
|
81
|
+
# inspect upload auto-creates the project and writes the real id/name
|
|
82
|
+
# back to this config file.
|
|
83
|
+
|
|
84
|
+
# Write config file
|
|
85
|
+
try:
|
|
86
|
+
with open(config_file, "w", encoding="utf-8") as f:
|
|
87
|
+
f.write(template_content)
|
|
88
|
+
except OSError as e:
|
|
89
|
+
console.print(f"[red]✗ Cannot write config file: {e}[/red]")
|
|
90
|
+
raise typer.Exit(code=EXIT_FAILURE) from None
|
|
91
|
+
|
|
92
|
+
# Keep secrets out of git: the config file holds the API key and the
|
|
93
|
+
# results directory holds scanned source artifacts. A nested .gitignore
|
|
94
|
+
# inside the generated directory covers both without touching the
|
|
95
|
+
# user's repository root .gitignore.
|
|
96
|
+
try:
|
|
97
|
+
(config_dir / ".gitignore").write_text("*\n", encoding="utf-8")
|
|
98
|
+
except OSError as e:
|
|
99
|
+
console.print(f"[yellow]⚠ Could not write {config_dir / '.gitignore'}: {e}[/yellow]")
|
|
100
|
+
|
|
101
|
+
console.print("[green]✓[/green] Created .sanityops/inspect_config.yaml")
|
|
102
|
+
console.print("Before running inspect, you need:")
|
|
103
|
+
console.print("- Edit this file to add your prompts, tools, and skills.")
|
|
104
|
+
console.print(
|
|
105
|
+
"- Edit this file to add your provider, api_key, model_id and base_url "
|
|
106
|
+
"(optional. If omitted, the CLI uses LLM_* environment variables)."
|
|
107
|
+
)
|
|
108
|
+
console.print("- To connect to the Sanityops service, configure the server URL and API key:")
|
|
109
|
+
console.print('sanityops-cli config server.base_url "<your-server-url>"')
|
|
110
|
+
console.print('sanityops-cli config server.api_key "<your-api-key>"')
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _load_template() -> str:
|
|
114
|
+
"""Load the inspect_config.yaml template as a string.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Template content with placeholder UUID. Returns as string
|
|
118
|
+
to preserve YAML comments.
|
|
119
|
+
"""
|
|
120
|
+
import sys
|
|
121
|
+
|
|
122
|
+
# PyInstaller bundles resources in sys._MEIPASS
|
|
123
|
+
if hasattr(sys, "_MEIPASS"):
|
|
124
|
+
template_path = Path(sys._MEIPASS) / "sanityops_cli" / "templates" / "inspect_config.yaml"
|
|
125
|
+
else:
|
|
126
|
+
# Standard importlib.resources for normal Python environments
|
|
127
|
+
import importlib.resources
|
|
128
|
+
|
|
129
|
+
template_path = importlib.resources.files("sanityops_cli.templates") / "inspect_config.yaml"
|
|
130
|
+
return template_path.read_text(encoding="utf-8")
|
|
131
|
+
|
|
132
|
+
return template_path.read_text(encoding="utf-8")
|