talkops-opscloud 0.3.0b1__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 (309) hide show
  1. opscloud/__init__.py +5 -0
  2. opscloud/__main__.py +6 -0
  3. opscloud/_constants.py +53 -0
  4. opscloud/_debug.py +145 -0
  5. opscloud/_debug_buffer.py +250 -0
  6. opscloud/_textual_patches.py +367 -0
  7. opscloud/_version.py +15 -0
  8. opscloud/agent/__init__.py +27 -0
  9. opscloud/agent/config.py +168 -0
  10. opscloud/agent/factory.py +1103 -0
  11. opscloud/approval_mode.py +44 -0
  12. opscloud/backend/__init__.py +49 -0
  13. opscloud/backend/composite.py +89 -0
  14. opscloud/backend/local.py +183 -0
  15. opscloud/backend/registry.py +79 -0
  16. opscloud/backend/sandbox_config.py +107 -0
  17. opscloud/backend/sandbox_factory.py +432 -0
  18. opscloud/built_in_skills/remember/SKILL.md +124 -0
  19. opscloud/built_in_skills/skill-creator/SKILL.md +348 -0
  20. opscloud/built_in_skills/skill-creator/scripts/init_skill.py +371 -0
  21. opscloud/built_in_skills/skill-creator/scripts/quick_validate.py +165 -0
  22. opscloud/cli/__init__.py +1 -0
  23. opscloud/cli/commands/__init__.py +15 -0
  24. opscloud/cli/commands/auth.py +45 -0
  25. opscloud/cli/commands/doctor.py +100 -0
  26. opscloud/cli/commands/mcp.py +137 -0
  27. opscloud/cli/commands/threads.py +51 -0
  28. opscloud/cli/interactive.py +154 -0
  29. opscloud/cli/main.py +903 -0
  30. opscloud/cli/non_interactive.py +425 -0
  31. opscloud/cli/server_manager.py +58 -0
  32. opscloud/commands/__init__.py +19 -0
  33. opscloud/commands/_base.py +110 -0
  34. opscloud/commands/_guards.py +52 -0
  35. opscloud/commands/_router.py +146 -0
  36. opscloud/commands/_types.py +36 -0
  37. opscloud/commands/core/__init__.py +47 -0
  38. opscloud/commands/core/auth.py +94 -0
  39. opscloud/commands/core/bug.py +46 -0
  40. opscloud/commands/core/clear.py +106 -0
  41. opscloud/commands/core/cloud.py +185 -0
  42. opscloud/commands/core/compact.py +128 -0
  43. opscloud/commands/core/config_cmd.py +120 -0
  44. opscloud/commands/core/context.py +220 -0
  45. opscloud/commands/core/cost.py +44 -0
  46. opscloud/commands/core/doctor.py +535 -0
  47. opscloud/commands/core/effort.py +108 -0
  48. opscloud/commands/core/exit_cmd.py +41 -0
  49. opscloud/commands/core/fast.py +148 -0
  50. opscloud/commands/core/help_cmd.py +94 -0
  51. opscloud/commands/core/mcp.py +134 -0
  52. opscloud/commands/core/model.py +109 -0
  53. opscloud/commands/core/permissions.py +196 -0
  54. opscloud/commands/core/plugins.py +92 -0
  55. opscloud/commands/core/pool.py +108 -0
  56. opscloud/commands/core/resume.py +97 -0
  57. opscloud/commands/core/skills.py +178 -0
  58. opscloud/commands/power/__init__.py +51 -0
  59. opscloud/commands/power/agents.py +98 -0
  60. opscloud/commands/power/btw.py +85 -0
  61. opscloud/commands/power/copy.py +97 -0
  62. opscloud/commands/power/goal.py +548 -0
  63. opscloud/commands/power/loop.py +295 -0
  64. opscloud/commands/power/memory.py +287 -0
  65. opscloud/commands/power/review.py +152 -0
  66. opscloud/commands/power/rubric.py +287 -0
  67. opscloud/commands/power/runtime.py +321 -0
  68. opscloud/commands/power/skill_creator.py +70 -0
  69. opscloud/commands/power/skill_invoke.py +165 -0
  70. opscloud/commands/power/tasks.py +209 -0
  71. opscloud/commands/power/trace.py +117 -0
  72. opscloud/commands/power/ui_toggles.py +135 -0
  73. opscloud/commands/power/version.py +52 -0
  74. opscloud/commands/registry.py +176 -0
  75. opscloud/config/__init__.py +114 -0
  76. opscloud/config/adapters/__init__.py +5 -0
  77. opscloud/config/adapters/postgres.py +776 -0
  78. opscloud/config/adapters/sqlite.py +769 -0
  79. opscloud/config/aws.py +248 -0
  80. opscloud/config/cloud_profiles.py +437 -0
  81. opscloud/config/env_vars.py +81 -0
  82. opscloud/config/langsmith.py +319 -0
  83. opscloud/config/manifest.py +817 -0
  84. opscloud/config/metadata.py +205 -0
  85. opscloud/config/paths.py +604 -0
  86. opscloud/config/plugins.py +130 -0
  87. opscloud/config/settings.py +834 -0
  88. opscloud/config/store.py +453 -0
  89. opscloud/config/store_factory.py +97 -0
  90. opscloud/config/toml_config.py +562 -0
  91. opscloud/editor.py +173 -0
  92. opscloud/exceptions.py +65 -0
  93. opscloud/file_ops.py +217 -0
  94. opscloud/hooks/__init__.py +139 -0
  95. opscloud/hooks/env.py +44 -0
  96. opscloud/hooks/interrupt.py +121 -0
  97. opscloud/hooks/models/__init__.py +109 -0
  98. opscloud/hooks/models/adapters.py +19 -0
  99. opscloud/hooks/models/config.py +79 -0
  100. opscloud/hooks/models/domain.py +434 -0
  101. opscloud/hooks/models/transport.py +37 -0
  102. opscloud/hooks/tools.py +231 -0
  103. opscloud/input.py +48 -0
  104. opscloud/integrations/__init__.py +80 -0
  105. opscloud/integrations/base.py +153 -0
  106. opscloud/integrations/content_filter.py +119 -0
  107. opscloud/integrations/event_bus.py +480 -0
  108. opscloud/integrations/hooks.py +238 -0
  109. opscloud/integrations/identity.py +181 -0
  110. opscloud/integrations/notifications.py +134 -0
  111. opscloud/integrations/stream_bridge.py +397 -0
  112. opscloud/integrations/thread_store.py +81 -0
  113. opscloud/json_types.py +20 -0
  114. opscloud/mcp/__init__.py +57 -0
  115. opscloud/mcp/config.py +203 -0
  116. opscloud/mcp/discovery.py +229 -0
  117. opscloud/mcp/mcp_info.py +109 -0
  118. opscloud/mcp/middleware.py +102 -0
  119. opscloud/mcp/preload.py +505 -0
  120. opscloud/mcp/raw_config.py +149 -0
  121. opscloud/mcp/semantic_profiler.py +305 -0
  122. opscloud/mcp/session_manager.py +727 -0
  123. opscloud/mcp/trust.py +105 -0
  124. opscloud/memory/__init__.py +27 -0
  125. opscloud/memory/branch.py +61 -0
  126. opscloud/memory/guard.py +19 -0
  127. opscloud/memory/onboarding.py +120 -0
  128. opscloud/memory/registry.py +134 -0
  129. opscloud/memory/store.py +223 -0
  130. opscloud/middleware/__init__.py +106 -0
  131. opscloud/middleware/_repository_bounds.py +459 -0
  132. opscloud/middleware/ask_user.py +152 -0
  133. opscloud/middleware/auto_mode.py +60 -0
  134. opscloud/middleware/auto_mode_hitl.py +2434 -0
  135. opscloud/middleware/compaction.py +566 -0
  136. opscloud/middleware/configurable_model.py +233 -0
  137. opscloud/middleware/cost_tracking.py +1021 -0
  138. opscloud/middleware/glm_stall_recovery.py +109 -0
  139. opscloud/middleware/goal_criteria.py +1234 -0
  140. opscloud/middleware/goal_state_notice.py +712 -0
  141. opscloud/middleware/goal_tools.py +407 -0
  142. opscloud/middleware/headless_mcp_guard.py +88 -0
  143. opscloud/middleware/jev_model_router.py +1018 -0
  144. opscloud/middleware/local_context.py +460 -0
  145. opscloud/middleware/mcp_context.py +151 -0
  146. opscloud/middleware/mcp_middleware.py +146 -0
  147. opscloud/middleware/memory_guard.py +393 -0
  148. opscloud/middleware/model_retry.py +205 -0
  149. opscloud/middleware/registry.py +137 -0
  150. opscloud/middleware/reliable_rubric.py +709 -0
  151. opscloud/middleware/resume_state.py +91 -0
  152. opscloud/middleware/server_hooks.py +1189 -0
  153. opscloud/middleware/shell_allow_list.py +90 -0
  154. opscloud/middleware/skills.py +382 -0
  155. opscloud/middleware/subagent_artifacts.py +84 -0
  156. opscloud/middleware/subagent_telemetry.py +261 -0
  157. opscloud/middleware/subagents.py +243 -0
  158. opscloud/middleware/tool_filter.py +384 -0
  159. opscloud/middleware/unified_system_message.py +62 -0
  160. opscloud/model/__init__.py +37 -0
  161. opscloud/model/config.py +2992 -0
  162. opscloud/model/factory.py +477 -0
  163. opscloud/model/pool.py +459 -0
  164. opscloud/model/reasoning.py +282 -0
  165. opscloud/offload.py +173 -0
  166. opscloud/output.py +64 -0
  167. opscloud/plugins/__init__.py +189 -0
  168. opscloud/plugins/_json.py +22 -0
  169. opscloud/plugins/adapters/__init__.py +13 -0
  170. opscloud/plugins/adapters/agents.py +112 -0
  171. opscloud/plugins/adapters/commands.py +190 -0
  172. opscloud/plugins/adapters/mcp.py +363 -0
  173. opscloud/plugins/adapters/skills.py +132 -0
  174. opscloud/plugins/commands_cli.py +541 -0
  175. opscloud/plugins/discovery.py +512 -0
  176. opscloud/plugins/manifest.py +335 -0
  177. opscloud/plugins/marketplace.py +893 -0
  178. opscloud/plugins/models.py +334 -0
  179. opscloud/plugins/project_plugins.py +317 -0
  180. opscloud/plugins/protocol.py +31 -0
  181. opscloud/plugins/store.py +905 -0
  182. opscloud/plugins/substitution.py +122 -0
  183. opscloud/project_utils.py +145 -0
  184. opscloud/prompts/__init__.py +37 -0
  185. opscloud/prompts/system.py +483 -0
  186. opscloud/prompts/templates/system_prompt.md +166 -0
  187. opscloud/prompts/types.py +54 -0
  188. opscloud/rubrics/__init__.py +42 -0
  189. opscloud/rubrics/evaluator.py +404 -0
  190. opscloud/rubrics/evidence_extractor.py +229 -0
  191. opscloud/rubrics/generator.py +239 -0
  192. opscloud/rubrics/jev_compiler.py +175 -0
  193. opscloud/rubrics/jev_grader.py +297 -0
  194. opscloud/rubrics/middleware.py +8 -0
  195. opscloud/schema/__init__.py +107 -0
  196. opscloud/schema/interrupts.py +981 -0
  197. opscloud/security/__init__.py +91 -0
  198. opscloud/security/approval_mode.py +528 -0
  199. opscloud/security/approval_mode_source.py +230 -0
  200. opscloud/security/cli_ast_evaluator.py +552 -0
  201. opscloud/security/jev_classifier.py +651 -0
  202. opscloud/security/shell_safety.py +340 -0
  203. opscloud/security/unicode_security.py +439 -0
  204. opscloud/security/url_validation.py +160 -0
  205. opscloud/server/__init__.py +13 -0
  206. opscloud/server/_server_config.py +158 -0
  207. opscloud/server/server.py +242 -0
  208. opscloud/server/server_graph.py +93 -0
  209. opscloud/skills/__init__.py +91 -0
  210. opscloud/skills/commands.py +1099 -0
  211. opscloud/skills/invocation.py +200 -0
  212. opscloud/skills/load.py +388 -0
  213. opscloud/skills/loader.py +23 -0
  214. opscloud/skills/merge.py +65 -0
  215. opscloud/skills/registry.py +263 -0
  216. opscloud/skills/sources.py +174 -0
  217. opscloud/skills/trust.py +417 -0
  218. opscloud/state/__init__.py +142 -0
  219. opscloud/state/base.py +154 -0
  220. opscloud/state/cloud_context.py +262 -0
  221. opscloud/state/goal_channels.py +124 -0
  222. opscloud/state/goal_state_limits.py +175 -0
  223. opscloud/state/resume_state.py +58 -0
  224. opscloud/state/service_context.py +281 -0
  225. opscloud/state/session.py +957 -0
  226. opscloud/state/state_migration.py +86 -0
  227. opscloud/subagents/__init__.py +30 -0
  228. opscloud/subagents/loader.py +331 -0
  229. opscloud/subagents/subagents_parser.py +117 -0
  230. opscloud/subagents/types.py +25 -0
  231. opscloud/tools/__init__.py +36 -0
  232. opscloud/tools/catalog.py +37 -0
  233. opscloud/tools/display.py +24 -0
  234. opscloud/tools/fetch_url.py +176 -0
  235. opscloud/tools/goal_tools.py +423 -0
  236. opscloud/tools/registry.py +105 -0
  237. opscloud/tools/thread.py +32 -0
  238. opscloud/tools/web_search.py +215 -0
  239. opscloud/ui/__init__.py +119 -0
  240. opscloud/ui/app.py +5400 -0
  241. opscloud/ui/app.tcss +479 -0
  242. opscloud/ui/clipboard.py +147 -0
  243. opscloud/ui/command_registry.py +482 -0
  244. opscloud/ui/message_store.py +152 -0
  245. opscloud/ui/permission_store.py +360 -0
  246. opscloud/ui/preferences.py +113 -0
  247. opscloud/ui/prompt_manager.py +96 -0
  248. opscloud/ui/remote_client.py +505 -0
  249. opscloud/ui/textual_adapter.py +1269 -0
  250. opscloud/ui/theme.py +728 -0
  251. opscloud/ui/ui_help.py +415 -0
  252. opscloud/ui/widgets/__init__.py +383 -0
  253. opscloud/ui/widgets/_ask_user_types.py +81 -0
  254. opscloud/ui/widgets/_copy_spans.py +52 -0
  255. opscloud/ui/widgets/_inline_prompt.py +290 -0
  256. opscloud/ui/widgets/_js_eval_display.py +139 -0
  257. opscloud/ui/widgets/_links.py +175 -0
  258. opscloud/ui/widgets/_tool_stream.py +308 -0
  259. opscloud/ui/widgets/agent_selector.py +297 -0
  260. opscloud/ui/widgets/approval.py +741 -0
  261. opscloud/ui/widgets/ask_user.py +539 -0
  262. opscloud/ui/widgets/auth.py +695 -0
  263. opscloud/ui/widgets/auth_manager.py +214 -0
  264. opscloud/ui/widgets/auto_mode_notice.py +146 -0
  265. opscloud/ui/widgets/autocomplete.py +709 -0
  266. opscloud/ui/widgets/chat_input.py +805 -0
  267. opscloud/ui/widgets/cloud_selector.py +528 -0
  268. opscloud/ui/widgets/config_manager.py +652 -0
  269. opscloud/ui/widgets/debug_console.py +1269 -0
  270. opscloud/ui/widgets/devops_renderers.py +184 -0
  271. opscloud/ui/widgets/diff.py +237 -0
  272. opscloud/ui/widgets/effort_selector.py +166 -0
  273. opscloud/ui/widgets/goal_review.py +612 -0
  274. opscloud/ui/widgets/goal_status.py +57 -0
  275. opscloud/ui/widgets/infra_panel.py +141 -0
  276. opscloud/ui/widgets/install_confirm.py +114 -0
  277. opscloud/ui/widgets/loading.py +170 -0
  278. opscloud/ui/widgets/mcp_viewer.py +1204 -0
  279. opscloud/ui/widgets/messages.py +4461 -0
  280. opscloud/ui/widgets/model_selector.py +863 -0
  281. opscloud/ui/widgets/notification_center.py +108 -0
  282. opscloud/ui/widgets/notification_settings.py +119 -0
  283. opscloud/ui/widgets/operation_card.py +106 -0
  284. opscloud/ui/widgets/permissions_manager.py +667 -0
  285. opscloud/ui/widgets/plugin_manager.py +1212 -0
  286. opscloud/ui/widgets/pool_selector.py +376 -0
  287. opscloud/ui/widgets/preamble.py +53 -0
  288. opscloud/ui/widgets/skills_viewer.py +432 -0
  289. opscloud/ui/widgets/status.py +1037 -0
  290. opscloud/ui/widgets/subagent_panel.py +927 -0
  291. opscloud/ui/widgets/theme_selector.py +232 -0
  292. opscloud/ui/widgets/thread_selector.py +358 -0
  293. opscloud/ui/widgets/toast.py +31 -0
  294. opscloud/ui/widgets/tool_display.py +325 -0
  295. opscloud/ui/widgets/tool_renderers.py +234 -0
  296. opscloud/ui/widgets/tool_widgets.py +278 -0
  297. opscloud/ui/widgets/welcome.py +421 -0
  298. opscloud/ui/widgets/welcome_popup.py +102 -0
  299. opscloud/utils/__init__.py +24 -0
  300. opscloud/utils/cost_estimation.py +169 -0
  301. opscloud/utils/git.py +390 -0
  302. opscloud/utils/logger.py +571 -0
  303. opscloud/utils/session_stats.py +115 -0
  304. opscloud/utils/startup_error.py +37 -0
  305. talkops_opscloud-0.3.0b1.dist-info/METADATA +449 -0
  306. talkops_opscloud-0.3.0b1.dist-info/RECORD +309 -0
  307. talkops_opscloud-0.3.0b1.dist-info/WHEEL +4 -0
  308. talkops_opscloud-0.3.0b1.dist-info/entry_points.txt +2 -0
  309. talkops_opscloud-0.3.0b1.dist-info/licenses/LICENSE +175 -0
