utim-cli 1.0.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.
utim_cli/bootstrap.py ADDED
@@ -0,0 +1,324 @@
1
+ """
2
+ UTIM Bootstrap Module
3
+ Auto-creates the .utim folder structure, custom skills, and local database on first run.
4
+ """
5
+
6
+ import os
7
+ from pathlib import Path
8
+
9
+ UTIM_DIR = Path('.utim')
10
+
11
+ DEFAULT_AGENTS_MD = """# Project-Scoped Rules and Guidelines for UTIM CLI
12
+
13
+ Welcome to the UTIM CLI development workspace. Follow these rules and activate the corresponding workspace-scoped skills based on your current task.
14
+
15
+ ## Available Workspace Skills
16
+
17
+ You MUST read the corresponding `SKILL.md` file using the file viewer tool before implementing features in these domains:
18
+
19
+ 1. **Terminal UI (TUI) Design**: [.utim/skills/terminal-ui-design/SKILL.md](file:///C:/Users/user/Desktop/New%20folder/New%20folder/.utim/skills/terminal-ui-design/SKILL.md)
20
+ - *Use when*: Modifying interactive menus, Rich console logs, layouts, and `prompt_toolkit` inputs.
21
+ 2. **Software Architecture**: [.utim/skills/software-architecture/SKILL.md](file:///C:/Users/user/Desktop/New%20folder/New%20folder/.utim/skills/software-architecture/SKILL.md)
22
+ - *Use when*: Designing new Python modules, modifying state/orchestrator files, or refactoring codebase structures.
23
+ 3. **MCP Server Development**: [.utim/skills/mcp-server-development/SKILL.md](file:///C:/Users/user/Desktop/New%20folder/New%20folder/.utim/skills/mcp-server-development/SKILL.md)
24
+ - *Use when*: Writing new MCP servers, modifying connection pools, or handling stdio protocol wrappers.
25
+ 4. **CLI UX Patterns**: [.utim/skills/cli-ux-patterns/SKILL.md](file:///C:/Users/user/Desktop/New%20folder/New%20folder/.utim/skills/cli-ux-patterns/SKILL.md)
26
+ - *Use when*: Refining CLI prompts, progress indicators, non-interactive (TTY) handling, and user confirmations.
27
+ 5. **Premium Web Design**: [.utim/skills/web-design-premium/SKILL.md](file:///C:/Users/user/Desktop/New%20folder/New%20folder/.utim/skills/web-design-premium/SKILL.md)
28
+ - *Use when*: Coding web interfaces, using HSL colors, glassmorphism UI, transitions, or applying SEO tags.
29
+ 6. **LLM Orchestration**: [.utim/skills/llm-orchestration/SKILL.md](file:///C:/Users/user/Desktop/New%20folder/New%20folder/.utim/skills/llm-orchestration/SKILL.md)
30
+ - *Use when*: Editing LLM prompting context, parser loops, tools injection, token pruner, or semantic vector DB memory retrieval.
31
+ 7. **Asynchronous Python (asyncio)**: [.utim/skills/async-python/SKILL.md](file:///C:/Users/user/Desktop/New%20folder/New%20folder/.utim/skills/async-python/SKILL.md)
32
+ - *Use when*: Implementing asynchronous background tasks, reading subprocess streams, or thread safety locks.
33
+ """
34
+
35
+ DEFAULT_SKILLS = {
36
+ "terminal-ui-design": """---
37
+ name: terminal-ui-design
38
+ description: Guidelines and best practices for creating beautiful, responsive terminal UIs (TUIs) in Python using Rich and prompt_toolkit.
39
+ ---
40
+
41
+ # Terminal UI (TUI) Design Guide
42
+
43
+ Terminal UIs must combine aesthetic appeal with technical reliability. Use Rich for output panels/tables and prompt_toolkit for interactive prompts, validations, and completion suggestions.
44
+
45
+ ---
46
+
47
+ ## 1. Color Palette & Theming
48
+ - Primary/Accent: Cyan or magenta.
49
+ - Success: Green.
50
+ - Warning: Yellow.
51
+ - Error: Red.
52
+ - Dim/Muted: Gray.
53
+
54
+ Always use a centralized theme rather than hardcoding colors.
55
+ """,
56
+ "software-architecture": """---
57
+ name: software-architecture
58
+ description: Guidelines and design patterns for Python application architecture, focus on modularity, decoupling, state management, and reliable abstractions.
59
+ ---
60
+
61
+ # Software Architecture Guidelines
62
+
63
+ Adhere to modular separation of concerns. Keep UI scripts, business logic, state models, and tool execution independent to prevent tight coupling.
64
+ """,
65
+ "mcp-server-development": """---
66
+ name: mcp-server-development
67
+ description: Guidelines for developing and integrating Model Context Protocol (MCP) servers, troubleshooting stdio stream corruption, and implementing robust tool/resource handlers.
68
+ ---
69
+
70
+ # MCP Server Development Guide
71
+
72
+ - Redirect all logs and debug printing to stderr, never stdout, to prevent stdio transport corruption.
73
+ - Process stdin EOF gracefully to prevent hanging subprocesses.
74
+ """,
75
+ "cli-ux-patterns": """---
76
+ name: cli-ux-patterns
77
+ description: Core guidelines for designing highly polished Command Line Interface (CLI) user experiences.
78
+ ---
79
+
80
+ # CLI UX Patterns
81
+
82
+ - Always clean up temporary or interactive question prompts using screen clearing codes.
83
+ - Ensure all mutating commands support non-destructive dry-run modes.
84
+ """,
85
+ "web-design-premium": """---
86
+ name: web-design-premium
87
+ description: Design principles and code implementation guidelines for premium, modern, and visually striking web interfaces.
88
+ ---
89
+
90
+ # Premium Web Design Guide
91
+
92
+ Apply glassmorphism (backdrop-filter blur), modern sans-serif fonts (e.g. Outfit, Inter), clean HSL-tailored colors, smooth transitions, and standard SEO best practices.
93
+ """,
94
+ "llm-orchestration": """---
95
+ name: llm-orchestration
96
+ description: Guidelines and patterns for LLM agent loops, tool-calling, context management, semantic token pruning, and reflection strategies.
97
+ ---
98
+
99
+ # LLM Orchestration & Agent Loops
100
+
101
+ - Ensure iteration limits are set to prevent infinite LLM reasoning loops.
102
+ - Perform pre-commit validation checks before executing tool code modifications.
103
+ """,
104
+ "async-python": """---
105
+ name: async-python
106
+ description: Guidelines and patterns for asynchronous Python programming using asyncio.
107
+ ---
108
+
109
+ # Asynchronous Python (asyncio)
110
+
111
+ - Use asyncio.create_subprocess_exec to execute subprocesses concurrently.
112
+ - Protect asynchronous actions with timeouts using asyncio.timeout.
113
+ """
114
+ }
115
+
116
+ def initialize_utim() -> str:
117
+ """Initialize .utim directory, local SQLite database, and auto-create custom skills/rules if they don't exist."""
118
+ try:
119
+ from utim_cli.backup import restore_state
120
+ restore_state()
121
+ except Exception as e:
122
+ from utim_cli.logger import log_error
123
+ log_error("bootstrap", "Failed to restore state during initialization", e)
124
+
125
+ UTIM_DIR.mkdir(exist_ok=True)
126
+
127
+ # 1. Initialize local_utim database using SQLAlchemy init_db
128
+ try:
129
+ from utim_cli.server.db import init_db
130
+ init_db()
131
+ except Exception as e:
132
+ from utim_cli.logger import log_error
133
+ log_error("bootstrap", "Failed to initialize SQLite local database", e)
134
+
135
+ # 2. Write analytical_rules.md if not exists
136
+ _write_analytical_rules_md()
137
+
138
+ # 3. Write UTIM.md if not exists
139
+ _write_utim_md()
140
+
141
+ # 4. Auto-create skills directory and default skills/rules
142
+ _write_skills_and_agents()
143
+
144
+ db_path = UTIM_DIR / 'utim_local.db'
145
+ return str(db_path)
146
+
147
+ def _write_skills_and_agents():
148
+ """Auto-create skills/ directory, AGENTS.md, and all default SKILL.md files inside .utim/."""
149
+ try:
150
+ # Write AGENTS.md
151
+ agents_path = UTIM_DIR / 'AGENTS.md'
152
+ if not agents_path.exists():
153
+ with open(agents_path, 'w', encoding='utf-8') as f:
154
+ f.write(DEFAULT_AGENTS_MD)
155
+
156
+ # Write skills
157
+ skills_dir = UTIM_DIR / 'skills'
158
+ for skill_name, content in DEFAULT_SKILLS.items():
159
+ skill_dir = skills_dir / skill_name
160
+ skill_dir.mkdir(parents=True, exist_ok=True)
161
+ skill_file = skill_dir / 'SKILL.md'
162
+ if not skill_file.exists():
163
+ with open(skill_file, 'w', encoding='utf-8') as f:
164
+ f.write(content)
165
+
166
+ # Write DESIGN.md
167
+ design_path = UTIM_DIR / 'DESIGN.md'
168
+ if not design_path.exists():
169
+ _write_default_design_md(design_path)
170
+ except Exception as e:
171
+ from utim_cli.logger import log_error
172
+ log_error("bootstrap", "Failed to write skills and agents configuration to .utim/", e)
173
+
174
+ def _write_default_design_md(md_path: Path):
175
+ content = """# Premium Web Design System (Aesthetics Cheat-Sheet)
176
+ Use this guide to build modern, beautiful, and "award-winning" web interfaces. Apply these principles, variables, and recipes to wow the user.
177
+
178
+ ---
179
+
180
+ ## 1. Typography
181
+ * **Body & UI**: 'Outfit', sans-serif (clean, rounded, premium feel).
182
+ * **Headings**: 'Bricolage Grotesque', sans-serif (vibrant, high-agency design).
183
+
184
+ ## 2. Color Palette (Clean Dark/Light HSL)
185
+ - Primary HSL colors, gradients, and custom themes to prevent generic looks.
186
+ """
187
+ try:
188
+ with open(md_path, 'w', encoding='utf-8') as f:
189
+ f.write(content)
190
+ except Exception:
191
+ pass
192
+
193
+ def _write_analytical_rules_md():
194
+ """Write the analytical_rules.md file to .utim folder if not exists."""
195
+ md_path = UTIM_DIR / 'analytical_rules.md'
196
+ if md_path.exists():
197
+ return
198
+
199
+ content = """# Enhanced Analytical Framework for UTIM AI
200
+
201
+ ## Core Principle: GOAL-FIRST ANALYSIS
202
+ Always start by identifying the TRUE objective, then work backwards to feasible actions.
203
+ """
204
+ try:
205
+ with open(md_path, 'w', encoding='utf-8') as f:
206
+ f.write(content)
207
+ except Exception as e:
208
+ from utim_cli.logger import log_error
209
+ log_error("bootstrap", f"Failed to write analytical rules markdown to {md_path}", e)
210
+
211
+ def _write_utim_md():
212
+ """Write the UTIM.md file to .utim folder if not exists."""
213
+ md_path = UTIM_DIR / 'UTIM.md'
214
+ if md_path.exists():
215
+ return
216
+
217
+ content = """## 1. Identity & Operating Mindset
218
+ * **Role**: You are UTIM AI, a high-agency senior software engineering intelligence operating inside a developer CLI.
219
+ """
220
+ try:
221
+ with open(md_path, 'w', encoding='utf-8') as f:
222
+ f.write(content)
223
+ except Exception as e:
224
+ from utim_cli.logger import log_error
225
+ log_error("bootstrap", f"Failed to write UTIM.md markdown to {md_path}", e)
226
+
227
+ def get_rag_context(user_prompt: str = "") -> str:
228
+ """Get RAG context string for system prompt injection by matching user_prompt keywords to skills."""
229
+ try:
230
+ from utim_cli.state import STATE
231
+ STATE["injected_contexts"] = []
232
+
233
+ # Simple keyword matching to select relevant skills
234
+ matched_skills = []
235
+ user_prompt_lower = user_prompt.lower()
236
+
237
+ keywords = {
238
+ "terminal-ui-design": ["ui", "tui", "console", "rich", "prompt", "menu", "prompt_toolkit", "layout"],
239
+ "software-architecture": ["architecture", "design", "module", "structure", "refactor", "coupling"],
240
+ "mcp-server-development": ["mcp", "server", "figma", "github", "stdio", "json-rpc"],
241
+ "cli-ux-patterns": ["cli", "ux", "undo", "redo", "prompt", "interactive", "confirm"],
242
+ "web-design-premium": ["web", "css", "html", "react", "premium", "glassmorphism", "style"],
243
+ "llm-orchestration": ["llm", "agent", "orchestration", "token", "prune", "prompt", "thinking"],
244
+ "async-python": ["async", "sync", "await", "loop", "concurrency", "thread", "process"]
245
+ }
246
+
247
+ for skill_name, keys in keywords.items():
248
+ if any(k in user_prompt_lower for k in keys):
249
+ matched_skills.append(skill_name)
250
+
251
+ # If no keywords matched, default to AGENTS.md
252
+ if not matched_skills:
253
+ agents_path = UTIM_DIR / 'AGENTS.md'
254
+ if agents_path.exists():
255
+ with open(agents_path, 'r', encoding='utf-8') as f:
256
+ content = f.read()
257
+ STATE["injected_contexts"].append(content)
258
+ return f"\n### PROJECT RULES & GUIDELINES ###\n{content}\n"
259
+ return ""
260
+
261
+ context = ""
262
+ for skill_name in matched_skills[:2]: # Load top 2 matched skills
263
+ skill_path = UTIM_DIR / 'skills' / skill_name / 'SKILL.md'
264
+ if skill_path.exists():
265
+ with open(skill_path, 'r', encoding='utf-8') as f:
266
+ content = f.read()
267
+ if content.startswith('---'):
268
+ parts = content.split('---', 2)
269
+ if len(parts) >= 3:
270
+ content = parts[2].strip()
271
+ STATE["injected_contexts"].append(content)
272
+ context += f"\n### RELEVANT CORE SKILL: {skill_name.upper()} ###\n{content[:1500]}\n"
273
+
274
+ return context
275
+ except Exception as e:
276
+ from utim_cli.logger import log_error
277
+ log_error("bootstrap", "Failed to get RAG context from skills", e)
278
+ return ""
279
+
280
+ def get_subagent_rag_context(subagent_name: str, query: str = "") -> str:
281
+ """Get RAG context string for the specified sub-agent prompt injection by reading from markdown skill files directly."""
282
+ try:
283
+ # Map subagent name to the most relevant skill file
284
+ mapping = {
285
+ "project_res": "software-architecture",
286
+ "plan_project": "llm-orchestration",
287
+ "web_search": "cli-ux-patterns",
288
+ "generate_image": "web-design-premium"
289
+ }
290
+ skill_name = mapping.get(subagent_name)
291
+ if not skill_name:
292
+ return ""
293
+
294
+ skill_path = UTIM_DIR / 'skills' / skill_name / 'SKILL.md'
295
+ if not skill_path.exists():
296
+ return ""
297
+
298
+ with open(skill_path, 'r', encoding='utf-8') as f:
299
+ content = f.read()
300
+
301
+ # Strip YAML frontmatter if present
302
+ if content.startswith('---'):
303
+ parts = content.split('---', 2)
304
+ if len(parts) >= 3:
305
+ content = parts[2].strip()
306
+
307
+ # Return a formatted summary context
308
+ context = f"\n\n### SUB-AGENT {subagent_name.upper()} SKILLS & FRAMEWORK (from {skill_name}) ###\n"
309
+ if len(content) > 1500:
310
+ context += content[:1500] + "\n... [Truncated for Context Limit]"
311
+ else:
312
+ context += content
313
+ return context
314
+ except Exception as e:
315
+ from utim_cli.logger import log_error
316
+ log_error("bootstrap", f"Failed to load subagent markdown context for {subagent_name}", e)
317
+ return ""
318
+
319
+ def get_time_rag_context(user_prompt: str) -> str:
320
+ """Gets relevant past task execution time experiences (mock/empty as databases are simplified)."""
321
+ return ""
322
+
323
+ if __name__ == '__main__':
324
+ print(f"Initializing UTIM... Database: {initialize_utim()}")
@@ -0,0 +1,135 @@
1
+ import os
2
+ import json
3
+ import requests
4
+ from utim_cli.config import config
5
+
6
+ class WrappedResponse:
7
+ """A compatibility wrapper that mimics requests.Response for client callers."""
8
+ def __init__(self, content_str=None, generator=None, status_code=200):
9
+ self.content_str = content_str
10
+ self.generator = generator
11
+ self.status_code = status_code
12
+ self.encoding = "utf-8"
13
+
14
+ def raise_for_status(self):
15
+ if self.status_code != 200:
16
+ raise requests.HTTPError(f"HTTP {self.status_code}")
17
+
18
+ def json(self):
19
+ if self.content_str:
20
+ return json.loads(self.content_str)
21
+ raise ValueError("No JSON content available")
22
+
23
+ def __enter__(self):
24
+ return self
25
+
26
+ def __exit__(self, exc_type, exc_val, exc_tb):
27
+ pass
28
+
29
+ def iter_lines(self, decode_unicode=False):
30
+ if self.generator:
31
+ return self.generator
32
+ if self.content_str:
33
+ return [self.content_str]
34
+ return []
35
+
36
+ def get_server_url() -> str:
37
+ return "https://utim-cli-production.up.railway.app"
38
+
39
+ def proxy_openrouter_request(json_data: dict, stream: bool = False, timeout=None) -> WrappedResponse:
40
+ """Routes LLM request to UTIM server /completions, or falls back to direct OpenRouter."""
41
+ # Ensure environment variables are loaded (orchestrator handles this, but tools.py is independent)
42
+ _cwd_env = os.path.join(os.getcwd(), ".env")
43
+ if os.path.isfile(_cwd_env):
44
+ try:
45
+ from dotenv import load_dotenv
46
+ load_dotenv(_cwd_env, override=True)
47
+ except Exception:
48
+ pass
49
+
50
+ api_key = config.get("api_key")
51
+ server_url = get_server_url()
52
+
53
+ if api_key:
54
+ headers = {
55
+ "X-API-Key": api_key,
56
+ "Content-Type": "application/json"
57
+ }
58
+ payload = {
59
+ "messages": json_data.get("messages"),
60
+ "model_id": json_data.get("model"),
61
+ "tools": json_data.get("tools"),
62
+ }
63
+
64
+ # UTIM server completions is a streaming endpoint
65
+ resp = requests.post(f"{server_url}/completions", json=payload, headers=headers, stream=True, timeout=timeout)
66
+ resp.raise_for_status()
67
+
68
+ if stream or json_data.get("stream"):
69
+ def line_generator():
70
+ for line in resp.iter_lines(decode_unicode=True):
71
+ if line:
72
+ try:
73
+ data = json.loads(line)
74
+ if data.get("type") == "content_delta" and data.get("text"):
75
+ chunk = {
76
+ "choices": [{
77
+ "delta": {"content": data["text"]}
78
+ }]
79
+ }
80
+ yield "data: " + json.dumps(chunk)
81
+ elif data.get("type") == "done":
82
+ if data.get("error"):
83
+ yield "data: " + json.dumps({"error": {"message": data["error"]}})
84
+ break
85
+ tcs = data.get("tool_calls")
86
+ if tcs:
87
+ chunk = {
88
+ "choices": [{
89
+ "delta": {"tool_calls": tcs}
90
+ }]
91
+ }
92
+ yield "data: " + json.dumps(chunk)
93
+ break
94
+ except Exception:
95
+ pass
96
+ yield "data: [DONE]"
97
+ return WrappedResponse(generator=line_generator())
98
+ else:
99
+ # Consume stream to construct final non-streaming response dict
100
+ content = ""
101
+ tool_calls = None
102
+ for line in resp.iter_lines(decode_unicode=True):
103
+ if line:
104
+ try:
105
+ data = json.loads(line)
106
+ if data.get("type") == "content_delta" and data.get("text"):
107
+ content += data["text"]
108
+ elif data.get("type") == "done":
109
+ if data.get("error"):
110
+ raise RuntimeError(data["error"])
111
+ content = data.get("content") or content
112
+ tool_calls = data.get("tool_calls")
113
+ except Exception:
114
+ pass
115
+ res_dict = {
116
+ "choices": [{
117
+ "message": {
118
+ "role": "assistant",
119
+ "content": content,
120
+ "tool_calls": tool_calls
121
+ }
122
+ }]
123
+ }
124
+ return WrappedResponse(content_str=json.dumps(res_dict))
125
+ else:
126
+ llm_key = os.getenv("OPENROUTER_API_KEY")
127
+ if not llm_key:
128
+ raise RuntimeError("Neither UTIM API key nor local OPENROUTER_API_KEY is configured.")
129
+
130
+ headers = {
131
+ "Authorization": f"Bearer {llm_key}",
132
+ "Content-Type": "application/json"
133
+ }
134
+ # Direct forwarding to OpenRouter
135
+ return requests.post("https://openrouter.ai/api/v1/chat/completions", json=json_data, headers=headers, stream=stream or json_data.get("stream"), timeout=timeout)
utim_cli/config.py ADDED
@@ -0,0 +1,194 @@
1
+ import json
2
+ import shutil
3
+ import os
4
+ import pathlib
5
+ from typing import Any, Dict, Optional
6
+
7
+ class Config:
8
+ def __init__(self):
9
+ self.global_dir = pathlib.Path.home() / ".utim"
10
+ self.local_dir = pathlib.Path(".utim").resolve()
11
+ self._data: Dict[str, Any] = self._load()
12
+
13
+ def _load(self) -> Dict[str, Any]:
14
+ data = {}
15
+ # Load global config
16
+ global_config = self.global_dir / "config.json"
17
+ if global_config.exists():
18
+ try:
19
+ with open(global_config, "r", encoding="utf-8") as f:
20
+ data.update(json.load(f))
21
+ except Exception:
22
+ pass
23
+
24
+ # Load local config if exists
25
+ local_config = self.local_dir / "config.json"
26
+ if local_config.exists():
27
+ try:
28
+ with open(local_config, "r", encoding="utf-8") as f:
29
+ data.update(json.load(f))
30
+ except Exception:
31
+ pass
32
+ return data
33
+
34
+ def save(self):
35
+ # Save to global config directory
36
+ self.global_dir.mkdir(parents=True, exist_ok=True)
37
+ global_config = self.global_dir / "config.json"
38
+ try:
39
+ with open(global_config, "w", encoding="utf-8") as f:
40
+ json.dump(self._data, f, indent=4)
41
+ except Exception:
42
+ pass
43
+
44
+ # Save to local config directory if it exists
45
+ if self.local_dir.exists():
46
+ local_config = self.local_dir / "config.json"
47
+ try:
48
+ with open(local_config, "w", encoding="utf-8") as f:
49
+ json.dump(self._data, f, indent=4)
50
+ except Exception:
51
+ pass
52
+
53
+ def get(self, key: str, default: Any = None) -> Any:
54
+ return self._data.get(key, default)
55
+
56
+ def set(self, key: str, value: Any):
57
+ self._data[key] = value
58
+ self.save()
59
+
60
+ @property
61
+ def token(self) -> Optional[str]:
62
+ return self.get("token")
63
+
64
+ @property
65
+ def email(self) -> str:
66
+ return self.get("email", os.getenv("UTIM_EMAIL", "local@utim.dev"))
67
+
68
+ @property
69
+ def name(self) -> Optional[str]:
70
+ return self.get("name")
71
+
72
+ def clear(self):
73
+ self._data = {}
74
+ self.save()
75
+
76
+ # ── Custom / Bring-Your-Own-Model support ─────────────────────────────────
77
+
78
+ @property
79
+ def custom_models(self) -> list:
80
+ """Return the list of user-defined models.
81
+
82
+ Each entry is a dict with the following keys:
83
+ model_id – unique identifier shown in the picker (e.g. "gpt-4o")
84
+ provider_name – human-readable label (e.g. "OpenAI")
85
+ base_url – OpenAI-compatible chat completions base URL
86
+ (e.g. "https://api.openai.com/v1")
87
+ api_key – API key for that provider (stored in plain text
88
+ in .utim/config.json; the user is warned)
89
+ context_window – integer token budget (default 128 000)
90
+ """
91
+ return self._data.get("custom_models", [])
92
+
93
+ @custom_models.setter
94
+ def custom_models(self, value: list):
95
+ self._data["custom_models"] = value
96
+ self.save()
97
+
98
+ def add_custom_model(self, entry: dict) -> None:
99
+ """Append or replace a custom model entry keyed on model_id."""
100
+ models = self.custom_models
101
+ models = [m for m in models if m.get("model_id") != entry["model_id"]]
102
+ models.append(entry)
103
+ self.custom_models = models
104
+
105
+ def remove_custom_model(self, model_id: str) -> bool:
106
+ """Remove a custom model by model_id. Returns True if it existed."""
107
+ models = self.custom_models
108
+ new = [m for m in models if m.get("model_id") != model_id]
109
+ if len(new) == len(models):
110
+ return False
111
+ self.custom_models = new
112
+ return True
113
+
114
+ def get_custom_model(self, model_id: str) -> Optional[dict]:
115
+ """Return the custom model entry for *model_id*, or None."""
116
+ for m in self.custom_models:
117
+ if m.get("model_id") == model_id:
118
+ return m
119
+ return None
120
+
121
+ @property
122
+ def debug_mode(self) -> bool:
123
+ return os.environ.get("UTIM_DEBUG", "0").lower() in ("1", "true", "yes")
124
+
125
+ @property
126
+ def dry_run(self) -> bool:
127
+ return os.environ.get("UTIM_DRY_RUN", "0").lower() in ("1", "true", "yes") or self.get("dry_run", False)
128
+
129
+
130
+ @property
131
+ def fallback_models(self) -> list:
132
+ """Return fallback LLM models list."""
133
+ models = os.environ.get("UTIM_FALLBACK_MODELS")
134
+ if models:
135
+ return [m.strip() for m in models.split(",")]
136
+ return [
137
+ "poolside/laguna-m.1:free",
138
+ "qwen/qwen3-coder:free",
139
+ "nvidia/nemotron-3-ultra-550b-a55b:free",
140
+ "openrouter/free"
141
+ ]
142
+
143
+ @property
144
+ def keep_full_turns(self) -> int:
145
+ # Number of recent turns to retain in memory
146
+
147
+ try:
148
+ return int(os.environ.get("UTIM_KEEP_TURNS", "10"))
149
+ except ValueError:
150
+ return 10
151
+
152
+ @property
153
+ def compression_enabled(self) -> bool:
154
+ # Enable adaptive context compression
155
+ return os.environ.get("UTIM_COMPRESSION", "true").lower() in ("1", "true", "yes")
156
+
157
+ @property
158
+ def blender_path(self) -> str:
159
+ """Return the absolute path to the Blender executable.
160
+
161
+ Detection order:
162
+ 1. Environment variable ``UTIM_BLENDER_PATH``
163
+ 2. Stored user config key ``blender_path``
164
+ 3. Common Windows install locations
165
+ 4. ``shutil.which('blender')`` fallback
166
+ """
167
+ # 1. explicit env var
168
+ env_path = os.getenv("UTIM_BLENDER_PATH")
169
+ if env_path and os.path.isfile(env_path):
170
+ return env_path
171
+ # 2. stored in config file
172
+ stored = self.get("blender_path")
173
+ if stored and os.path.isfile(stored):
174
+ return stored
175
+ # 3. common install locations (Windows default)
176
+ common_paths = [
177
+ r"C:\\Program Files\\Blender Foundation\\Blender\\blender.exe",
178
+ r"C:\\Program Files (x86)\\Blender Foundation\\Blender\\blender.exe",
179
+ r"E:\\Blender\\blender.exe",
180
+ ]
181
+ for p in common_paths:
182
+ if os.path.isfile(p):
183
+ return p
184
+ # 4. which search (covers custom PATH entries)
185
+ which_path = shutil.which("blender")
186
+ if which_path:
187
+ return which_path
188
+ # Fallback – raise informative error later when used
189
+ return ""
190
+
191
+ # Global config instance
192
+ config = Config()
193
+ # Convenience constant for direct access
194
+ BLENDER_PATH = config.blender_path