llmcode-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. llm_code/__init__.py +2 -0
  2. llm_code/analysis/__init__.py +6 -0
  3. llm_code/analysis/cache.py +33 -0
  4. llm_code/analysis/engine.py +256 -0
  5. llm_code/analysis/go_rules.py +114 -0
  6. llm_code/analysis/js_rules.py +84 -0
  7. llm_code/analysis/python_rules.py +311 -0
  8. llm_code/analysis/rules.py +140 -0
  9. llm_code/analysis/rust_rules.py +108 -0
  10. llm_code/analysis/universal_rules.py +111 -0
  11. llm_code/api/__init__.py +0 -0
  12. llm_code/api/client.py +90 -0
  13. llm_code/api/errors.py +73 -0
  14. llm_code/api/openai_compat.py +390 -0
  15. llm_code/api/provider.py +35 -0
  16. llm_code/api/sse.py +52 -0
  17. llm_code/api/types.py +140 -0
  18. llm_code/cli/__init__.py +0 -0
  19. llm_code/cli/commands.py +70 -0
  20. llm_code/cli/image.py +122 -0
  21. llm_code/cli/render.py +214 -0
  22. llm_code/cli/status_line.py +79 -0
  23. llm_code/cli/streaming.py +92 -0
  24. llm_code/cli/tui_main.py +220 -0
  25. llm_code/computer_use/__init__.py +11 -0
  26. llm_code/computer_use/app_detect.py +49 -0
  27. llm_code/computer_use/app_tier.py +57 -0
  28. llm_code/computer_use/coordinator.py +99 -0
  29. llm_code/computer_use/input_control.py +71 -0
  30. llm_code/computer_use/screenshot.py +93 -0
  31. llm_code/cron/__init__.py +13 -0
  32. llm_code/cron/parser.py +145 -0
  33. llm_code/cron/scheduler.py +135 -0
  34. llm_code/cron/storage.py +126 -0
  35. llm_code/enterprise/__init__.py +1 -0
  36. llm_code/enterprise/audit.py +59 -0
  37. llm_code/enterprise/auth.py +26 -0
  38. llm_code/enterprise/oidc.py +95 -0
  39. llm_code/enterprise/rbac.py +65 -0
  40. llm_code/harness/__init__.py +5 -0
  41. llm_code/harness/config.py +33 -0
  42. llm_code/harness/engine.py +129 -0
  43. llm_code/harness/guides.py +41 -0
  44. llm_code/harness/sensors.py +68 -0
  45. llm_code/harness/templates.py +84 -0
  46. llm_code/hida/__init__.py +1 -0
  47. llm_code/hida/classifier.py +187 -0
  48. llm_code/hida/engine.py +49 -0
  49. llm_code/hida/profiles.py +95 -0
  50. llm_code/hida/types.py +28 -0
  51. llm_code/ide/__init__.py +1 -0
  52. llm_code/ide/bridge.py +80 -0
  53. llm_code/ide/detector.py +76 -0
  54. llm_code/ide/server.py +169 -0
  55. llm_code/logging.py +29 -0
  56. llm_code/lsp/__init__.py +0 -0
  57. llm_code/lsp/client.py +298 -0
  58. llm_code/lsp/detector.py +42 -0
  59. llm_code/lsp/manager.py +56 -0
  60. llm_code/lsp/tools.py +288 -0
  61. llm_code/marketplace/__init__.py +0 -0
  62. llm_code/marketplace/builtin_registry.py +102 -0
  63. llm_code/marketplace/installer.py +162 -0
  64. llm_code/marketplace/plugin.py +78 -0
  65. llm_code/marketplace/registry.py +360 -0
  66. llm_code/mcp/__init__.py +0 -0
  67. llm_code/mcp/bridge.py +87 -0
  68. llm_code/mcp/client.py +117 -0
  69. llm_code/mcp/health.py +120 -0
  70. llm_code/mcp/manager.py +214 -0
  71. llm_code/mcp/oauth.py +219 -0
  72. llm_code/mcp/transport.py +254 -0
  73. llm_code/mcp/types.py +53 -0
  74. llm_code/remote/__init__.py +0 -0
  75. llm_code/remote/client.py +136 -0
  76. llm_code/remote/protocol.py +22 -0
  77. llm_code/remote/server.py +275 -0
  78. llm_code/remote/ssh_proxy.py +56 -0
  79. llm_code/runtime/__init__.py +0 -0
  80. llm_code/runtime/auto_commit.py +56 -0
  81. llm_code/runtime/auto_diagnose.py +62 -0
  82. llm_code/runtime/checkpoint.py +70 -0
  83. llm_code/runtime/checkpoint_recovery.py +142 -0
  84. llm_code/runtime/compaction.py +35 -0
  85. llm_code/runtime/compressor.py +415 -0
  86. llm_code/runtime/config.py +533 -0
  87. llm_code/runtime/context.py +49 -0
  88. llm_code/runtime/conversation.py +921 -0
  89. llm_code/runtime/cost_tracker.py +126 -0
  90. llm_code/runtime/dream.py +127 -0
  91. llm_code/runtime/file_protection.py +150 -0
  92. llm_code/runtime/hardware.py +85 -0
  93. llm_code/runtime/hooks.py +223 -0
  94. llm_code/runtime/indexer.py +230 -0
  95. llm_code/runtime/knowledge_compiler.py +232 -0
  96. llm_code/runtime/memory.py +132 -0
  97. llm_code/runtime/memory_layers.py +467 -0
  98. llm_code/runtime/memory_lint.py +252 -0
  99. llm_code/runtime/model_aliases.py +37 -0
  100. llm_code/runtime/ollama.py +93 -0
  101. llm_code/runtime/overlay.py +124 -0
  102. llm_code/runtime/permissions.py +200 -0
  103. llm_code/runtime/plan.py +45 -0
  104. llm_code/runtime/prompt.py +238 -0
  105. llm_code/runtime/repo_map.py +174 -0
  106. llm_code/runtime/sandbox.py +116 -0
  107. llm_code/runtime/session.py +268 -0
  108. llm_code/runtime/skill_resolver.py +61 -0
  109. llm_code/runtime/skills.py +133 -0
  110. llm_code/runtime/speculative.py +75 -0
  111. llm_code/runtime/streaming_executor.py +216 -0
  112. llm_code/runtime/telemetry.py +196 -0
  113. llm_code/runtime/token_budget.py +26 -0
  114. llm_code/runtime/vcr.py +142 -0
  115. llm_code/runtime/vision.py +102 -0
  116. llm_code/swarm/__init__.py +1 -0
  117. llm_code/swarm/backend_subprocess.py +108 -0
  118. llm_code/swarm/backend_tmux.py +103 -0
  119. llm_code/swarm/backend_worktree.py +306 -0
  120. llm_code/swarm/checkpoint.py +74 -0
  121. llm_code/swarm/coordinator.py +236 -0
  122. llm_code/swarm/mailbox.py +88 -0
  123. llm_code/swarm/manager.py +202 -0
  124. llm_code/swarm/memory_sync.py +80 -0
  125. llm_code/swarm/recovery.py +21 -0
  126. llm_code/swarm/team.py +67 -0
  127. llm_code/swarm/types.py +31 -0
  128. llm_code/task/__init__.py +16 -0
  129. llm_code/task/diagnostics.py +93 -0
  130. llm_code/task/manager.py +162 -0
  131. llm_code/task/types.py +112 -0
  132. llm_code/task/verifier.py +104 -0
  133. llm_code/tools/__init__.py +0 -0
  134. llm_code/tools/agent.py +145 -0
  135. llm_code/tools/agent_roles.py +82 -0
  136. llm_code/tools/base.py +94 -0
  137. llm_code/tools/bash.py +565 -0
  138. llm_code/tools/computer_use_tools.py +278 -0
  139. llm_code/tools/coordinator_tool.py +75 -0
  140. llm_code/tools/cron_create.py +90 -0
  141. llm_code/tools/cron_delete.py +49 -0
  142. llm_code/tools/cron_list.py +51 -0
  143. llm_code/tools/deferred.py +92 -0
  144. llm_code/tools/dump.py +116 -0
  145. llm_code/tools/edit_file.py +282 -0
  146. llm_code/tools/git_tools.py +531 -0
  147. llm_code/tools/glob_search.py +112 -0
  148. llm_code/tools/grep_search.py +144 -0
  149. llm_code/tools/ide_diagnostics.py +59 -0
  150. llm_code/tools/ide_open.py +58 -0
  151. llm_code/tools/ide_selection.py +52 -0
  152. llm_code/tools/memory_tools.py +138 -0
  153. llm_code/tools/multi_edit.py +143 -0
  154. llm_code/tools/notebook_edit.py +107 -0
  155. llm_code/tools/notebook_read.py +81 -0
  156. llm_code/tools/parsing.py +63 -0
  157. llm_code/tools/read_file.py +154 -0
  158. llm_code/tools/registry.py +58 -0
  159. llm_code/tools/search_backends/__init__.py +56 -0
  160. llm_code/tools/search_backends/brave.py +56 -0
  161. llm_code/tools/search_backends/duckduckgo.py +129 -0
  162. llm_code/tools/search_backends/searxng.py +71 -0
  163. llm_code/tools/search_backends/tavily.py +73 -0
  164. llm_code/tools/swarm_create.py +109 -0
  165. llm_code/tools/swarm_delete.py +95 -0
  166. llm_code/tools/swarm_list.py +44 -0
  167. llm_code/tools/swarm_message.py +109 -0
  168. llm_code/tools/task_close.py +79 -0
  169. llm_code/tools/task_plan.py +79 -0
  170. llm_code/tools/task_verify.py +90 -0
  171. llm_code/tools/tool_search.py +65 -0
  172. llm_code/tools/web_common.py +258 -0
  173. llm_code/tools/web_fetch.py +223 -0
  174. llm_code/tools/web_search.py +280 -0
  175. llm_code/tools/write_file.py +118 -0
  176. llm_code/tui/__init__.py +1 -0
  177. llm_code/tui/app.py +2432 -0
  178. llm_code/tui/chat_view.py +82 -0
  179. llm_code/tui/chat_widgets.py +309 -0
  180. llm_code/tui/header_bar.py +46 -0
  181. llm_code/tui/input_bar.py +349 -0
  182. llm_code/tui/keybindings.py +142 -0
  183. llm_code/tui/marketplace.py +210 -0
  184. llm_code/tui/status_bar.py +72 -0
  185. llm_code/tui/theme.py +96 -0
  186. llm_code/utils/__init__.py +0 -0
  187. llm_code/utils/diff.py +111 -0
  188. llm_code/utils/errors.py +70 -0
  189. llm_code/utils/hyperlink.py +73 -0
  190. llm_code/utils/notebook.py +179 -0
  191. llm_code/utils/search.py +69 -0
  192. llm_code/utils/text_normalize.py +28 -0
  193. llm_code/utils/version_check.py +62 -0
  194. llm_code/vim/__init__.py +4 -0
  195. llm_code/vim/engine.py +51 -0
  196. llm_code/vim/motions.py +172 -0
  197. llm_code/vim/operators.py +183 -0
  198. llm_code/vim/text_objects.py +139 -0
  199. llm_code/vim/transitions.py +279 -0
  200. llm_code/vim/types.py +68 -0
  201. llm_code/voice/__init__.py +1 -0
  202. llm_code/voice/languages.py +43 -0
  203. llm_code/voice/recorder.py +136 -0
  204. llm_code/voice/stt.py +36 -0
  205. llm_code/voice/stt_anthropic.py +66 -0
  206. llm_code/voice/stt_google.py +32 -0
  207. llm_code/voice/stt_whisper.py +52 -0
  208. llmcode_cli-1.0.0.dist-info/METADATA +524 -0
  209. llmcode_cli-1.0.0.dist-info/RECORD +212 -0
  210. llmcode_cli-1.0.0.dist-info/WHEEL +4 -0
  211. llmcode_cli-1.0.0.dist-info/entry_points.txt +2 -0
  212. llmcode_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,102 @@