@@ -0,0 +1,562 @@
1
+ """Atomic TOML configuration persistence for OpsCloud."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ from opscloud.utils.logger import get_logger
7
+ import os
8
+ import tempfile
9
+ import threading
10
+ import tomllib
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import tomli_w
15
+
16
+ from opscloud.config.paths import CONFIG_PATH, STATE_DIR
17
+
18
+ logger = get_logger(__name__)
19
+
20
+ _config_write_lock = threading.Lock()
21
+ _RECENT_MODELS_FILE = STATE_DIR / "recent_models.json"
22
+ _RECENT_PROFILES_FILE = STATE_DIR / "recent_cloud_profiles.json"
23
+ _MAX_RECENT = 10
24
+
25
+
26
+ def read_config_toml(config_path: Path | None = None) -> dict[str, Any]:
27
+ """Read config.toml and return parsed dict, or empty dict on missing/error."""
28
+ target_path = config_path or CONFIG_PATH
29
+ if not target_path.exists():
30
+ return {}
31
+ try:
32
+ with target_path.open("rb") as f:
33
+ return tomllib.load(f)
34
+ except Exception as exc:
35
+ logger.warning("Could not read config TOML from %s: %s", target_path, exc)
36
+ return {}
37
+
38
+
39
+ def _save_toml_field(
40
+ section: str,
41
+ field: str,
42
+ value: Any,
43
+ config_path: Path | None = None,
44
+ ) -> bool:
45
+ """Read-modify-write a key under a section in config.toml atomically."""
46
+ target_path = config_path or CONFIG_PATH
47
+ try:
48
+ with _config_write_lock:
49
+ target_path.parent.mkdir(parents=True, exist_ok=True)
50
+ if target_path.exists():
51
+ with target_path.open("rb") as f:
52
+ data = tomllib.load(f)
53
+ else:
54
+ data = {}
55
+
56
+ if section not in data or not isinstance(data[section], dict):
57
+ data[section] = {}
58
+ data[section][field] = value
59
+
60
+ fd, tmp_path = tempfile.mkstemp(dir=target_path.parent, suffix=".tmp")
61
+ try:
62
+ with os.fdopen(fd, "wb") as f:
63
+ tomli_w.dump(data, f)
64
+ Path(tmp_path).replace(target_path)
65
+ except BaseException:
66
+ with contextlib.suppress(OSError):
67
+ Path(tmp_path).unlink()
68
+ raise
69
+ except (OSError, tomllib.TOMLDecodeError, TypeError, ValueError):
70
+ logger.exception("Could not save %s.%s preference to %s", section, field, target_path)
71
+ return False
72
+ return True
73
+
74
+
75
+ def save_recent_model(model_spec: str, config_path: Path | None = None) -> bool:
76
+ """Persist recent model selection to [models].recent in config.toml and MRU cache."""
77
+ if not isinstance(model_spec, str) or not model_spec:
78
+ return False
79
+ ok = _save_toml_field("models", "recent", model_spec, config_path)
80
+ touch_recent_model(model_spec)
81
+ return ok
82
+
83
+
84
+ def load_recent_model(config_path: Path | None = None) -> str | None:
85
+ """Load [models].recent from config.toml."""
86
+ data = read_config_toml(config_path)
87
+ val = data.get("models", {}).get("recent")
88
+ return val.strip() if isinstance(val, str) and val.strip() else None
89
+
90
+
91
+ def save_default_model(model_spec: str, config_path: Path | None = None) -> bool:
92
+ """Persist default model selection to [models].default in config.toml."""
93
+ if not isinstance(model_spec, str) or not model_spec:
94
+ return False
95
+ return _save_toml_field("models", "default", model_spec, config_path)
96
+
97
+
98
+ def clear_default_model(config_path: Path | None = None) -> bool:
99
+ """Remove default model entry from [models].default in config.toml."""
100
+ target_path = config_path or CONFIG_PATH
101
+ if not target_path.exists():
102
+ return True
103
+ try:
104
+ with _config_write_lock:
105
+ with target_path.open("rb") as f:
106
+ data = tomllib.load(f)
107
+ models_sec = data.get("models")
108
+ if not isinstance(models_sec, dict) or "default" not in models_sec:
109
+ return True
110
+ del models_sec["default"]
111
+
112
+ fd, tmp_path = tempfile.mkstemp(dir=target_path.parent, suffix=".tmp")
113
+ try:
114
+ with os.fdopen(fd, "wb") as f:
115
+ tomli_w.dump(data, f)
116
+ Path(tmp_path).replace(target_path)
117
+ except BaseException:
118
+ with contextlib.suppress(OSError):
119
+ Path(tmp_path).unlink()
120
+ raise
121
+ except (OSError, tomllib.TOMLDecodeError, TypeError, ValueError):
122
+ logger.exception("Could not clear default model preference")
123
+ return False
124
+ return True
125
+
126
+
127
+ def load_default_model(config_path: Path | None = None) -> str | None:
128
+ """Load [models].default from config.toml, falling back to [models].recent."""
129
+ data = read_config_toml(config_path)
130
+ models_sec = data.get("models", {})
131
+ val = models_sec.get("default")
132
+ if isinstance(val, str) and val.strip():
133
+ return val.strip()
134
+ recent = models_sec.get("recent")
135
+ if isinstance(recent, str) and recent.strip():
136
+ return recent.strip()
137
+ return None
138
+
139
+
140
+ def touch_recent_model(model_spec: str) -> bool:
141
+ """Promote model_spec to the front of recent_models.json."""
142
+ if not isinstance(model_spec, str) or not model_spec:
143
+ return False
144
+ try:
145
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
146
+ recent: list[str] = []
147
+ if _RECENT_MODELS_FILE.exists():
148
+ try:
149
+ import json
150
+
151
+ raw = json.loads(_RECENT_MODELS_FILE.read_text(encoding="utf-8"))
152
+ if isinstance(raw, list):
153
+ recent = [str(item) for item in raw]
154
+ elif isinstance(raw, dict) and isinstance(raw.get("models"), list):
155
+ recent = [str(item) for item in raw["models"]]
156
+ except Exception:
157
+ recent = []
158
+ recent = [s for s in recent if s != model_spec]
159
+ recent.insert(0, model_spec)
160
+ recent = recent[:_MAX_RECENT]
161
+ import json
162
+
163
+ _RECENT_MODELS_FILE.write_text(json.dumps({"models": recent}, indent=2), encoding="utf-8")
164
+ return True
165
+ except Exception as exc:
166
+ logger.warning("Could not update recent models file: %s", exc)
167
+ return False
168
+
169
+
170
+ def save_effort_for_model(
171
+ model_spec: str,
172
+ effort: str | None,
173
+ config_path: Path | None = None,
174
+ ) -> bool:
175
+ """Read-modify-write an entry under [effort.by_model] in config.toml."""
176
+ if not isinstance(model_spec, str) or not model_spec:
177
+ return False
178
+ target_path = config_path or CONFIG_PATH
179
+
180
+ try:
181
+ with _config_write_lock:
182
+ target_path.parent.mkdir(parents=True, exist_ok=True)
183
+ if target_path.exists():
184
+ with target_path.open("rb") as f:
185
+ data = tomllib.load(f)
186
+ else:
187
+ data = {}
188
+
189
+ effort_sec = data.setdefault("effort", {})
190
+ if not isinstance(effort_sec, dict):
191
+ effort_sec = {}
192
+ data["effort"] = effort_sec
193
+ by_mod = effort_sec.setdefault("by_model", {})
194
+ if not isinstance(by_mod, dict):
195
+ by_mod = {}
196
+ effort_sec["by_model"] = by_mod
197
+
198
+ if effort is None:
199
+ by_mod.pop(model_spec, None)
200
+ if not by_mod:
201
+ effort_sec.pop("by_model", None)
202
+ if not effort_sec:
203
+ data.pop("effort", None)
204
+ else:
205
+ by_mod[model_spec] = effort
206
+
207
+ fd, tmp_path = tempfile.mkstemp(dir=target_path.parent, suffix=".tmp")
208
+ try:
209
+ with os.fdopen(fd, "wb") as f:
210
+ tomli_w.dump(data, f)
211
+ Path(tmp_path).replace(target_path)
212
+ except BaseException:
213
+ with contextlib.suppress(OSError):
214
+ Path(tmp_path).unlink()
215
+ raise
216
+ except (OSError, tomllib.TOMLDecodeError, TypeError, ValueError):
217
+ logger.exception("Could not save reasoning effort preference for %s", model_spec)
218
+ return False
219
+ return True
220
+
221
+
222
+ def clear_effort_for_model(model_spec: str, config_path: Path | None = None) -> bool:
223
+ """Remove reasoning effort entry for model_spec from [effort.by_model] in config.toml."""
224
+ return save_effort_for_model(model_spec, None, config_path=config_path)
225
+
226
+
227
+ def load_effort_for_model(model_spec: str, config_path: Path | None = None) -> str | None:
228
+ """Load persisted reasoning effort for a model from [effort.by_model] in config.toml."""
229
+ data = read_config_toml(config_path)
230
+ by_mod = data.get("effort", {}).get("by_model", {})
231
+ if isinstance(by_mod, dict):
232
+ val = by_mod.get(model_spec)
233
+ return val.strip() if isinstance(val, str) and val.strip() else None
234
+ return None
235
+
236
+
237
+ def save_recent_agent(agent_name: str, config_path: Path | None = None) -> bool:
238
+ """Persist recent agent selection to [agents].recent in config.toml."""
239
+ return _save_toml_field("agents", "recent", agent_name, config_path)
240
+
241
+
242
+ def load_recent_agent(config_path: Path | None = None) -> str | None:
243
+ """Load [agents].recent from config.toml."""
244
+ data = read_config_toml(config_path)
245
+ val = data.get("agents", {}).get("recent")
246
+ return val.strip() if isinstance(val, str) and val.strip() else None
247
+
248
+
249
+ def save_default_agent(agent_name: str, config_path: Path | None = None) -> bool:
250
+ """Persist default agent selection to [agents].default in config.toml."""
251
+ return _save_toml_field("agents", "default", agent_name, config_path)
252
+
253
+
254
+ def clear_default_agent(config_path: Path | None = None) -> bool:
255
+ """Remove default agent entry from [agents].default in config.toml."""
256
+ target_path = config_path or CONFIG_PATH
257
+ if not target_path.exists():
258
+ return True
259
+ try:
260
+ with _config_write_lock:
261
+ with target_path.open("rb") as f:
262
+ data = tomllib.load(f)
263
+ agents_sec = data.get("agents")
264
+ if not isinstance(agents_sec, dict) or "default" not in agents_sec:
265
+ return True
266
+ del agents_sec["default"]
267
+
268
+ fd, tmp_path = tempfile.mkstemp(dir=target_path.parent, suffix=".tmp")
269
+ try:
270
+ with os.fdopen(fd, "wb") as f:
271
+ tomli_w.dump(data, f)
272
+ Path(tmp_path).replace(target_path)
273
+ except BaseException:
274
+ with contextlib.suppress(OSError):
275
+ Path(tmp_path).unlink()
276
+ raise
277
+ except (OSError, tomllib.TOMLDecodeError, TypeError, ValueError):
278
+ logger.exception("Could not clear default agent preference")
279
+ return False
280
+ return True
281
+
282
+
283
+ def load_default_agent(config_path: Path | None = None) -> str | None:
284
+ """Load [agents].default from config.toml, falling back to [agents].recent."""
285
+ data = read_config_toml(config_path)
286
+ agents_sec = data.get("agents", {})
287
+ val = agents_sec.get("default")
288
+ if isinstance(val, str) and val.strip():
289
+ return val.strip()
290
+ recent = agents_sec.get("recent")
291
+ if isinstance(recent, str) and recent.strip():
292
+ return recent.strip()
293
+ return None
294
+
295
+
296
+ def save_theme_preference(theme: str, config_path: Path | None = None) -> bool:
297
+ """Persist theme preference to [ui].theme in config.toml."""
298
+ return _save_toml_field("ui", "theme", theme, config_path)
299
+
300
+
301
+ def load_theme_preference(config_path: Path | None = None) -> str:
302
+ """Load theme preference from [ui].theme in config.toml, defaulting to 'opscloud-dark'."""
303
+ data = read_config_toml(config_path)
304
+ val = data.get("ui", {}).get("theme")
305
+ if isinstance(val, str) and val.strip():
306
+ return val.strip()
307
+ return "opscloud-dark"
308
+
309
+
310
+ def apply_stored_credentials() -> None:
311
+ """Apply any stored credentials from config into the environment."""
312
+ data = read_config_toml()
313
+ env_sec = data.get("env", {})
314
+ if isinstance(env_sec, dict):
315
+ for k, v in env_sec.items():
316
+ if k not in os.environ and isinstance(v, str):
317
+ os.environ[k] = v
318
+
319
+
320
+ def save_agent_pool(
321
+ pool: dict[str, str],
322
+ provider: str | None = None,
323
+ config_path: Path | None = None,
324
+ ) -> bool:
325
+ """Save user-configured agent model pool to [agent_pool] in config.toml."""
326
+ target_path = config_path or CONFIG_PATH
327
+ try:
328
+ with _config_write_lock:
329
+ target_path.parent.mkdir(parents=True, exist_ok=True)
330
+ if target_path.exists():
331
+ with target_path.open("rb") as f:
332
+ data = tomllib.load(f)
333
+ else:
334
+ data = {}
335
+
336
+ pool_sec: dict[str, Any] = {}
337
+ if provider:
338
+ pool_sec["provider"] = provider
339
+ for key in ("fast", "standard", "powerful"):
340
+ if key in pool and pool[key]:
341
+ pool_sec[key] = str(pool[key]).strip()
342
+
343
+ data["agent_pool"] = pool_sec
344
+
345
+ fd, tmp_path = tempfile.mkstemp(dir=target_path.parent, suffix=".tmp")
346
+ try:
347
+ with os.fdopen(fd, "wb") as f:
348
+ tomli_w.dump(data, f)
349
+ Path(tmp_path).replace(target_path)
350
+ except BaseException:
351
+ with contextlib.suppress(OSError):
352
+ Path(tmp_path).unlink()
353
+ raise
354
+ except (OSError, tomllib.TOMLDecodeError, TypeError, ValueError):
355
+ logger.exception("Could not save agent_pool preference to %s", target_path)
356
+ return False
357
+ return True
358
+
359
+
360
+ def load_agent_pool(config_path: Path | None = None) -> dict[str, str] | None:
361
+ """Load user-configured agent model pool from [agent_pool] in config.toml."""
362
+ data = read_config_toml(config_path)
363
+ pool_sec = data.get("agent_pool") or data.get("model_pool")
364
+ if not isinstance(pool_sec, dict) or not pool_sec:
365
+ return None
366
+
367
+ res: dict[str, str] = {}
368
+ for key in ("fast", "standard", "powerful", "provider"):
369
+ val = pool_sec.get(key)
370
+ if isinstance(val, str) and val.strip():
371
+ res[key] = val.strip()
372
+
373
+ if not any(k in res for k in ("fast", "standard", "powerful")):
374
+ return None
375
+ return res
376
+
377
+
378
+ def clear_agent_pool(config_path: Path | None = None) -> bool:
379
+ """Remove [agent_pool] section from config.toml."""
380
+ target_path = config_path or CONFIG_PATH
381
+ if not target_path.exists():
382
+ return True
383
+ try:
384
+ with _config_write_lock:
385
+ with target_path.open("rb") as f:
386
+ data = tomllib.load(f)
387
+ if "agent_pool" not in data and "model_pool" not in data:
388
+ return True
389
+ data.pop("agent_pool", None)
390
+ data.pop("model_pool", None)
391
+
392
+ fd, tmp_path = tempfile.mkstemp(dir=target_path.parent, suffix=".tmp")
393
+ try:
394
+ with os.fdopen(fd, "wb") as f:
395
+ tomli_w.dump(data, f)
396
+ Path(tmp_path).replace(target_path)
397
+ except BaseException:
398
+ with contextlib.suppress(OSError):
399
+ Path(tmp_path).unlink()
400
+ raise
401
+ except (OSError, tomllib.TOMLDecodeError, TypeError, ValueError):
402
+ logger.exception("Could not clear agent_pool preference")
403
+ return False
404
+ return True
405
+
406
+
407
+ def save_aws_profile(profile: str, config_path: Path | None = None) -> bool:
408
+ """Persist AWS profile selection to [aws].profile in config.toml."""
409
+ if not isinstance(profile, str) or not profile.strip():
410
+ return False
411
+ clean_profile = profile.strip()
412
+ ok = _save_toml_field("aws", "profile", clean_profile, config_path)
413
+ save_recent_aws_profile(clean_profile)
414
+ return ok
415
+
416
+
417
+ def load_aws_profile(config_path: Path | None = None) -> str | None:
418
+ """Load [aws].profile from config.toml with fallback to [cloud].aws_profile."""
419
+ data = read_config_toml(config_path)
420
+ val = data.get("aws", {}).get("profile")
421
+ if isinstance(val, str) and val.strip():
422
+ return val.strip()
423
+ val = data.get("cloud", {}).get("aws_profile")
424
+ if isinstance(val, str) and val.strip():
425
+ return val.strip()
426
+ val = data.get("cloud", {}).get("profile")
427
+ if isinstance(val, str) and val.strip():
428
+ return val.strip()
429
+ return None
430
+
431
+
432
+ def save_aws_region(region: str, config_path: Path | None = None) -> bool:
433
+ """Persist AWS region selection to [aws].region in config.toml."""
434
+ if not isinstance(region, str) or not region.strip():
435
+ return False
436
+ return _save_toml_field("aws", "region", region.strip(), config_path)
437
+
438
+
439
+ def load_aws_region(config_path: Path | None = None) -> str | None:
440
+ """Load [aws].region from config.toml with fallback to [cloud].aws_region."""
441
+ data = read_config_toml(config_path)
442
+ val = data.get("aws", {}).get("region")
443
+ if isinstance(val, str) and val.strip():
444
+ return val.strip()
445
+ val = data.get("cloud", {}).get("aws_region")
446
+ if isinstance(val, str) and val.strip():
447
+ return val.strip()
448
+ val = data.get("cloud", {}).get("region")
449
+ if isinstance(val, str) and val.strip():
450
+ return val.strip()
451
+ return None
452
+
453
+
454
+ def clear_aws_profile(config_path: Path | None = None) -> bool:
455
+ """Remove [aws].profile from config.toml."""
456
+ target_path = config_path or CONFIG_PATH
457
+ if not target_path.exists():
458
+ return True
459
+ try:
460
+ with _config_write_lock:
461
+ with target_path.open("rb") as f:
462
+ data = tomllib.load(f)
463
+ aws_sec = data.get("aws")
464
+ if not isinstance(aws_sec, dict) or "profile" not in aws_sec:
465
+ return True
466
+ del aws_sec["profile"]
467
+ if not aws_sec:
468
+ data.pop("aws", None)
469
+
470
+ fd, tmp_path = tempfile.mkstemp(dir=target_path.parent, suffix=".tmp")
471
+ try:
472
+ with os.fdopen(fd, "wb") as f:
473
+ tomli_w.dump(data, f)
474
+ Path(tmp_path).replace(target_path)
475
+ except BaseException:
476
+ with contextlib.suppress(OSError):
477
+ Path(tmp_path).unlink()
478
+ raise
479
+ except (OSError, tomllib.TOMLDecodeError, TypeError, ValueError):
480
+ logger.exception("Could not clear aws.profile preference")
481
+ return False
482
+ return True
483
+
484
+
485
+ def clear_aws_region(config_path: Path | None = None) -> bool:
486
+ """Remove [aws].region from config.toml."""
487
+ target_path = config_path or CONFIG_PATH
488
+ if not target_path.exists():
489
+ return True
490
+ try:
491
+ with _config_write_lock:
492
+ with target_path.open("rb") as f:
493
+ data = tomllib.load(f)
494
+ aws_sec = data.get("aws")
495
+ if not isinstance(aws_sec, dict) or "region" not in aws_sec:
496
+ return True
497
+ del aws_sec["region"]
498
+ if not aws_sec:
499
+ data.pop("aws", None)
500
+
501
+ fd, tmp_path = tempfile.mkstemp(dir=target_path.parent, suffix=".tmp")
502
+ try:
503
+ with os.fdopen(fd, "wb") as f:
504
+ tomli_w.dump(data, f)
505
+ Path(tmp_path).replace(target_path)
506
+ except BaseException:
507
+ with contextlib.suppress(OSError):
508
+ Path(tmp_path).unlink()
509
+ raise
510
+ except (OSError, tomllib.TOMLDecodeError, TypeError, ValueError):
511
+ logger.exception("Could not clear aws.region preference")
512
+ return False
513
+ return True
514
+
515
+
516
+ def save_recent_aws_profile(profile: str) -> bool:
517
+ """Promote profile to front of recent_cloud_profiles.json."""
518
+ if not isinstance(profile, str) or not profile.strip():
519
+ return False
520
+ clean_profile = profile.strip()
521
+ try:
522
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
523
+ recent: list[str] = []
524
+ if _RECENT_PROFILES_FILE.exists():
525
+ try:
526
+ import json
527
+
528
+ raw = json.loads(_RECENT_PROFILES_FILE.read_text(encoding="utf-8"))
529
+ if isinstance(raw, list):
530
+ recent = [str(x) for x in raw]
531
+ elif isinstance(raw, dict) and isinstance(raw.get("aws_profiles"), list):
532
+ recent = [str(x) for x in raw["aws_profiles"]]
533
+ except Exception:
534
+ recent = []
535
+ recent = [p for p in recent if p != clean_profile]
536
+ recent.insert(0, clean_profile)
537
+ recent = recent[:_MAX_RECENT]
538
+ import json
539
+
540
+ _RECENT_PROFILES_FILE.write_text(json.dumps({"aws_profiles": recent}, indent=2), encoding="utf-8")
541
+ return True
542
+ except Exception as exc:
543
+ logger.warning("Could not update recent cloud profiles: %s", exc)
544
+ return False
545
+
546
+
547
+ def load_recent_aws_profiles() -> list[str]:
548
+ """Load recent AWS profiles from recent_cloud_profiles.json."""
549
+ if not _RECENT_PROFILES_FILE.exists():
550
+ return []
551
+ try:
552
+ import json
553
+
554
+ raw = json.loads(_RECENT_PROFILES_FILE.read_text(encoding="utf-8"))
555
+ if isinstance(raw, list):
556
+ return [str(x) for x in raw]
557
+ if isinstance(raw, dict) and isinstance(raw.get("aws_profiles"), list):
558
+ return [str(x) for x in raw["aws_profiles"]]
559
+ return []
560
+ except Exception:
561
+ return []
562
+