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,512 @@
1
+ """Plugin discovery, install, and enablement helpers for OpsCloud."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from functools import partial
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from opscloud.utils.logger import get_logger
11
+
12
+ from opscloud.config.plugins import (
13
+ get_default_marketplace_names,
14
+ get_primary_default_marketplace_name,
15
+ )
16
+ from opscloud.plugins.manifest import (
17
+ PluginManifestError,
18
+ build_inventory,
19
+ load_manifest,
20
+ )
21
+ from opscloud.plugins.marketplace import (
22
+ MarketplaceError,
23
+ load_marketplace,
24
+ load_marketplace_location,
25
+ materialize_marketplace_source,
26
+ materialize_plugin_source,
27
+ parse_marketplace_source,
28
+ redact_urls_in_text,
29
+ refresh_marketplace_catalog,
30
+ )
31
+ from opscloud.plugins.models import (
32
+ InstallScope,
33
+ MarketplacePluginEntry,
34
+ MarketplaceRecord,
35
+ PluginDiscoveryResult,
36
+ PluginInstance,
37
+ PluginMarketplace,
38
+ RepositoryMarketplaceSource,
39
+ split_plugin_id,
40
+ )
41
+ from opscloud.plugins.store import (
42
+ cache_and_register_plugin,
43
+ ensure_marketplace_cache_dir,
44
+ ensure_plugin_data_dir,
45
+ get_primary_install_entry,
46
+ load_all_enabled_plugin_ids,
47
+ load_enabled_plugin_ids,
48
+ load_local_enabled_plugin_ids,
49
+ load_project_enabled_plugin_ids,
50
+ load_installed_plugins,
51
+ load_marketplace_records,
52
+ plugin_data_dir,
53
+ plugin_mutation_lock,
54
+ remove_marketplace_record,
55
+ save_marketplace_record,
56
+ set_plugin_enabled_for_scope,
57
+ uninstall_plugin as uninstall_plugin_record,
58
+ )
59
+
60
+ logger = get_logger(__name__)
61
+
62
+
63
+ @plugin_mutation_lock()
64
+ def add_local_marketplace(path: str | Path) -> PluginMarketplace:
65
+ """Add a local marketplace to OpsCloud state."""
66
+ marketplace = load_marketplace(Path(path))
67
+ save_marketplace_record(
68
+ MarketplaceRecord(
69
+ name=marketplace.name,
70
+ source_type="directory",
71
+ source=str(marketplace.root),
72
+ install_location=str(marketplace.root),
73
+ )
74
+ )
75
+ return marketplace
76
+
77
+
78
+ @plugin_mutation_lock()
79
+ def add_marketplace_source(raw: str) -> PluginMarketplace:
80
+ """Add a marketplace from a pasted source string."""
81
+ source = parse_marketplace_source(raw)
82
+ marketplace, location = materialize_marketplace_source(source)
83
+ save_marketplace_record(
84
+ MarketplaceRecord(
85
+ name=marketplace.name,
86
+ source_type=source.source_type,
87
+ source=source.value,
88
+ install_location=str(location),
89
+ ref=source.ref if isinstance(source, RepositoryMarketplaceSource) else None,
90
+ )
91
+ )
92
+ return marketplace
93
+
94
+
95
+ @plugin_mutation_lock()
96
+ def remove_marketplace(name: str) -> bool:
97
+ """Remove a marketplace and every plugin installed from it."""
98
+ record = load_marketplace_records().get(name)
99
+ if record is None:
100
+ return False
101
+
102
+ installed = load_installed_plugins(strict=True)
103
+ enabled = load_enabled_plugin_ids(strict=True)
104
+ plugin_ids = set(installed) | set(enabled)
105
+ for plugin_id in plugin_ids:
106
+ try:
107
+ _plugin_name, marketplace_name = split_plugin_id(plugin_id)
108
+ except ValueError:
109
+ continue
110
+ if marketplace_name == name:
111
+ uninstall_plugin(plugin_id)
112
+
113
+ removed = remove_marketplace_record(name)
114
+ location = Path(record.install_location)
115
+ try:
116
+ resolved = location.resolve()
117
+ cache_root = ensure_marketplace_cache_dir().resolve()
118
+ except OSError:
119
+ return removed
120
+ if record.source_type in {"github", "git", "url"} and resolved.is_relative_to(
121
+ cache_root
122
+ ):
123
+ if resolved.is_dir():
124
+ shutil.rmtree(resolved, ignore_errors=True)
125
+ elif resolved.is_file():
126
+ resolved.unlink(missing_ok=True)
127
+ return removed
128
+
129
+
130
+ @plugin_mutation_lock()
131
+ def refresh_marketplace(
132
+ name: str, *, project_root: Path | None = None
133
+ ) -> PluginMarketplace:
134
+ """Refresh a marketplace from its remote source and update local cache and records."""
135
+ records = load_marketplace_records(project_root=project_root)
136
+ record = records.get(name)
137
+ if record is None:
138
+ msg = f"Marketplace {name!r} is not configured"
139
+ raise MarketplaceError(msg)
140
+
141
+ marketplace = refresh_marketplace_catalog(record)
142
+ save_marketplace_record(
143
+ MarketplaceRecord(
144
+ name=marketplace.name,
145
+ source_type=record.source_type,
146
+ source=record.source,
147
+ install_location=record.install_location,
148
+ ref=record.ref,
149
+ plugin_count=len(marketplace.plugins),
150
+ is_default=record.is_default,
151
+ )
152
+ )
153
+ return marketplace
154
+
155
+
156
+ @plugin_mutation_lock()
157
+ def refresh_all_marketplaces(
158
+ *, project_root: Path | None = None
159
+ ) -> dict[str, PluginMarketplace]:
160
+ """Refresh all configured marketplaces."""
161
+ records = load_marketplace_records(project_root=project_root)
162
+ results: dict[str, PluginMarketplace] = {}
163
+ for name in records:
164
+ try:
165
+ results[name] = refresh_marketplace(name, project_root=project_root)
166
+ except Exception as exc:
167
+ logger.warning("Failed to refresh marketplace %s: %s", name, exc)
168
+ return results
169
+
170
+
171
+ def _normalize_plugin_id(plugin_id: str) -> str:
172
+ """Normalize plugin id, resolving across available marketplaces or defaulting to the primary default marketplace."""
173
+ if "@" in plugin_id:
174
+ return plugin_id
175
+
176
+ records = load_marketplace_records()
177
+ for name, rec in records.items():
178
+ try:
179
+ mp = load_marketplace_location(Path(rec.install_location))
180
+ if any(p.name == plugin_id for p in mp.plugins):
181
+ return f"{plugin_id}@{name}"
182
+ except Exception:
183
+ continue
184
+
185
+ return f"{plugin_id}@{get_primary_default_marketplace_name()}"
186
+
187
+
188
+ def _resolve_installed_plugin_id(plugin_id: str) -> str:
189
+ """Resolve plugin id against currently installed plugins."""
190
+ if "@" in plugin_id:
191
+ return plugin_id
192
+
193
+ installed = load_installed_plugins()
194
+ prefix = f"{plugin_id}@"
195
+ matches = [pid for pid in installed if pid.startswith(prefix)]
196
+ if len(matches) == 1:
197
+ return matches[0]
198
+ if matches:
199
+ default_names = get_default_marketplace_names()
200
+ for m in matches:
201
+ mp_part = m.split("@", 1)[1]
202
+ if mp_part in default_names:
203
+ return m
204
+ return matches[0]
205
+
206
+ return f"{plugin_id}@{get_primary_default_marketplace_name()}"
207
+
208
+
209
+ def _require_installed_plugin(plugin_id: str) -> None:
210
+ canonical_id = _resolve_installed_plugin_id(plugin_id)
211
+ if canonical_id not in load_installed_plugins(strict=True):
212
+ msg = f"Plugin {plugin_id!r} is not installed"
213
+ raise MarketplaceError(msg)
214
+
215
+
216
+ @plugin_mutation_lock()
217
+ def set_installed_plugin_enabled(
218
+ plugin_id: str,
219
+ enabled: bool = True,
220
+ *,
221
+ scope: InstallScope = "user",
222
+ project_root: Path | None = None,
223
+ ) -> None:
224
+ """Set the enabled state of an installed plugin at the given scope."""
225
+ canonical_id = _resolve_installed_plugin_id(plugin_id)
226
+ if scope != "project":
227
+ try:
228
+ _require_installed_plugin(canonical_id)
229
+ except MarketplaceError:
230
+ pass
231
+ set_plugin_enabled_for_scope(
232
+ canonical_id, enabled, scope=scope, project_root=project_root
233
+ )
234
+ if enabled:
235
+ ensure_plugin_data_dir(canonical_id)
236
+
237
+
238
+ @plugin_mutation_lock()
239
+ def uninstall_plugin(
240
+ plugin_id: str,
241
+ *,
242
+ scope: InstallScope | None = None,
243
+ project_root: Path | None = None,
244
+ ) -> None:
245
+ """Uninstall a plugin (disable, clear records, delete orphaned cache)."""
246
+ canonical_id = _resolve_installed_plugin_id(plugin_id)
247
+ effective_scope = scope or ("project" if project_root else "user")
248
+ set_plugin_enabled_for_scope(
249
+ canonical_id, False, scope=effective_scope, project_root=project_root
250
+ )
251
+ uninstall_plugin_record(
252
+ canonical_id, scope=scope, project_root=project_root
253
+ )
254
+
255
+
256
+ def _resolve_marketplace_and_entry(
257
+ plugin_id: str,
258
+ ) -> tuple[PluginMarketplace, MarketplacePluginEntry]:
259
+ canonical_id = _normalize_plugin_id(plugin_id)
260
+ try:
261
+ plugin_name, marketplace_name = split_plugin_id(canonical_id)
262
+ except ValueError as exc:
263
+ raise MarketplaceError(str(exc)) from exc
264
+ records = load_marketplace_records()
265
+ record = records.get(marketplace_name)
266
+ if record is None:
267
+ msg = f"Marketplace {marketplace_name!r} is not configured"
268
+ raise MarketplaceError(msg)
269
+ marketplace = load_marketplace_location(Path(record.install_location))
270
+ entry = next(
271
+ (plugin for plugin in marketplace.plugins if plugin.name == plugin_name),
272
+ None,
273
+ )
274
+ if entry is None:
275
+ msg = f"Plugin {plugin_id!r} not found in marketplace {marketplace_name}"
276
+ raise MarketplaceError(msg)
277
+ return marketplace, entry
278
+
279
+
280
+ @plugin_mutation_lock()
281
+ def install_plugin(
282
+ plugin_id: str,
283
+ *,
284
+ scope: InstallScope = "user",
285
+ project_root: Path | None = None,
286
+ ) -> PluginInstance:
287
+ """Install a marketplace plugin into the versioned cache and enable it.
288
+
289
+ Args:
290
+ plugin_id: Plugin id in ``name@marketplace`` form or shorthand ``name``.
291
+ scope: Installation scope — ``"user"``, ``"project"``, or ``"local"``.
292
+ project_root: Required when ``scope`` is ``"project"`` or ``"local"``.
293
+ """
294
+ load_installed_plugins(strict=True)
295
+ canonical_id = _normalize_plugin_id(plugin_id)
296
+ marketplace, entry = _resolve_marketplace_and_entry(canonical_id)
297
+ source_root = materialize_plugin_source(marketplace, entry)
298
+ if source_root is None:
299
+ msg = (
300
+ f"Plugin {canonical_id} has unsupported source "
301
+ f"{redact_urls_in_text(repr(entry.source))}; "
302
+ "use a local path, GitHub repository, or Git repository source"
303
+ )
304
+ raise MarketplaceError(msg)
305
+
306
+ try:
307
+ manifest, _manifest_path, manifest_warnings = load_manifest(
308
+ source_root, fallback_name=entry.name
309
+ )
310
+ except PluginManifestError as exc:
311
+ msg = f"Cannot install {canonical_id}: {exc}"
312
+ raise MarketplaceError(msg) from exc
313
+
314
+ for warning in manifest_warnings:
315
+ logger.debug("Plugin install warning for %s: %s", canonical_id, warning)
316
+
317
+ version = manifest.version if manifest is not None else None
318
+ cache_path = cache_and_register_plugin(
319
+ canonical_id,
320
+ source_root,
321
+ version=version,
322
+ scope=scope,
323
+ project_root=project_root,
324
+ validate=partial(
325
+ _validate_plugin_copy,
326
+ plugin_id=canonical_id,
327
+ fallback_name=entry.name,
328
+ ),
329
+ )
330
+
331
+ set_plugin_enabled_for_scope(
332
+ canonical_id, True, scope=scope, project_root=project_root
333
+ )
334
+ ensure_plugin_data_dir(canonical_id)
335
+
336
+ instance, warnings = _plugin_from_install_path(
337
+ plugin_id=canonical_id,
338
+ root=cache_path,
339
+ marketplace_name=marketplace.name,
340
+ fallback_name=entry.name,
341
+ )
342
+ if instance is None:
343
+ detail = "; ".join(warnings)
344
+ uninstall_plugin_record(
345
+ canonical_id, scope=scope, project_root=project_root
346
+ )
347
+ msg = f"Installed {canonical_id} but failed to load from cache: {detail}"
348
+ raise MarketplaceError(msg)
349
+ return instance
350
+
351
+
352
+ def _validate_plugin_copy(
353
+ root: Path,
354
+ *,
355
+ plugin_id: str,
356
+ fallback_name: str,
357
+ ) -> None:
358
+ try:
359
+ manifest, _manifest_path, warnings = load_manifest(
360
+ root, fallback_name=fallback_name
361
+ )
362
+ except PluginManifestError as exc:
363
+ msg = f"Cannot install {plugin_id}: {exc}"
364
+ raise MarketplaceError(msg) from exc
365
+ build_inventory(root, manifest, warnings)
366
+
367
+
368
+ def _plugin_from_install_path(
369
+ *,
370
+ plugin_id: str,
371
+ root: Path,
372
+ marketplace_name: str,
373
+ fallback_name: str,
374
+ ) -> tuple[PluginInstance | None, tuple[str, ...]]:
375
+ warnings: list[str] = []
376
+ try:
377
+ manifest, _manifest_path, manifest_warnings = load_manifest(
378
+ root, fallback_name=fallback_name
379
+ )
380
+ except PluginManifestError as exc:
381
+ return None, (f"Skipping plugin {plugin_id}: {exc}",)
382
+ warnings.extend(manifest_warnings)
383
+ name = manifest.name if manifest and manifest.name else fallback_name
384
+ inventory = build_inventory(root, manifest, tuple(warnings))
385
+ try:
386
+ instance = PluginInstance(
387
+ plugin_id=plugin_id,
388
+ name=name,
389
+ marketplace=marketplace_name,
390
+ version=manifest.version if manifest is not None else None,
391
+ root=root,
392
+ data_dir=plugin_data_dir(plugin_id),
393
+ manifest=manifest,
394
+ inventory=inventory,
395
+ )
396
+ except ValueError as exc:
397
+ return None, (f"Skipping plugin {plugin_id}: {exc}",)
398
+ return instance, inventory.warnings
399
+
400
+
401
+ def discover_plugins(
402
+ project_root: Path | None = None,
403
+ store: Any = None,
404
+ ) -> PluginDiscoveryResult:
405
+ """Discover enabled marketplace plugins from their install cache paths.
406
+
407
+ Uses merged enablement across user + project + local scopes.
408
+ """
409
+ enabled = load_all_enabled_plugin_ids(project_root=project_root)
410
+ plugins: list[PluginInstance] = []
411
+ warnings: list[str] = []
412
+ seen_plugin_ids: set[str] = set()
413
+
414
+ # 1. Local filesystem discovery (e.g. project plugins directory)
415
+ if project_root:
416
+ proj_plugins = Path(project_root) / "plugins"
417
+ if proj_plugins.is_dir():
418
+ for p in sorted(proj_plugins.iterdir()):
419
+ if p.is_dir() and not p.name.startswith("."):
420
+ p_id = f"{p.name}@project"
421
+ inst, p_warns = _plugin_from_install_path(
422
+ plugin_id=p_id,
423
+ root=p,
424
+ marketplace_name="project",
425
+ fallback_name=p.name,
426
+ )
427
+ warnings.extend(p_warns)
428
+ if inst:
429
+ plugins.append(inst)
430
+ seen_plugin_ids.add(p_id)
431
+
432
+ enabled = load_all_enabled_plugin_ids(project_root=project_root)
433
+
434
+ for plugin_id in sorted(enabled):
435
+ if plugin_id in seen_plugin_ids:
436
+ continue
437
+ try:
438
+ plugin_name, marketplace_name = split_plugin_id(plugin_id)
439
+ except ValueError:
440
+ warnings.append(f"Ignoring invalid plugin id {plugin_id!r}")
441
+ continue
442
+ entry = get_primary_install_entry(plugin_id)
443
+ needs_install = entry is None
444
+ if entry is not None:
445
+ root = Path(entry.install_path)
446
+ try:
447
+ if not root.is_dir():
448
+ needs_install = True
449
+ except (OSError, RuntimeError):
450
+ needs_install = True
451
+
452
+ if needs_install:
453
+ try:
454
+ scope: InstallScope = "user"
455
+ if project_root:
456
+ if plugin_id in load_local_enabled_plugin_ids(project_root):
457
+ scope = "local"
458
+ elif plugin_id in load_project_enabled_plugin_ids(project_root):
459
+ scope = "project"
460
+ instance = install_plugin(
461
+ plugin_id, scope=scope, project_root=project_root
462
+ )
463
+ if instance is not None:
464
+ plugins.append(instance)
465
+ continue
466
+ except Exception as exc:
467
+ warnings.append(
468
+ f"Plugin {plugin_id} is enabled but auto-install failed: {exc}"
469
+ )
470
+ continue
471
+
472
+ assert entry is not None
473
+ root = Path(entry.install_path)
474
+ try:
475
+ plugin, plugin_warnings = _plugin_from_install_path(
476
+ plugin_id=plugin_id,
477
+ root=root,
478
+ marketplace_name=marketplace_name,
479
+ fallback_name=plugin_name,
480
+ )
481
+ except (OSError, RuntimeError) as exc:
482
+ warnings.append(f"Skipping plugin {plugin_id}: {exc}")
483
+ continue
484
+ warnings.extend(plugin_warnings)
485
+ if plugin is not None:
486
+ plugins.append(plugin)
487
+
488
+ return PluginDiscoveryResult(plugins=tuple(plugins), warnings=tuple(warnings))
489
+
490
+
491
+ def list_available_plugins(
492
+ project_root: Path | None = None,
493
+ ) -> tuple[tuple[str, str, bool], ...]:
494
+ """List plugins from configured marketplaces."""
495
+ records = load_marketplace_records(project_root=project_root)
496
+ enabled = load_all_enabled_plugin_ids(project_root=project_root)
497
+ rows: list[tuple[str, str, bool]] = []
498
+ for name, record in sorted(records.items()):
499
+ try:
500
+ marketplace = load_marketplace_location(Path(record.install_location))
501
+ except MarketplaceError as exc:
502
+ rows.append((f"<marketplace:{name}>", str(exc), False))
503
+ continue
504
+ for plugin in marketplace.plugins:
505
+ plugin_id = f"{plugin.name}@{marketplace.name}"
506
+ rows.append((plugin_id, plugin.description or "", plugin_id in enabled))
507
+ return tuple(rows)
508
+
509
+
510
+ def list_installed_plugin_ids() -> frozenset[str]:
511
+ """Return plugin ids that have install records."""
512
+ return frozenset(load_installed_plugins())