pisces-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.
pisces/config_cmd.py ADDED
@@ -0,0 +1,234 @@
1
+ """
2
+ pisces.config_cmd
3
+ ~~~~~~~~~~~~~~~~~
4
+ Implements `pisces config` — interactive configuration wizard.
5
+
6
+ What it does:
7
+ - Shows current configuration (with --show)
8
+ - Interactively updates provider, model, repair, validation settings
9
+ - Validates that the chosen provider has a key available in the environment
10
+ - Never reads or writes API keys to pisces.toml
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+
17
+ from rich import box
18
+ from rich.prompt import Confirm, Prompt
19
+ from rich.table import Table
20
+
21
+ from pisces.config_manager import (
22
+ PROVIDER_DEFAULTS,
23
+ SUPPORTED_PROVIDERS,
24
+ PiscesConfig,
25
+ check_api_key_available,
26
+ load_config,
27
+ write_config,
28
+ )
29
+ from pisces.tui import (
30
+ console,
31
+ print_error,
32
+ print_section,
33
+ print_step_ok,
34
+ print_success,
35
+ print_warning,
36
+ )
37
+
38
+ # ─────────────────────────────────────────────────────────────
39
+ # Entry point
40
+ # ─────────────────────────────────────────────────────────────
41
+
42
+
43
+ def run_config(project_root: Path, *, show_only: bool = False) -> int:
44
+ """
45
+ Entry point for `pisces config`.
46
+
47
+ Returns 0 on success, 1 on error.
48
+ """
49
+ print_section("pisces config")
50
+
51
+ # Load existing config (requires pisces init to have been run)
52
+ try:
53
+ cfg = load_config(project_root)
54
+ except RuntimeError as e:
55
+ print_error(str(e), hint="Run 'pisces init' first.")
56
+ return 1
57
+ except ValueError as e:
58
+ print_error(f"Invalid configuration: {e}")
59
+ return 1
60
+
61
+ if show_only:
62
+ _print_config_table(cfg, project_root)
63
+ return 0
64
+
65
+ # Interactive wizard
66
+ _print_config_table(cfg, project_root)
67
+ console.print()
68
+
69
+ try:
70
+ cfg = _run_wizard(cfg)
71
+ except KeyboardInterrupt:
72
+ console.print("\n[muted]Configuration cancelled.[/]")
73
+ return 0
74
+
75
+ # Save
76
+ try:
77
+ write_config(project_root, cfg)
78
+ except OSError as e:
79
+ print_error(f"Could not save configuration: {e}")
80
+ return 1
81
+
82
+ print_success("✓ Configuration saved to pisces.toml")
83
+
84
+ # Key check
85
+ _check_provider_key(cfg.llm.provider)
86
+
87
+ return 0
88
+
89
+
90
+ # ─────────────────────────────────────────────────────────────
91
+ # Current config display table
92
+ # ─────────────────────────────────────────────────────────────
93
+
94
+
95
+ def _print_config_table(cfg: PiscesConfig, project_root: Path) -> None:
96
+ """Print a rich table showing the current configuration."""
97
+ table = Table(
98
+ title="[bold]Current Configuration[/]",
99
+ title_justify="left",
100
+ box=box.ROUNDED,
101
+ border_style="blue",
102
+ header_style="bold cyan",
103
+ show_lines=False,
104
+ padding=(0, 2),
105
+ )
106
+ table.add_column("Setting", style="dim", min_width=22)
107
+ table.add_column("Value", style="white", min_width=30)
108
+ table.add_column("Source", style="muted", min_width=16)
109
+
110
+ # API key status (env var check)
111
+ has_key = check_api_key_available(cfg.llm.provider)
112
+ key_status = "[success]✓ Found in env[/]" if has_key else "[high]✗ Not set[/]"
113
+
114
+ table.add_row("llm.provider", f"[magenta]{cfg.llm.provider}[/]", "pisces.toml")
115
+ table.add_row("llm.model", f"[cyan]{cfg.llm.model}[/]", "pisces.toml")
116
+ table.add_row("API key", key_status, "env var")
117
+ table.add_row("repair.max_attempts", str(cfg.repair.max_attempts), "pisces.toml")
118
+ table.add_row(
119
+ "repair.require_approval",
120
+ "[success]true[/]" if cfg.repair.require_approval else "[high]false[/]",
121
+ "pisces.toml",
122
+ )
123
+ table.add_row(
124
+ "validation.test_command", f"[code]{cfg.validation.test_command}[/]", "pisces.toml"
125
+ )
126
+ table.add_row("Config file", str(project_root / "pisces.toml"), "—")
127
+
128
+ console.print()
129
+ console.print(table)
130
+ console.print()
131
+
132
+
133
+ # ─────────────────────────────────────────────────────────────
134
+ # Interactive wizard
135
+ # ─────────────────────────────────────────────────────────────
136
+
137
+
138
+ def _run_wizard(cfg: PiscesConfig) -> PiscesConfig:
139
+ """Run an interactive prompt-based config wizard. Returns updated cfg."""
140
+ console.print("[bold]Configure pisces[/] [dim](<press Enter to keep current value>)[/]")
141
+ console.print()
142
+
143
+ # ── LLM Provider ────────────────────────────────────────
144
+ console.print(f"[dim]Supported providers:[/] {', '.join(SUPPORTED_PROVIDERS)}")
145
+ provider = (
146
+ Prompt.ask(
147
+ " [bold magenta]LLM provider[/]",
148
+ default=cfg.llm.provider,
149
+ console=console,
150
+ )
151
+ .lower()
152
+ .strip()
153
+ )
154
+
155
+ if provider not in SUPPORTED_PROVIDERS:
156
+ print_warning(f"Unknown provider '{provider}'. Keeping current: {cfg.llm.provider}")
157
+ provider = cfg.llm.provider
158
+
159
+ # ── Model ────────────────────────────────────────────────
160
+ default_model = PROVIDER_DEFAULTS.get(provider, cfg.llm.model)
161
+ if provider != cfg.llm.provider:
162
+ # Provider changed → suggest new provider's default model
163
+ console.print(f" [dim]Default model for {provider}:[/] [cyan]{default_model}[/]")
164
+
165
+ model = Prompt.ask(
166
+ " [bold cyan]Model name[/]",
167
+ default=cfg.llm.model if provider == cfg.llm.provider else default_model,
168
+ console=console,
169
+ ).strip()
170
+
171
+ # ── Max repair attempts ──────────────────────────────────
172
+ attempts_str = Prompt.ask(
173
+ " [bold]Max self-healing attempts[/]",
174
+ default=str(cfg.repair.max_attempts),
175
+ console=console,
176
+ ).strip()
177
+ try:
178
+ max_attempts = int(attempts_str)
179
+ if max_attempts < 1:
180
+ raise ValueError
181
+ except ValueError:
182
+ print_warning(f"Invalid value '{attempts_str}'. Keeping: {cfg.repair.max_attempts}")
183
+ max_attempts = cfg.repair.max_attempts
184
+
185
+ # ── Require HITL approval ────────────────────────────────
186
+ console.print()
187
+ console.print(" [dim]Human approval (HITL) means pisces will ask before modifying source.[/]")
188
+ require_approval = Confirm.ask(
189
+ " [bold]Require human approval before patching?[/]",
190
+ default=cfg.repair.require_approval,
191
+ console=console,
192
+ )
193
+
194
+ # ── Test command ─────────────────────────────────────────
195
+ console.print()
196
+ test_command = Prompt.ask(
197
+ " [bold]Test command[/] [dim](<used to validate patches>)[/]",
198
+ default=cfg.validation.test_command,
199
+ console=console,
200
+ ).strip()
201
+
202
+ # ── Apply ────────────────────────────────────────────────
203
+ cfg.llm.provider = provider
204
+ cfg.llm.model = model
205
+ cfg.repair.max_attempts = max_attempts
206
+ cfg.repair.require_approval = require_approval
207
+ cfg.validation.test_command = test_command
208
+
209
+ return cfg
210
+
211
+
212
+ # ─────────────────────────────────────────────────────────────
213
+ # API key availability check (advisory only)
214
+ # ─────────────────────────────────────────────────────────────
215
+
216
+
217
+ def _check_provider_key(provider: str) -> None:
218
+ """Print a reminder if the provider's API key is not set."""
219
+ has_key = check_api_key_available(provider)
220
+ if has_key:
221
+ print_step_ok(f"API key for '{provider}' found in environment ✓")
222
+ else:
223
+ # Local providers don't need keys
224
+ if provider in ("ollama", "lmstudio"):
225
+ print_step_ok(f"'{provider}' is a local provider — no API key needed ✓")
226
+ else:
227
+ from pisces.config_manager import _PROVIDER_ENV_VARS
228
+
229
+ env_var = _PROVIDER_ENV_VARS.get(provider, ["?"])[0]
230
+ print_warning(
231
+ f"API key for '{provider}' not found.\n"
232
+ f" Set it before running 'pisces fix':\n"
233
+ f" export {env_var}=your_key_here"
234
+ )
@@ -0,0 +1,208 @@
1
+ """loads, merges, and validates pisces configuration
2
+ # pisces.toml should not and never contain sensitive config(eg: API key or maybe cerdentials)
3
+ # it may contain things like say provider name, model, settings or models
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import sys
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ if sys.version_info >= (3, 11):
15
+ import tomllib
16
+ else:
17
+ try:
18
+ import tomli as tomllib
19
+ except ImportError as e:
20
+ raise ImportError("Install 'tomli' for Python < 3.11: pip install tomli") from e
21
+
22
+ """Environment Variable names for each of the provider"""
23
+ _PROVIDER_ENV_VARS: dict[str, list[str]] = {
24
+ "openai": ["OPENAI_API_KEY"],
25
+ "anthropic": ["ANTHROPIC_API_KEY"],
26
+ "gemini": ["GOOGLE_API_KEY", "GEMINI_API_KEY"],
27
+ "groq": ["GROQ_API_KEY"],
28
+ "ollama": [], # key not needed cos local
29
+ "lmstudio": [], # key not needed cos local
30
+ }
31
+
32
+ # Supported provider and their default models
33
+ PROVIDER_DEFAULTS: dict[str, str] = {
34
+ "openai": "gpt-4o-mini",
35
+ "anthropic": "claude-3-5-haiku-20241022",
36
+ "gemini": "gemini-2.0-flash",
37
+ "groq": "llama-3.3-70b-versatile",
38
+ "ollama": "llama3.2",
39
+ "lmstudio": "local-model",
40
+ }
41
+
42
+ SUPPORTED_PROVIDERS = list(PROVIDER_DEFAULTS.keys())
43
+
44
+ """Config Dataclasses"""
45
+
46
+
47
+ @dataclass
48
+ class LLMConfig:
49
+ provider: str = "groq"
50
+ model: str = "llama-3.3-70b-versatile"
51
+
52
+
53
+ @dataclass
54
+ class RepairConfig:
55
+ max_attempts: int = 3
56
+ require_approval: bool = True # HITL always onn by default
57
+
58
+
59
+ @dataclass
60
+ class ValidationConfig:
61
+ test_command: str = "pytest"
62
+
63
+
64
+ @dataclass
65
+ class ScanConfig:
66
+ exclude: list[str] = field(
67
+ default_factory=lambda: [
68
+ ".git",
69
+ ".venv",
70
+ "venv",
71
+ "__pycache__",
72
+ "node_modules",
73
+ ".pisces",
74
+ "dist",
75
+ "build",
76
+ ]
77
+ )
78
+
79
+
80
+ @dataclass
81
+ class PiscesConfig:
82
+ llm: LLMConfig = field(default_factory=LLMConfig)
83
+ repair: RepairConfig = field(default_factory=RepairConfig)
84
+ validation: ValidationConfig = field(default_factory=ValidationConfig)
85
+ scan: ScanConfig = field(default_factory=ScanConfig)
86
+
87
+ _project_root: Path | None = field(default=None, repr=False, compare=False)
88
+
89
+ """Loader"""
90
+
91
+
92
+ def load_config(project_root: Path) -> PiscesConfig:
93
+ """
94
+ Load configuration from *project_root*/pisces.toml.
95
+ Falls back to defaults for any missing keys.
96
+ Raises RuntimeError if pisces has not been initialized.
97
+ """
98
+ toml_path = project_root / "pisces.toml"
99
+
100
+ if not toml_path.exists():
101
+ raise RuntimeError(f"pisces.toml not found in {project_root}\nRun 'pisces init' first.")
102
+ with toml_path.open("rb") as f:
103
+ raw: dict[str, Any] = tomllib.load(f)
104
+
105
+ cfg = PiscesConfig()
106
+ cfg._project_root = project_root
107
+
108
+ # [llm]
109
+ llm = raw.get("llm", {})
110
+ if "provider" in llm:
111
+ cfg.llm.provider = llm["provider"].lower().strip()
112
+ if "model" in llm:
113
+ cfg.llm.model = llm["model"].strip()
114
+ # [repair]
115
+ repair = raw.get("repair", {})
116
+ if "max_attempts" in repair:
117
+ cfg.repair.max_attempts = int(repair["max_attempts"])
118
+ if "require_approval" in repair:
119
+ cfg.repair.require_approval = bool(repair["require_approval"])
120
+ # [validation]
121
+ validation = raw.get("validation", {})
122
+ if "test_command" in validation:
123
+ cfg.validation.test_command = validation["test_command"].strip()
124
+ # [scan]
125
+ scan = raw.get("scan", {})
126
+ if "exclude" in scan:
127
+ cfg.scan.exclude = [str(e) for e in scan["exclude"]]
128
+ _validate_config(cfg)
129
+ return cfg
130
+
131
+
132
+ def _validate_config(cfg: PiscesConfig) -> None:
133
+ """Raise ValueError for clearly invalid config values."""
134
+ if cfg.llm.provider not in SUPPORTED_PROVIDERS:
135
+ raise ValueError(
136
+ f"Unsupported provider '{cfg.llm.provider}'. "
137
+ f"Choose from: {', '.join(SUPPORTED_PROVIDERS)}"
138
+ )
139
+ if cfg.repair.max_attempts < 1:
140
+ raise ValueError("repair.max_attempts must be >= 1")
141
+
142
+
143
+ # ─────────────────────────────────────────────────────────────
144
+ # Credential lookup — env vars ONLY
145
+ # ─────────────────────────────────────────────────────────────
146
+ def get_api_key(provider: str) -> str | None:
147
+ """
148
+ Return the API key for *provider* from environment variables.
149
+ Returns None for local providers (ollama, lmstudio) that need no key.
150
+ Raises EnvironmentError if the key is required but missing.
151
+ """
152
+ provider = provider.lower()
153
+ env_vars = _PROVIDER_ENV_VARS.get(provider, [])
154
+ if not env_vars:
155
+ return None # local provider — no key needed
156
+ for var in env_vars:
157
+ value = os.environ.get(var, "").strip()
158
+ if value:
159
+ return value
160
+ raise OSError(
161
+ f"No API key found for provider '{provider}'.\n"
162
+ f"Set one of these environment variables: {', '.join(env_vars)}\n\n"
163
+ f"Example (Linux/macOS/WSL):\n"
164
+ f" export {env_vars[0]}=your_key_here\n\n"
165
+ f"Example (Windows CMD):\n"
166
+ f" set {env_vars[0]}=your_key_here\n\n"
167
+ f"Example (Windows PowerShell):\n"
168
+ f" $env:{env_vars[0]}='your_key_here'"
169
+ )
170
+
171
+
172
+ def check_api_key_available(provider: str) -> bool:
173
+ """Return True if an API key is available (or not required)."""
174
+ try:
175
+ get_api_key(provider)
176
+ return True
177
+ except OSError:
178
+ return False
179
+
180
+
181
+ # ─────────────────────────────────────────────────────────────
182
+ # Config writer (non-sensitive fields only)
183
+ # ─────────────────────────────────────────────────────────────
184
+ def write_config(project_root: Path, cfg: PiscesConfig) -> None:
185
+ """
186
+ Write *cfg* to pisces.toml.
187
+ NEVER writes credentials. Only writes provider name, model, and settings.
188
+ """
189
+ toml_path = project_root / "pisces.toml"
190
+ # Build exclude list as TOML inline array
191
+ excludes = ", ".join(f'"{e}"' for e in cfg.scan.exclude)
192
+ content = f"""\
193
+ # pisces project configuration
194
+ # API keys MUST come from environment variables — never put them here.
195
+ [llm]
196
+ provider = "{cfg.llm.provider}"
197
+ model = "{cfg.llm.model}"
198
+ [repair]
199
+ max_attempts = {cfg.repair.max_attempts}
200
+ require_approval = {"true" if cfg.repair.require_approval else "false"}
201
+ [validation]
202
+ test_command = "{cfg.validation.test_command}"
203
+ [scan]
204
+ exclude = [
205
+ {excludes}
206
+ ]
207
+ """
208
+ toml_path.write_text(content, encoding="utf-8")