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.
- opscloud/__init__.py +5 -0
- opscloud/__main__.py +6 -0
- opscloud/_constants.py +53 -0
- opscloud/_debug.py +145 -0
- opscloud/_debug_buffer.py +250 -0
- opscloud/_textual_patches.py +367 -0
- opscloud/_version.py +15 -0
- opscloud/agent/__init__.py +27 -0
- opscloud/agent/config.py +168 -0
- opscloud/agent/factory.py +1103 -0
- opscloud/approval_mode.py +44 -0
- opscloud/backend/__init__.py +49 -0
- opscloud/backend/composite.py +89 -0
- opscloud/backend/local.py +183 -0
- opscloud/backend/registry.py +79 -0
- opscloud/backend/sandbox_config.py +107 -0
- opscloud/backend/sandbox_factory.py +432 -0
- opscloud/built_in_skills/remember/SKILL.md +124 -0
- opscloud/built_in_skills/skill-creator/SKILL.md +348 -0
- opscloud/built_in_skills/skill-creator/scripts/init_skill.py +371 -0
- opscloud/built_in_skills/skill-creator/scripts/quick_validate.py +165 -0
- opscloud/cli/__init__.py +1 -0
- opscloud/cli/commands/__init__.py +15 -0
- opscloud/cli/commands/auth.py +45 -0
- opscloud/cli/commands/doctor.py +100 -0
- opscloud/cli/commands/mcp.py +137 -0
- opscloud/cli/commands/threads.py +51 -0
- opscloud/cli/interactive.py +154 -0
- opscloud/cli/main.py +903 -0
- opscloud/cli/non_interactive.py +425 -0
- opscloud/cli/server_manager.py +58 -0
- opscloud/commands/__init__.py +19 -0
- opscloud/commands/_base.py +110 -0
- opscloud/commands/_guards.py +52 -0
- opscloud/commands/_router.py +146 -0
- opscloud/commands/_types.py +36 -0
- opscloud/commands/core/__init__.py +47 -0
- opscloud/commands/core/auth.py +94 -0
- opscloud/commands/core/bug.py +46 -0
- opscloud/commands/core/clear.py +106 -0
- opscloud/commands/core/cloud.py +185 -0
- opscloud/commands/core/compact.py +128 -0
- opscloud/commands/core/config_cmd.py +120 -0
- opscloud/commands/core/context.py +220 -0
- opscloud/commands/core/cost.py +44 -0
- opscloud/commands/core/doctor.py +535 -0
- opscloud/commands/core/effort.py +108 -0
- opscloud/commands/core/exit_cmd.py +41 -0
- opscloud/commands/core/fast.py +148 -0
- opscloud/commands/core/help_cmd.py +94 -0
- opscloud/commands/core/mcp.py +134 -0
- opscloud/commands/core/model.py +109 -0
- opscloud/commands/core/permissions.py +196 -0
- opscloud/commands/core/plugins.py +92 -0
- opscloud/commands/core/pool.py +108 -0
- opscloud/commands/core/resume.py +97 -0
- opscloud/commands/core/skills.py +178 -0
- opscloud/commands/power/__init__.py +51 -0
- opscloud/commands/power/agents.py +98 -0
- opscloud/commands/power/btw.py +85 -0
- opscloud/commands/power/copy.py +97 -0
- opscloud/commands/power/goal.py +548 -0
- opscloud/commands/power/loop.py +295 -0
- opscloud/commands/power/memory.py +287 -0
- opscloud/commands/power/review.py +152 -0
- opscloud/commands/power/rubric.py +287 -0
- opscloud/commands/power/runtime.py +321 -0
- opscloud/commands/power/skill_creator.py +70 -0
- opscloud/commands/power/skill_invoke.py +165 -0
- opscloud/commands/power/tasks.py +209 -0
- opscloud/commands/power/trace.py +117 -0
- opscloud/commands/power/ui_toggles.py +135 -0
- opscloud/commands/power/version.py +52 -0
- opscloud/commands/registry.py +176 -0
- opscloud/config/__init__.py +114 -0
- opscloud/config/adapters/__init__.py +5 -0
- opscloud/config/adapters/postgres.py +776 -0
- opscloud/config/adapters/sqlite.py +769 -0
- opscloud/config/aws.py +248 -0
- opscloud/config/cloud_profiles.py +437 -0
- opscloud/config/env_vars.py +81 -0
- opscloud/config/langsmith.py +319 -0
- opscloud/config/manifest.py +817 -0
- opscloud/config/metadata.py +205 -0
- opscloud/config/paths.py +604 -0
- opscloud/config/plugins.py +130 -0
- opscloud/config/settings.py +834 -0
- opscloud/config/store.py +453 -0
- opscloud/config/store_factory.py +97 -0
- opscloud/config/toml_config.py +562 -0
- opscloud/editor.py +173 -0
- opscloud/exceptions.py +65 -0
- opscloud/file_ops.py +217 -0
- opscloud/hooks/__init__.py +139 -0
- opscloud/hooks/env.py +44 -0
- opscloud/hooks/interrupt.py +121 -0
- opscloud/hooks/models/__init__.py +109 -0
- opscloud/hooks/models/adapters.py +19 -0
- opscloud/hooks/models/config.py +79 -0
- opscloud/hooks/models/domain.py +434 -0
- opscloud/hooks/models/transport.py +37 -0
- opscloud/hooks/tools.py +231 -0
- opscloud/input.py +48 -0
- opscloud/integrations/__init__.py +80 -0
- opscloud/integrations/base.py +153 -0
- opscloud/integrations/content_filter.py +119 -0
- opscloud/integrations/event_bus.py +480 -0
- opscloud/integrations/hooks.py +238 -0
- opscloud/integrations/identity.py +181 -0
- opscloud/integrations/notifications.py +134 -0
- opscloud/integrations/stream_bridge.py +397 -0
- opscloud/integrations/thread_store.py +81 -0
- opscloud/json_types.py +20 -0
- opscloud/mcp/__init__.py +57 -0
- opscloud/mcp/config.py +203 -0
- opscloud/mcp/discovery.py +229 -0
- opscloud/mcp/mcp_info.py +109 -0
- opscloud/mcp/middleware.py +102 -0
- opscloud/mcp/preload.py +505 -0
- opscloud/mcp/raw_config.py +149 -0
- opscloud/mcp/semantic_profiler.py +305 -0
- opscloud/mcp/session_manager.py +727 -0
- opscloud/mcp/trust.py +105 -0
- opscloud/memory/__init__.py +27 -0
- opscloud/memory/branch.py +61 -0
- opscloud/memory/guard.py +19 -0
- opscloud/memory/onboarding.py +120 -0
- opscloud/memory/registry.py +134 -0
- opscloud/memory/store.py +223 -0
- opscloud/middleware/__init__.py +106 -0
- opscloud/middleware/_repository_bounds.py +459 -0
- opscloud/middleware/ask_user.py +152 -0
- opscloud/middleware/auto_mode.py +60 -0
- opscloud/middleware/auto_mode_hitl.py +2434 -0
- opscloud/middleware/compaction.py +566 -0
- opscloud/middleware/configurable_model.py +233 -0
- opscloud/middleware/cost_tracking.py +1021 -0
- opscloud/middleware/glm_stall_recovery.py +109 -0
- opscloud/middleware/goal_criteria.py +1234 -0
- opscloud/middleware/goal_state_notice.py +712 -0
- opscloud/middleware/goal_tools.py +407 -0
- opscloud/middleware/headless_mcp_guard.py +88 -0
- opscloud/middleware/jev_model_router.py +1018 -0
- opscloud/middleware/local_context.py +460 -0
- opscloud/middleware/mcp_context.py +151 -0
- opscloud/middleware/mcp_middleware.py +146 -0
- opscloud/middleware/memory_guard.py +393 -0
- opscloud/middleware/model_retry.py +205 -0
- opscloud/middleware/registry.py +137 -0
- opscloud/middleware/reliable_rubric.py +709 -0
- opscloud/middleware/resume_state.py +91 -0
- opscloud/middleware/server_hooks.py +1189 -0
- opscloud/middleware/shell_allow_list.py +90 -0
- opscloud/middleware/skills.py +382 -0
- opscloud/middleware/subagent_artifacts.py +84 -0
- opscloud/middleware/subagent_telemetry.py +261 -0
- opscloud/middleware/subagents.py +243 -0
- opscloud/middleware/tool_filter.py +384 -0
- opscloud/middleware/unified_system_message.py +62 -0
- opscloud/model/__init__.py +37 -0
- opscloud/model/config.py +2992 -0
- opscloud/model/factory.py +477 -0
- opscloud/model/pool.py +459 -0
- opscloud/model/reasoning.py +282 -0
- opscloud/offload.py +173 -0
- opscloud/output.py +64 -0
- opscloud/plugins/__init__.py +189 -0
- opscloud/plugins/_json.py +22 -0
- opscloud/plugins/adapters/__init__.py +13 -0
- opscloud/plugins/adapters/agents.py +112 -0
- opscloud/plugins/adapters/commands.py +190 -0
- opscloud/plugins/adapters/mcp.py +363 -0
- opscloud/plugins/adapters/skills.py +132 -0
- opscloud/plugins/commands_cli.py +541 -0
- opscloud/plugins/discovery.py +512 -0
- opscloud/plugins/manifest.py +335 -0
- opscloud/plugins/marketplace.py +893 -0
- opscloud/plugins/models.py +334 -0
- opscloud/plugins/project_plugins.py +317 -0
- opscloud/plugins/protocol.py +31 -0
- opscloud/plugins/store.py +905 -0
- opscloud/plugins/substitution.py +122 -0
- opscloud/project_utils.py +145 -0
- opscloud/prompts/__init__.py +37 -0
- opscloud/prompts/system.py +483 -0
- opscloud/prompts/templates/system_prompt.md +166 -0
- opscloud/prompts/types.py +54 -0
- opscloud/rubrics/__init__.py +42 -0
- opscloud/rubrics/evaluator.py +404 -0
- opscloud/rubrics/evidence_extractor.py +229 -0
- opscloud/rubrics/generator.py +239 -0
- opscloud/rubrics/jev_compiler.py +175 -0
- opscloud/rubrics/jev_grader.py +297 -0
- opscloud/rubrics/middleware.py +8 -0
- opscloud/schema/__init__.py +107 -0
- opscloud/schema/interrupts.py +981 -0
- opscloud/security/__init__.py +91 -0
- opscloud/security/approval_mode.py +528 -0
- opscloud/security/approval_mode_source.py +230 -0
- opscloud/security/cli_ast_evaluator.py +552 -0
- opscloud/security/jev_classifier.py +651 -0
- opscloud/security/shell_safety.py +340 -0
- opscloud/security/unicode_security.py +439 -0
- opscloud/security/url_validation.py +160 -0
- opscloud/server/__init__.py +13 -0
- opscloud/server/_server_config.py +158 -0
- opscloud/server/server.py +242 -0
- opscloud/server/server_graph.py +93 -0
- opscloud/skills/__init__.py +91 -0
- opscloud/skills/commands.py +1099 -0
- opscloud/skills/invocation.py +200 -0
- opscloud/skills/load.py +388 -0
- opscloud/skills/loader.py +23 -0
- opscloud/skills/merge.py +65 -0
- opscloud/skills/registry.py +263 -0
- opscloud/skills/sources.py +174 -0
- opscloud/skills/trust.py +417 -0
- opscloud/state/__init__.py +142 -0
- opscloud/state/base.py +154 -0
- opscloud/state/cloud_context.py +262 -0
- opscloud/state/goal_channels.py +124 -0
- opscloud/state/goal_state_limits.py +175 -0
- opscloud/state/resume_state.py +58 -0
- opscloud/state/service_context.py +281 -0
- opscloud/state/session.py +957 -0
- opscloud/state/state_migration.py +86 -0
- opscloud/subagents/__init__.py +30 -0
- opscloud/subagents/loader.py +331 -0
- opscloud/subagents/subagents_parser.py +117 -0
- opscloud/subagents/types.py +25 -0
- opscloud/tools/__init__.py +36 -0
- opscloud/tools/catalog.py +37 -0
- opscloud/tools/display.py +24 -0
- opscloud/tools/fetch_url.py +176 -0
- opscloud/tools/goal_tools.py +423 -0
- opscloud/tools/registry.py +105 -0
- opscloud/tools/thread.py +32 -0
- opscloud/tools/web_search.py +215 -0
- opscloud/ui/__init__.py +119 -0
- opscloud/ui/app.py +5400 -0
- opscloud/ui/app.tcss +479 -0
- opscloud/ui/clipboard.py +147 -0
- opscloud/ui/command_registry.py +482 -0
- opscloud/ui/message_store.py +152 -0
- opscloud/ui/permission_store.py +360 -0
- opscloud/ui/preferences.py +113 -0
- opscloud/ui/prompt_manager.py +96 -0
- opscloud/ui/remote_client.py +505 -0
- opscloud/ui/textual_adapter.py +1269 -0
- opscloud/ui/theme.py +728 -0
- opscloud/ui/ui_help.py +415 -0
- opscloud/ui/widgets/__init__.py +383 -0
- opscloud/ui/widgets/_ask_user_types.py +81 -0
- opscloud/ui/widgets/_copy_spans.py +52 -0
- opscloud/ui/widgets/_inline_prompt.py +290 -0
- opscloud/ui/widgets/_js_eval_display.py +139 -0
- opscloud/ui/widgets/_links.py +175 -0
- opscloud/ui/widgets/_tool_stream.py +308 -0
- opscloud/ui/widgets/agent_selector.py +297 -0
- opscloud/ui/widgets/approval.py +741 -0
- opscloud/ui/widgets/ask_user.py +539 -0
- opscloud/ui/widgets/auth.py +695 -0
- opscloud/ui/widgets/auth_manager.py +214 -0
- opscloud/ui/widgets/auto_mode_notice.py +146 -0
- opscloud/ui/widgets/autocomplete.py +709 -0
- opscloud/ui/widgets/chat_input.py +805 -0
- opscloud/ui/widgets/cloud_selector.py +528 -0
- opscloud/ui/widgets/config_manager.py +652 -0
- opscloud/ui/widgets/debug_console.py +1269 -0
- opscloud/ui/widgets/devops_renderers.py +184 -0
- opscloud/ui/widgets/diff.py +237 -0
- opscloud/ui/widgets/effort_selector.py +166 -0
- opscloud/ui/widgets/goal_review.py +612 -0
- opscloud/ui/widgets/goal_status.py +57 -0
- opscloud/ui/widgets/infra_panel.py +141 -0
- opscloud/ui/widgets/install_confirm.py +114 -0
- opscloud/ui/widgets/loading.py +170 -0
- opscloud/ui/widgets/mcp_viewer.py +1204 -0
- opscloud/ui/widgets/messages.py +4461 -0
- opscloud/ui/widgets/model_selector.py +863 -0
- opscloud/ui/widgets/notification_center.py +108 -0
- opscloud/ui/widgets/notification_settings.py +119 -0
- opscloud/ui/widgets/operation_card.py +106 -0
- opscloud/ui/widgets/permissions_manager.py +667 -0
- opscloud/ui/widgets/plugin_manager.py +1212 -0
- opscloud/ui/widgets/pool_selector.py +376 -0
- opscloud/ui/widgets/preamble.py +53 -0
- opscloud/ui/widgets/skills_viewer.py +432 -0
- opscloud/ui/widgets/status.py +1037 -0
- opscloud/ui/widgets/subagent_panel.py +927 -0
- opscloud/ui/widgets/theme_selector.py +232 -0
- opscloud/ui/widgets/thread_selector.py +358 -0
- opscloud/ui/widgets/toast.py +31 -0
- opscloud/ui/widgets/tool_display.py +325 -0
- opscloud/ui/widgets/tool_renderers.py +234 -0
- opscloud/ui/widgets/tool_widgets.py +278 -0
- opscloud/ui/widgets/welcome.py +421 -0
- opscloud/ui/widgets/welcome_popup.py +102 -0
- opscloud/utils/__init__.py +24 -0
- opscloud/utils/cost_estimation.py +169 -0
- opscloud/utils/git.py +390 -0
- opscloud/utils/logger.py +571 -0
- opscloud/utils/session_stats.py +115 -0
- opscloud/utils/startup_error.py +37 -0
- talkops_opscloud-0.3.0b1.dist-info/METADATA +449 -0
- talkops_opscloud-0.3.0b1.dist-info/RECORD +309 -0
- talkops_opscloud-0.3.0b1.dist-info/WHEEL +4 -0
- talkops_opscloud-0.3.0b1.dist-info/entry_points.txt +2 -0
- 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
|
+
|