1
+ """Built-in registry of known Claude Code plugins (official + community)."""
2
+ from __future__ import annotations
3
+
4
+ OFFICIAL_PLUGINS = [
5
+ {"name": "superpowers", "desc": "Core skills: TDD, debugging, collaboration, plans", "skills": 28, "repo": "obra/superpowers"},
6
+ {"name": "data-engineering", "desc": "Data warehouse exploration, pipeline audit, SQL", "skills": 46, "repo": ""},
7
+ {"name": "searchfit-seo", "desc": "AI-powered SEO toolkit — audit, content strategy", "skills": 22, "repo": ""},
8
+ {"name": "chrome-devtools-mcp", "desc": "Browser automation, debugging, performance analysis", "skills": 8, "repo": "anthropics/claude-code-chrome-devtools-mcp"},
9
+ {"name": "figma", "desc": "Figma MCP server + design-to-code skills", "skills": 7, "repo": ""},
10
+ {"name": "frontend-design", "desc": "UI/UX implementation skill for web frontends", "skills": 3, "repo": ""},
11
+ {"name": "skill-creator", "desc": "Create, improve, and measure skill performance", "skills": 3, "repo": ""},
12
+ {"name": "remember", "desc": "Continuous memory — extract and persist context", "skills": 2, "repo": ""},
13
+ {"name": "claude-code-setup", "desc": "Analyze codebases, recommend Claude Code automations", "skills": 1, "repo": ""},
14
+ {"name": "claude-md-management", "desc": "Audit and improve CLAUDE.md files", "skills": 1, "repo": ""},
15
+ {"name": "agent-sdk-dev", "desc": "Claude Agent SDK development tools", "skills": 0, "repo": ""},
16
+ {"name": "code-review", "desc": "Automated code review with specialized agents", "skills": 0, "repo": ""},
17
+ {"name": "code-simplifier", "desc": "Simplify and refine code for clarity", "skills": 0, "repo": ""},
18
+ {"name": "commit-commands", "desc": "Streamline git workflow — commit, push, PR", "skills": 0, "repo": ""},
19
+ {"name": "context7", "desc": "Context7 MCP for up-to-date documentation lookup", "skills": 0, "repo": ""},
20
+ {"name": "explanatory-output-style", "desc": "Educational insights about implementation choices", "skills": 0, "repo": ""},
21
+ {"name": "feature-dev", "desc": "Feature development workflow with specialized agents", "skills": 0, "repo": ""},
22
+ {"name": "playwright", "desc": "Browser automation and E2E testing (Microsoft)", "skills": 0, "repo": ""},
23
+ {"name": "pr-review-toolkit", "desc": "Comprehensive PR review with specialized agents", "skills": 0, "repo": ""},
24
+ {"name": "ralph-loop", "desc": "Continuous self-referential AI loops", "skills": 0, "repo": ""},
25
+ {"name": "security-guidance", "desc": "Security reminder hooks for safe coding", "skills": 0, "repo": ""},
26
+ {"name": "semgrep", "desc": "Semgrep MCP for static analysis and SAST", "skills": 0, "repo": ""},
27
+ {"name": "supabase", "desc": "Supabase MCP for database and auth operations", "skills": 0, "repo": ""},
28
+ {"name": "clangd-lsp", "desc": "C/C++ language server integration", "skills": 0, "repo": ""},
29
+ {"name": "gopls-lsp", "desc": "Go language server integration", "skills": 0, "repo": ""},
30
+ {"name": "pyright-lsp", "desc": "Python language server integration", "skills": 0, "repo": ""},
31
+ {"name": "rust-analyzer-lsp", "desc": "Rust language server integration", "skills": 0, "repo": ""},
32
+ {"name": "typescript-lsp", "desc": "TypeScript language server integration", "skills": 0, "repo": ""},
33
+ ]
34
+
35
+ COMMUNITY_PLUGINS = [
36
+ {"name": "ai-integration-architect", "desc": "Design and scaffold AI integration into enterprise systems", "skills": 1, "repo": ""},
37
+ {"name": "claude-md-optimizer", "desc": "Optimize oversized CLAUDE.md using progressive disclosure", "skills": 1, "repo": ""},
38
+ ]
39
+
40
+
41
+ def get_all_known_plugins() -> list[dict]:
42
+ """Return all known plugins (official + community) sorted by skill count."""
43
+ all_plugins = []
44
+ for p in OFFICIAL_PLUGINS:
45
+ all_plugins.append({**p, "source": "official"})
46
+ for p in COMMUNITY_PLUGINS:
47
+ all_plugins.append({**p, "source": "community"})
48
+ all_plugins.sort(key=lambda x: (-x["skills"], x["name"]))
49
+ return all_plugins
50
+
51
+
52
+ async def search_clawhub_skills(query: str = "", limit: int = 30) -> list[tuple[str, str]]:
53
+ """Search ClawHub.ai skill marketplace (44,000+ skills)."""
54
+ import httpx
55
+
56
+ # If no query, fetch popular categories to get a good mix
57
+ queries = [query] if query else ["code", "test", "review", "debug", "deploy", "security", "api", "frontend"]
58
+
59
+ results: list[tuple[str, str]] = []
60
+ seen_slugs: set[str] = set()
61
+
62
+ async with httpx.AsyncClient(timeout=8.0) as client:
63
+ for q in queries:
64
+ if len(results) >= limit:
65
+ break
66
+ try:
67
+ resp = await client.get(
68
+ "https://clawhub.ai/api/search",
69
+ params={"q": q, "limit": min(10, limit - len(results))},
70
+ )
71
+ resp.raise_for_status()
72
+ data = resp.json()
73
+ for item in data.get("results", []):
74
+ slug = item.get("slug", "")
75
+ if slug and slug not in seen_slugs:
76
+ seen_slugs.add(slug)
77
+ name = item.get("displayName") or slug
78
+ summary = item.get("summary", "")[:60]
79
+ results.append((slug, f"{name} — {summary}"))
80
+ except Exception:
81
+ continue
82
+
83
+ return results[:limit]
84
+
85
+
86
+ async def search_clawhub_plugins(query: str, limit: int = 30) -> list[tuple[str, str]]:
87
+ """Search ClawHub.ai plugin marketplace."""
88
+ import httpx
89
+ async with httpx.AsyncClient(timeout=10.0) as client:
90
+ resp = await client.get(
91
+ "https://clawhub.ai/api/search",
92
+ params={"q": f"plugin {query}", "limit": limit},
93
+ )
94
+ resp.raise_for_status()
95
+ data = resp.json()
96
+ results = []
97
+ for item in data.get("results", []):
98
+ name = item.get("displayName") or item.get("slug", "")
99
+ slug = item.get("slug", "")
100
+ summary = item.get("summary", "")[:70]
101
+ results.append((slug, f"{name} — {summary}"))
102
+ return results
@@ -0,0 +1,162 @@
1
+ """Plugin installer — local copy, npm, and GitHub install strategies."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import json
6
+ import shutil
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from llm_code.marketplace.plugin import InstalledPlugin, PluginManifest
11
+
12
+
13
+ # State file format:
14
+ # {
15
+ # "plugin-name": {"enabled": true, "installed_from": "local"}
16
+ # }
17
+
18
+
19
+ class PluginInstaller:
20
+ """Manages installation, removal, and enumeration of plugins."""
21
+
22
+ def __init__(self, install_dir: Path) -> None:
23
+ self._install_dir = install_dir
24
+ self._install_dir.mkdir(parents=True, exist_ok=True)
25
+ self._state_path = self._install_dir / "state.json"
26
+
27
+ # ------------------------------------------------------------------
28
+ # State helpers
29
+ # ------------------------------------------------------------------
30
+
31
+ def _read_state(self) -> dict[str, dict[str, Any]]:
32
+ if not self._state_path.exists():
33
+ return {}
34
+ return json.loads(self._state_path.read_text(encoding="utf-8"))
35
+
36
+ def _write_state(self, state: dict[str, dict[str, Any]]) -> None:
37
+ self._state_path.write_text(json.dumps(state, indent=2), encoding="utf-8")
38
+
39
+ # ------------------------------------------------------------------
40
+ # Install strategies
41
+ # ------------------------------------------------------------------
42
+
43
+ def install_from_local(self, source: Path) -> Path:
44
+ """Copy a local plugin directory into the install directory.
45
+
46
+ Returns the destination path.
47
+ """
48
+ manifest = PluginManifest.from_path(source)
49
+ dest = self._install_dir / manifest.name
50
+ if dest.exists():
51
+ shutil.rmtree(dest)
52
+ shutil.copytree(source, dest)
53
+
54
+ state = self._read_state()
55
+ state[manifest.name] = {"enabled": True, "installed_from": "local"}
56
+ self._write_state(state)
57
+
58
+ return dest
59
+
60
+ async def install_from_npm(self, package: str, version: str = "latest") -> Path:
61
+ """Install a plugin package via npm --prefix (uses execvp, not shell).
62
+
63
+ Returns the destination path.
64
+ """
65
+ dest = self._install_dir / package.replace("/", "__")
66
+ dest.mkdir(parents=True, exist_ok=True)
67
+ pkg_spec = f"{package}@{version}" if version != "latest" else package
68
+
69
+ proc = await asyncio.create_subprocess_exec(
70
+ "npm", "install", "--prefix", str(dest), pkg_spec,
71
+ stdout=asyncio.subprocess.PIPE,
72
+ stderr=asyncio.subprocess.PIPE,
73
+ )
74
+ await proc.communicate()
75
+
76
+ state = self._read_state()
77
+ state[package] = {"enabled": True, "installed_from": "npm"}
78
+ self._write_state(state)
79
+
80
+ return dest
81
+
82
+ async def install_from_github(self, repo: str, ref: str = "main") -> Path:
83
+ """Clone a GitHub repository as a plugin using git clone (uses execvp, not shell).
84
+
85
+ Returns the destination path.
86
+ """
87
+ name = repo.replace("/", "__")
88
+ dest = self._install_dir / name
89
+ if dest.exists():
90
+ shutil.rmtree(dest)
91
+
92
+ url = f"https://github.com/{repo}.git"
93
+ proc = await asyncio.create_subprocess_exec(
94
+ "git", "clone", "--depth", "1", "--branch", ref, url, str(dest),
95
+ stdout=asyncio.subprocess.PIPE,
96
+ stderr=asyncio.subprocess.PIPE,
97
+ )
98
+ await proc.communicate()
99
+
100
+ state = self._read_state()
101
+ state[name] = {"enabled": True, "installed_from": "github"}
102
+ self._write_state(state)
103
+
104
+ return dest
105
+
106
+ # ------------------------------------------------------------------
107
+ # Management
108
+ # ------------------------------------------------------------------
109
+
110
+ def uninstall(self, name: str) -> None:
111
+ """Remove an installed plugin by name."""
112
+ dest = self._install_dir / name
113
+ if dest.exists():
114
+ shutil.rmtree(dest)
115
+
116
+ state = self._read_state()
117
+ state.pop(name, None)
118
+ self._write_state(state)
119
+
120
+ def list_installed(self) -> list[InstalledPlugin]:
121
+ """Return all installed plugins, merging directory scan with state.json."""
122
+ state = self._read_state()
123
+ plugins: list[InstalledPlugin] = []
124
+
125
+ for entry in sorted(self._install_dir.iterdir()):
126
+ if not entry.is_dir():
127
+ continue
128
+ if entry.name == "state.json":
129
+ continue
130
+ try:
131
+ manifest = PluginManifest.from_path(entry)
132
+ except FileNotFoundError:
133
+ continue
134
+
135
+ entry_state = state.get(manifest.name, {})
136
+ enabled = bool(entry_state.get("enabled", True))
137
+ installed_from = str(entry_state.get("installed_from", "local"))
138
+
139
+ plugins.append(
140
+ InstalledPlugin(
141
+ manifest=manifest,
142
+ path=entry,
143
+ enabled=enabled,
144
+ installed_from=installed_from,
145
+ )
146
+ )
147
+
148
+ return plugins
149
+
150
+ def enable(self, name: str) -> None:
151
+ """Mark a plugin as enabled in state.json."""
152
+ state = self._read_state()
153
+ entry = state.setdefault(name, {})
154
+ state[name] = {**entry, "enabled": True}
155
+ self._write_state(state)
156
+
157
+ def disable(self, name: str) -> None:
158
+ """Mark a plugin as disabled in state.json."""
159
+ state = self._read_state()
160
+ entry = state.setdefault(name, {})
161
+ state[name] = {**entry, "enabled": False}
162
+ self._write_state(state)
@@ -0,0 +1,78 @@
1
+ """Plugin manifest and installed-plugin models for the marketplace subsystem."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class PluginManifest:
12
+ """Immutable representation of a parsed .claude-plugin/plugin.json manifest."""
13
+
14
+ name: str
15
+ version: str
16
+ description: str
17
+ author: dict[str, Any] | None = None
18
+ homepage: str | None = None
19
+ repository: str | None = None
20
+ keywords: tuple[str, ...] = ()
21
+ commands: tuple[str, ...] = ()
22
+ agents: tuple[str, ...] | None = None
23
+ skills: str | tuple[str, ...] | None = None
24
+ hooks: dict[str, Any] | None = None
25
+ mcp_servers: tuple[dict[str, Any], ...] | None = None
26
+ lsp_servers: tuple[dict[str, Any], ...] | None = None
27
+
28
+ @classmethod
29
+ def from_path(cls, plugin_dir: Path) -> "PluginManifest":
30
+ """Read .claude-plugin/plugin.json from plugin_dir and return a PluginManifest.
31
+
32
+ Raises FileNotFoundError if plugin_dir does not exist or plugin.json is missing.
33
+ """
34
+ if not plugin_dir.exists():
35
+ raise FileNotFoundError(f"Plugin directory not found: {plugin_dir}")
36
+
37
+ manifest_path = plugin_dir / ".claude-plugin" / "plugin.json"
38
+ if not manifest_path.exists():
39
+ raise FileNotFoundError(f"Plugin manifest not found: {manifest_path}")
40
+
41
+ data: dict[str, Any] = json.loads(manifest_path.read_text(encoding="utf-8"))
42
+
43
+ # Map camelCase JSON keys to snake_case fields
44
+ mcp_raw = data.get("mcpServers")
45
+ lsp_raw = data.get("lspServers")
46
+
47
+ keywords_raw = data.get("keywords", [])
48
+ commands_raw = data.get("commands", [])
49
+ agents_raw = data.get("agents")
50
+ skills_raw = data.get("skills")
51
+ hooks_raw = data.get("hooks")
52
+
53
+ return cls(
54
+ name=data["name"],
55
+ version=data["version"],
56
+ description=data["description"],
57
+ author=data.get("author"),
58
+ homepage=data.get("homepage"),
59
+ repository=data.get("repository"),
60
+ keywords=tuple(keywords_raw) if keywords_raw else (),
61
+ commands=tuple(commands_raw) if commands_raw else (),
62
+ agents=tuple(agents_raw) if agents_raw is not None else None,
63
+ skills=skills_raw if isinstance(skills_raw, str) else (tuple(skills_raw) if skills_raw is not None else None),
64
+ hooks=hooks_raw,
65
+ mcp_servers=tuple(mcp_raw) if mcp_raw is not None else None,
66
+ lsp_servers=tuple(lsp_raw) if lsp_raw is not None else None,
67
+ )
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class InstalledPlugin:
72
+ """Immutable snapshot of an installed plugin with runtime state."""
73
+
74
+ manifest: PluginManifest
75
+ path: Path
76
+ enabled: bool
77
+ scope: str = "user"
78
+ installed_from: str = "local"