copex 0.8.4__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.
copex/config.py ADDED
@@ -0,0 +1,311 @@
1
+ """Configuration management for Copex."""
2
+
3
+ import os
4
+ import shutil
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from pydantic import BaseModel, Field, field_validator
10
+
11
+ from copex.models import Model, ReasoningEffort
12
+
13
+
14
+ def find_copilot_cli() -> str | None:
15
+ """Auto-detect the Copilot CLI path across platforms.
16
+
17
+ Searches in order:
18
+ 1. shutil.which('copilot') - system PATH
19
+ 2. Common npm global locations
20
+ 3. Common installation paths
21
+
22
+ Returns the path if found, None otherwise.
23
+ """
24
+ # First try PATH (works on all platforms)
25
+ cli_path = shutil.which("copilot")
26
+ if cli_path:
27
+ # On Windows, prefer .cmd over .ps1 for subprocess compatibility
28
+ if sys.platform == "win32" and cli_path.endswith(".ps1"):
29
+ cmd_path = cli_path.replace(".ps1", ".cmd")
30
+ if os.path.exists(cmd_path):
31
+ return cmd_path
32
+ return cli_path
33
+
34
+ # Platform-specific common locations
35
+ if sys.platform == "win32":
36
+ # Windows locations
37
+ candidates = [
38
+ Path(os.environ.get("APPDATA", "")) / "npm" / "copilot.cmd",
39
+ Path(os.environ.get("LOCALAPPDATA", "")) / "npm" / "copilot.cmd",
40
+ Path.home() / "AppData" / "Roaming" / "npm" / "copilot.cmd",
41
+ Path.home() / ".npm-global" / "copilot.cmd",
42
+ ]
43
+ # Also check USERPROFILE
44
+ if "USERPROFILE" in os.environ:
45
+ candidates.append(Path(os.environ["USERPROFILE"]) / "AppData" / "Roaming" / "npm" / "copilot.cmd")
46
+ elif sys.platform == "darwin":
47
+ # macOS locations
48
+ candidates = [
49
+ Path.home() / ".npm-global" / "bin" / "copilot",
50
+ Path("/usr/local/bin/copilot"),
51
+ Path("/opt/homebrew/bin/copilot"),
52
+ Path.home() / ".nvm" / "versions" / "node", # Check NVM later
53
+ ]
54
+ else:
55
+ # Linux locations
56
+ candidates = [
57
+ Path.home() / ".npm-global" / "bin" / "copilot",
58
+ Path("/usr/local/bin/copilot"),
59
+ Path("/usr/bin/copilot"),
60
+ Path.home() / ".local" / "bin" / "copilot",
61
+ ]
62
+
63
+ for candidate in candidates:
64
+ if candidate.exists() and candidate.is_file():
65
+ return str(candidate)
66
+
67
+ # Check for NVM installations (macOS/Linux)
68
+ if sys.platform != "win32":
69
+ nvm_dir = Path.home() / ".nvm" / "versions" / "node"
70
+ if nvm_dir.exists():
71
+ # Find latest node version
72
+ versions = sorted(nvm_dir.iterdir(), reverse=True)
73
+ for version in versions:
74
+ copilot_path = version / "bin" / "copilot"
75
+ if copilot_path.exists():
76
+ return str(copilot_path)
77
+
78
+ return None
79
+
80
+
81
+ UI_THEMES = {"default", "midnight", "mono", "sunset"}
82
+ UI_DENSITIES = {"compact", "extended"}
83
+
84
+
85
+ class RetryConfig(BaseModel):
86
+ """Retry configuration."""
87
+
88
+ max_retries: int = Field(default=5, ge=1, le=20, description="Maximum retry attempts")
89
+ max_auto_continues: int = Field(default=3, ge=0, le=10, description="Maximum auto-continue cycles after exhausting retries")
90
+ base_delay: float = Field(default=1.0, ge=0.1, description="Base delay between retries (seconds)")
91
+ max_delay: float = Field(default=30.0, ge=1.0, description="Maximum delay between retries (seconds)")
92
+ exponential_base: float = Field(default=2.0, ge=1.5, description="Exponential backoff multiplier")
93
+ retry_on_any_error: bool = Field(
94
+ default=True, description="Retry and auto-continue on any error"
95
+ )
96
+ retry_on_errors: list[str] = Field(
97
+ default=["500", "502", "503", "504", "Internal Server Error", "rate limit"],
98
+ description="Error patterns to retry on (only used if retry_on_any_error=False)",
99
+ )
100
+
101
+
102
+ def get_user_state_path() -> Path:
103
+ """Get path to user state file."""
104
+ return Path.home() / ".copex" / "state.json"
105
+
106
+
107
+ def load_last_model() -> Model | None:
108
+ """Load the last used model from user state."""
109
+ state_path = get_user_state_path()
110
+ if not state_path.exists():
111
+ return None
112
+ try:
113
+ import json
114
+ with open(state_path, "r", encoding="utf-8") as f:
115
+ data = json.load(f)
116
+ model_value = data.get("last_model")
117
+ if model_value:
118
+ return Model(model_value)
119
+ except (ValueError, OSError, json.JSONDecodeError):
120
+ pass
121
+ return None
122
+
123
+
124
+ def save_last_model(model: Model) -> None:
125
+ """Save the last used model to user state."""
126
+ import json
127
+ state_path = get_user_state_path()
128
+ state_path.parent.mkdir(parents=True, exist_ok=True)
129
+
130
+ # Load existing state
131
+ data: dict[str, Any] = {}
132
+ if state_path.exists():
133
+ try:
134
+ with open(state_path, "r", encoding="utf-8") as f:
135
+ data = json.load(f)
136
+ except (OSError, json.JSONDecodeError):
137
+ pass
138
+
139
+ # Update and save
140
+ data["last_model"] = model.value
141
+ with open(state_path, "w", encoding="utf-8") as f:
142
+ json.dump(data, f, indent=2)
143
+
144
+
145
+ class CopexConfig(BaseModel):
146
+ """Main configuration for Copex client."""
147
+
148
+ model: Model = Field(default=Model.CLAUDE_OPUS_4_5, description="Model to use")
149
+ reasoning_effort: ReasoningEffort = Field(
150
+ default=ReasoningEffort.XHIGH, description="Reasoning effort level"
151
+ )
152
+ streaming: bool = Field(default=True, description="Enable streaming responses")
153
+ retry: RetryConfig = Field(default_factory=RetryConfig, description="Retry configuration")
154
+
155
+ # Client options
156
+ cli_path: str | None = Field(default=None, description="Path to Copilot CLI executable")
157
+ cli_url: str | None = Field(default=None, description="URL of existing CLI server")
158
+ cwd: str | None = Field(default=None, description="Working directory for CLI process")
159
+ auto_start: bool = Field(default=True, description="Auto-start CLI server")
160
+ auto_restart: bool = Field(default=True, description="Auto-restart on crash")
161
+ log_level: str = Field(default="warning", description="Log level")
162
+
163
+ # Session options
164
+ timeout: float = Field(default=300.0, ge=10.0, description="Inactivity timeout (seconds) - resets on each event")
165
+ auto_continue: bool = Field(
166
+ default=True, description="Auto-send 'Keep going' on interruption/error"
167
+ )
168
+ continue_prompt: str = Field(
169
+ default="Keep going", description="Prompt to send on auto-continue"
170
+ )
171
+
172
+ # Skills and capabilities
173
+ skills: list[str] = Field(
174
+ default_factory=list,
175
+ description="Skills to enable (e.g., ['code-review', 'azure-openai'])"
176
+ )
177
+ instructions: str | None = Field(
178
+ default=None,
179
+ description="Custom instructions for the session"
180
+ )
181
+ instructions_file: str | None = Field(
182
+ default=None,
183
+ description="Path to instructions file (.md)"
184
+ )
185
+
186
+ # MCP configuration
187
+ mcp_servers: list[dict[str, Any]] = Field(
188
+ default_factory=list,
189
+ description="MCP server configurations"
190
+ )
191
+ mcp_config_file: str | None = Field(
192
+ default=None,
193
+ description="Path to MCP config JSON file"
194
+ )
195
+
196
+ # Tool filtering
197
+ available_tools: list[str] | None = Field(
198
+ default=None,
199
+ description="Whitelist of tools to enable (None = all)"
200
+ )
201
+ excluded_tools: list[str] = Field(
202
+ default_factory=list,
203
+ description="Blacklist of tools to disable"
204
+ )
205
+
206
+ # UI options
207
+ ui_theme: str = Field(
208
+ default="default",
209
+ description="UI theme (default, midnight, mono, sunset)"
210
+ )
211
+ ui_density: str = Field(
212
+ default="extended",
213
+ description="UI density (compact or extended)"
214
+ )
215
+
216
+ @field_validator("ui_theme")
217
+ @classmethod
218
+ def _validate_ui_theme(cls, value: str) -> str:
219
+ if value not in UI_THEMES:
220
+ valid = ", ".join(sorted(UI_THEMES))
221
+ raise ValueError(f"Invalid ui_theme. Valid: {valid}")
222
+ return value
223
+
224
+ @field_validator("ui_density")
225
+ @classmethod
226
+ def _validate_ui_density(cls, value: str) -> str:
227
+ if value not in UI_DENSITIES:
228
+ valid = ", ".join(sorted(UI_DENSITIES))
229
+ raise ValueError(f"Invalid ui_density. Valid: {valid}")
230
+ return value
231
+
232
+ @classmethod
233
+ def from_file(cls, path: str | Path) -> "CopexConfig":
234
+ """Load configuration from TOML file."""
235
+ import tomllib
236
+
237
+ path = Path(path)
238
+ if not path.exists():
239
+ return cls()
240
+
241
+ with open(path, "rb") as f:
242
+ data = tomllib.load(f)
243
+
244
+ return cls(**data)
245
+
246
+ @classmethod
247
+ def default_path(cls) -> Path:
248
+ """Get default config file path."""
249
+ return Path.home() / ".config" / "copex" / "config.toml"
250
+
251
+ def to_client_options(self) -> dict[str, Any]:
252
+ """Convert to CopilotClient options."""
253
+ opts: dict[str, Any] = {
254
+ "auto_start": self.auto_start,
255
+ "auto_restart": self.auto_restart,
256
+ "log_level": self.log_level,
257
+ }
258
+
259
+ # Use provided cli_path or auto-detect
260
+ cli_path = self.cli_path or find_copilot_cli()
261
+ if cli_path:
262
+ opts["cli_path"] = cli_path
263
+
264
+ if self.cli_url:
265
+ opts["cli_url"] = self.cli_url
266
+ if self.cwd:
267
+ opts["cwd"] = self.cwd
268
+ return opts
269
+
270
+ def to_session_options(self) -> dict[str, Any]:
271
+ """Convert to create_session options."""
272
+ opts: dict[str, Any] = {
273
+ "model": self.model.value,
274
+ "model_reasoning_effort": self.reasoning_effort.value,
275
+ "streaming": self.streaming,
276
+ }
277
+
278
+ # Skills
279
+ if self.skills:
280
+ opts["skills"] = self.skills
281
+
282
+ # Instructions
283
+ if self.instructions:
284
+ opts["instructions"] = self.instructions
285
+ elif self.instructions_file:
286
+ instructions_path = Path(self.instructions_file)
287
+ if not instructions_path.exists():
288
+ raise FileNotFoundError(f"Instructions file not found: {instructions_path}")
289
+ with open(instructions_path, "r", encoding="utf-8") as f:
290
+ opts["instructions"] = f.read()
291
+
292
+ # MCP servers
293
+ if self.mcp_servers:
294
+ opts["mcp_servers"] = self.mcp_servers
295
+ elif self.mcp_config_file:
296
+ config_path = Path(self.mcp_config_file)
297
+ if not config_path.exists():
298
+ raise FileNotFoundError(f"MCP config file not found: {config_path}")
299
+ import json
300
+ with open(config_path, "r", encoding="utf-8") as f:
301
+ mcp_data = json.load(f)
302
+ if "servers" in mcp_data:
303
+ opts["mcp_servers"] = list(mcp_data["servers"].values())
304
+
305
+ # Tool filtering
306
+ if self.available_tools is not None:
307
+ opts["available_tools"] = self.available_tools
308
+ if self.excluded_tools:
309
+ opts["excluded_tools"] = self.excluded_tools
310
+
311
+ return opts