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.
Files changed (53) hide show
  1. sanityops_cli/__init__.py +16 -0
  2. sanityops_cli/agents/__init__.py +14 -0
  3. sanityops_cli/agents/repair_agent/__init__.py +19 -0
  4. sanityops_cli/agents/repair_agent/agent.py +230 -0
  5. sanityops_cli/agents/repair_agent/prompts.py +100 -0
  6. sanityops_cli/agents/repair_agent/tools/__init__.py +19 -0
  7. sanityops_cli/agents/repair_agent/tools/store_repairs_tool.py +144 -0
  8. sanityops_cli/agents/scanner_agent/agent.py +332 -0
  9. sanityops_cli/agents/scanner_agent/hooks/progress_hook.py +152 -0
  10. sanityops_cli/agents/scanner_agent/models/finding.py +120 -0
  11. sanityops_cli/agents/scanner_agent/prompts.py +316 -0
  12. sanityops_cli/agents/scanner_agent/tools/grep_tool.py +667 -0
  13. sanityops_cli/agents/scanner_agent/tools/listfiles_tool.py +88 -0
  14. sanityops_cli/agents/scanner_agent/tools/readfile_tool.py +804 -0
  15. sanityops_cli/agents/scanner_agent/tools/storefindings_tool.py +178 -0
  16. sanityops_cli/api/__init__.py +16 -0
  17. sanityops_cli/api/client.py +403 -0
  18. sanityops_cli/commands/__init__.py +14 -0
  19. sanityops_cli/commands/config.py +312 -0
  20. sanityops_cli/commands/init.py +132 -0
  21. sanityops_cli/commands/inspect.py +646 -0
  22. sanityops_cli/constants/__init__.py +14 -0
  23. sanityops_cli/constants/config_defaults.py +22 -0
  24. sanityops_cli/constants/exit_codes.py +19 -0
  25. sanityops_cli/defect_checker/__init__.py +16 -0
  26. sanityops_cli/defect_checker/checker.py +100 -0
  27. sanityops_cli/defect_checker/llm_config.py +112 -0
  28. sanityops_cli/defect_checker/markdown_reporter.py +249 -0
  29. sanityops_cli/defect_checker/renderer.py +203 -0
  30. sanityops_cli/exceptions/__init__.py +14 -0
  31. sanityops_cli/exceptions/api_exceptions.py +60 -0
  32. sanityops_cli/exceptions/base_exceptions.py +25 -0
  33. sanityops_cli/help_panel.py +49 -0
  34. sanityops_cli/logging/__init__.py +18 -0
  35. sanityops_cli/logging/logger.py +108 -0
  36. sanityops_cli/main.py +123 -0
  37. sanityops_cli/progress/__init__.py +18 -0
  38. sanityops_cli/progress/tracker.py +159 -0
  39. sanityops_cli/renderers/__init__.py +14 -0
  40. sanityops_cli/renderers/command_renderer/inspect_command_renderer.py +87 -0
  41. sanityops_cli/templates/__init__.py +14 -0
  42. sanityops_cli/templates/inspect_config.yaml +55 -0
  43. sanityops_cli/utils/__init__.py +14 -0
  44. sanityops_cli/utils/artifact_packer.py +407 -0
  45. sanityops_cli/utils/config_loader.py +296 -0
  46. sanityops_cli/utils/config_resolver.py +358 -0
  47. sanityops_cli/utils/validators.py +117 -0
  48. sanityops_cli-0.1.3.dist-info/METADATA +213 -0
  49. sanityops_cli-0.1.3.dist-info/RECORD +53 -0
  50. sanityops_cli-0.1.3.dist-info/WHEEL +4 -0
  51. sanityops_cli-0.1.3.dist-info/entry_points.txt +2 -0
  52. sanityops_cli-0.1.3.dist-info/licenses/LICENSE +201 -0
  53. 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")