axion-code 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.
- axion/__init__.py +3 -0
- axion/api/__init__.py +0 -0
- axion/api/anthropic.py +460 -0
- axion/api/client.py +259 -0
- axion/api/error.py +161 -0
- axion/api/ollama.py +597 -0
- axion/api/openai_compat.py +805 -0
- axion/api/openai_responses.py +627 -0
- axion/api/prompt_cache.py +31 -0
- axion/api/sse.py +98 -0
- axion/api/types.py +451 -0
- axion/cli/__init__.py +0 -0
- axion/cli/init_cmd.py +50 -0
- axion/cli/input.py +290 -0
- axion/cli/main.py +2953 -0
- axion/cli/render.py +489 -0
- axion/cli/tui.py +766 -0
- axion/commands/__init__.py +0 -0
- axion/commands/handlers/__init__.py +0 -0
- axion/commands/handlers/agents.py +51 -0
- axion/commands/handlers/builtin_commands.py +367 -0
- axion/commands/handlers/mcp.py +59 -0
- axion/commands/handlers/models.py +75 -0
- axion/commands/handlers/plugins.py +55 -0
- axion/commands/handlers/skills.py +61 -0
- axion/commands/parsing.py +317 -0
- axion/commands/registry.py +166 -0
- axion/compat_harness/__init__.py +0 -0
- axion/compat_harness/extractor.py +145 -0
- axion/plugins/__init__.py +0 -0
- axion/plugins/hooks.py +22 -0
- axion/plugins/manager.py +391 -0
- axion/plugins/manifest.py +270 -0
- axion/runtime/__init__.py +0 -0
- axion/runtime/bash.py +388 -0
- axion/runtime/bootstrap.py +39 -0
- axion/runtime/claude_subscription.py +300 -0
- axion/runtime/compact.py +233 -0
- axion/runtime/config.py +397 -0
- axion/runtime/conversation.py +1073 -0
- axion/runtime/file_ops.py +613 -0
- axion/runtime/git.py +213 -0
- axion/runtime/hooks.py +235 -0
- axion/runtime/image.py +212 -0
- axion/runtime/lanes.py +282 -0
- axion/runtime/lsp.py +425 -0
- axion/runtime/mcp/__init__.py +0 -0
- axion/runtime/mcp/client.py +76 -0
- axion/runtime/mcp/lifecycle.py +96 -0
- axion/runtime/mcp/stdio.py +318 -0
- axion/runtime/mcp/tool_bridge.py +79 -0
- axion/runtime/memory.py +196 -0
- axion/runtime/oauth.py +329 -0
- axion/runtime/openai_subscription.py +346 -0
- axion/runtime/permissions.py +247 -0
- axion/runtime/plan_mode.py +96 -0
- axion/runtime/policy_engine.py +259 -0
- axion/runtime/prompt.py +586 -0
- axion/runtime/recovery.py +261 -0
- axion/runtime/remote.py +28 -0
- axion/runtime/sandbox.py +68 -0
- axion/runtime/scheduler.py +231 -0
- axion/runtime/session.py +365 -0
- axion/runtime/sharing.py +159 -0
- axion/runtime/skills.py +124 -0
- axion/runtime/tasks.py +258 -0
- axion/runtime/usage.py +241 -0
- axion/runtime/workers.py +186 -0
- axion/telemetry/__init__.py +0 -0
- axion/telemetry/events.py +67 -0
- axion/telemetry/profile.py +49 -0
- axion/telemetry/sink.py +60 -0
- axion/telemetry/tracer.py +95 -0
- axion/tools/__init__.py +0 -0
- axion/tools/lane_completion.py +33 -0
- axion/tools/registry.py +853 -0
- axion/tools/tool_search.py +226 -0
- axion_code-1.0.0.dist-info/METADATA +709 -0
- axion_code-1.0.0.dist-info/RECORD +82 -0
- axion_code-1.0.0.dist-info/WHEEL +4 -0
- axion_code-1.0.0.dist-info/entry_points.txt +2 -0
- axion_code-1.0.0.dist-info/licenses/LICENSE +21 -0
axion/plugins/manager.py
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
"""Plugin manager and registry with lifecycle execution.
|
|
2
|
+
|
|
3
|
+
Maps to: rust/crates/plugins/src/lib.rs (PluginManager, PluginRegistry)
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from axion.plugins.manifest import (
|
|
16
|
+
ManifestValidationError,
|
|
17
|
+
PluginKind,
|
|
18
|
+
PluginManifest,
|
|
19
|
+
PluginMetadata,
|
|
20
|
+
load_manifest_from_directory,
|
|
21
|
+
validate_manifest,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class PluginSummary:
|
|
29
|
+
"""Summary of an installed plugin."""
|
|
30
|
+
|
|
31
|
+
id: str
|
|
32
|
+
name: str
|
|
33
|
+
version: str
|
|
34
|
+
description: str
|
|
35
|
+
enabled: bool
|
|
36
|
+
kind: PluginKind
|
|
37
|
+
tool_count: int = 0
|
|
38
|
+
command_count: int = 0
|
|
39
|
+
hook_count: int = 0
|
|
40
|
+
validation_errors: list[str] = field(default_factory=list)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class PluginLoadFailure:
|
|
45
|
+
"""Error details during plugin loading."""
|
|
46
|
+
|
|
47
|
+
plugin_id: str
|
|
48
|
+
error: str
|
|
49
|
+
path: str = ""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class PluginRegistryReport:
|
|
54
|
+
"""Report of loaded/failed plugins."""
|
|
55
|
+
|
|
56
|
+
loaded: list[PluginSummary]
|
|
57
|
+
failed: list[PluginLoadFailure]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class RegisteredPlugin:
|
|
62
|
+
"""A plugin with its metadata and manifest."""
|
|
63
|
+
|
|
64
|
+
metadata: PluginMetadata
|
|
65
|
+
manifest: PluginManifest
|
|
66
|
+
enabled: bool = True
|
|
67
|
+
validation_errors: list[ManifestValidationError] = field(default_factory=list)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class InstalledPluginRecord:
|
|
72
|
+
"""Persistence record of an installed plugin."""
|
|
73
|
+
|
|
74
|
+
plugin_id: str
|
|
75
|
+
source: str
|
|
76
|
+
installed_at_ms: int = 0
|
|
77
|
+
enabled: bool = True
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class PluginRegistry:
|
|
81
|
+
"""Registry of all discovered plugins.
|
|
82
|
+
|
|
83
|
+
Maps to: rust/crates/plugins/src/lib.rs::PluginRegistry
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(self) -> None:
|
|
87
|
+
self._plugins: dict[str, RegisteredPlugin] = {}
|
|
88
|
+
|
|
89
|
+
def register(self, plugin: RegisteredPlugin) -> None:
|
|
90
|
+
self._plugins[plugin.metadata.id] = plugin
|
|
91
|
+
|
|
92
|
+
def get(self, plugin_id: str) -> RegisteredPlugin | None:
|
|
93
|
+
return self._plugins.get(plugin_id)
|
|
94
|
+
|
|
95
|
+
def all_plugins(self) -> list[RegisteredPlugin]:
|
|
96
|
+
return list(self._plugins.values())
|
|
97
|
+
|
|
98
|
+
def enabled_plugins(self) -> list[RegisteredPlugin]:
|
|
99
|
+
return [p for p in self._plugins.values() if p.enabled]
|
|
100
|
+
|
|
101
|
+
def summaries(self) -> list[PluginSummary]:
|
|
102
|
+
return [
|
|
103
|
+
PluginSummary(
|
|
104
|
+
id=p.metadata.id,
|
|
105
|
+
name=p.metadata.name,
|
|
106
|
+
version=p.metadata.version,
|
|
107
|
+
description=p.metadata.description,
|
|
108
|
+
enabled=p.enabled,
|
|
109
|
+
kind=p.metadata.kind,
|
|
110
|
+
tool_count=len(p.manifest.tools),
|
|
111
|
+
command_count=len(p.manifest.commands),
|
|
112
|
+
hook_count=(
|
|
113
|
+
len(p.manifest.hooks.pre_tool_use)
|
|
114
|
+
+ len(p.manifest.hooks.post_tool_use)
|
|
115
|
+
+ len(p.manifest.hooks.post_tool_use_failure)
|
|
116
|
+
),
|
|
117
|
+
validation_errors=[e.message for e in p.validation_errors],
|
|
118
|
+
)
|
|
119
|
+
for p in self._plugins.values()
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
def aggregated_hooks(self) -> dict[str, list[str]]:
|
|
123
|
+
"""Collect all hook commands from enabled plugins."""
|
|
124
|
+
hooks: dict[str, list[str]] = {
|
|
125
|
+
"pre_tool_use": [],
|
|
126
|
+
"post_tool_use": [],
|
|
127
|
+
"post_tool_use_failure": [],
|
|
128
|
+
}
|
|
129
|
+
for plugin in self.enabled_plugins():
|
|
130
|
+
hooks["pre_tool_use"].extend(plugin.manifest.hooks.pre_tool_use)
|
|
131
|
+
hooks["post_tool_use"].extend(plugin.manifest.hooks.post_tool_use)
|
|
132
|
+
hooks["post_tool_use_failure"].extend(plugin.manifest.hooks.post_tool_use_failure)
|
|
133
|
+
return hooks
|
|
134
|
+
|
|
135
|
+
def remove(self, plugin_id: str) -> RegisteredPlugin | None:
|
|
136
|
+
return self._plugins.pop(plugin_id, None)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class PluginManager:
|
|
140
|
+
"""Manages plugin lifecycle: install, enable, disable, uninstall, init, shutdown.
|
|
141
|
+
|
|
142
|
+
Maps to: rust/crates/plugins/src/lib.rs::PluginManager
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
def __init__(self, config_dir: Path | None = None) -> None:
|
|
146
|
+
self.config_dir = config_dir or (Path.home() / ".axion" / "plugins")
|
|
147
|
+
self.registry = PluginRegistry()
|
|
148
|
+
self._installed_registry_path = self.config_dir / ".installed.json"
|
|
149
|
+
|
|
150
|
+
def discover_plugins(self) -> PluginRegistryReport:
|
|
151
|
+
"""Discover and register plugins from the config directory."""
|
|
152
|
+
loaded: list[PluginSummary] = []
|
|
153
|
+
failed: list[PluginLoadFailure] = []
|
|
154
|
+
|
|
155
|
+
if not self.config_dir.exists():
|
|
156
|
+
return PluginRegistryReport(loaded=loaded, failed=failed)
|
|
157
|
+
|
|
158
|
+
# Load installed registry
|
|
159
|
+
installed_records = self._load_installed_records()
|
|
160
|
+
|
|
161
|
+
for plugin_dir in self.config_dir.iterdir():
|
|
162
|
+
if not plugin_dir.is_dir() or plugin_dir.name.startswith("."):
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
manifest = load_manifest_from_directory(plugin_dir)
|
|
166
|
+
if manifest is None:
|
|
167
|
+
failed.append(PluginLoadFailure(
|
|
168
|
+
plugin_id=plugin_dir.name,
|
|
169
|
+
error="Failed to load plugin manifest",
|
|
170
|
+
path=str(plugin_dir),
|
|
171
|
+
))
|
|
172
|
+
continue
|
|
173
|
+
|
|
174
|
+
# Validate manifest
|
|
175
|
+
errors = validate_manifest(manifest, root=plugin_dir)
|
|
176
|
+
|
|
177
|
+
metadata = PluginMetadata(
|
|
178
|
+
id=plugin_dir.name,
|
|
179
|
+
name=manifest.name,
|
|
180
|
+
version=manifest.version,
|
|
181
|
+
description=manifest.description,
|
|
182
|
+
kind=PluginKind.EXTERNAL,
|
|
183
|
+
source=str(plugin_dir),
|
|
184
|
+
default_enabled=manifest.default_enabled,
|
|
185
|
+
root=plugin_dir,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Check installed records for enabled state
|
|
189
|
+
record = installed_records.get(plugin_dir.name)
|
|
190
|
+
enabled = record.enabled if record else manifest.default_enabled
|
|
191
|
+
|
|
192
|
+
plugin = RegisteredPlugin(
|
|
193
|
+
metadata=metadata,
|
|
194
|
+
manifest=manifest,
|
|
195
|
+
enabled=enabled,
|
|
196
|
+
validation_errors=errors,
|
|
197
|
+
)
|
|
198
|
+
self.registry.register(plugin)
|
|
199
|
+
|
|
200
|
+
loaded = self.registry.summaries()
|
|
201
|
+
return PluginRegistryReport(loaded=loaded, failed=failed)
|
|
202
|
+
|
|
203
|
+
def install(self, source: Path) -> PluginSummary | None:
|
|
204
|
+
"""Install a plugin from a directory."""
|
|
205
|
+
manifest = load_manifest_from_directory(source)
|
|
206
|
+
if manifest is None:
|
|
207
|
+
logger.error("No valid plugin manifest found at %s", source)
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
# Validate
|
|
211
|
+
errors = validate_manifest(manifest, root=source)
|
|
212
|
+
if any(e.field == "name" for e in errors):
|
|
213
|
+
logger.error("Plugin manifest has critical errors: %s", errors)
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
# Derive plugin ID
|
|
217
|
+
plugin_id = manifest.name.lower().replace(" ", "-")
|
|
218
|
+
target = self.config_dir / plugin_id
|
|
219
|
+
|
|
220
|
+
# Copy to config dir
|
|
221
|
+
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
222
|
+
if target.exists():
|
|
223
|
+
shutil.rmtree(target)
|
|
224
|
+
shutil.copytree(source, target)
|
|
225
|
+
|
|
226
|
+
metadata = PluginMetadata(
|
|
227
|
+
id=plugin_id,
|
|
228
|
+
name=manifest.name,
|
|
229
|
+
version=manifest.version,
|
|
230
|
+
description=manifest.description,
|
|
231
|
+
kind=PluginKind.EXTERNAL,
|
|
232
|
+
source=str(source),
|
|
233
|
+
root=target,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
plugin = RegisteredPlugin(
|
|
237
|
+
metadata=metadata, manifest=manifest, validation_errors=errors,
|
|
238
|
+
)
|
|
239
|
+
self.registry.register(plugin)
|
|
240
|
+
|
|
241
|
+
# Save installed record
|
|
242
|
+
self._save_installed_record(plugin_id, str(source), enabled=True)
|
|
243
|
+
|
|
244
|
+
# Run init lifecycle commands
|
|
245
|
+
self._run_lifecycle_commands(manifest.lifecycle.init, target)
|
|
246
|
+
|
|
247
|
+
return PluginSummary(
|
|
248
|
+
id=plugin_id,
|
|
249
|
+
name=manifest.name,
|
|
250
|
+
version=manifest.version,
|
|
251
|
+
description=manifest.description,
|
|
252
|
+
enabled=True,
|
|
253
|
+
kind=PluginKind.EXTERNAL,
|
|
254
|
+
tool_count=len(manifest.tools),
|
|
255
|
+
command_count=len(manifest.commands),
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
def enable(self, plugin_id: str) -> bool:
|
|
259
|
+
plugin = self.registry.get(plugin_id)
|
|
260
|
+
if plugin is None:
|
|
261
|
+
return False
|
|
262
|
+
plugin.enabled = True
|
|
263
|
+
self._save_installed_record(plugin_id, plugin.metadata.source, enabled=True)
|
|
264
|
+
return True
|
|
265
|
+
|
|
266
|
+
def disable(self, plugin_id: str) -> bool:
|
|
267
|
+
plugin = self.registry.get(plugin_id)
|
|
268
|
+
if plugin is None:
|
|
269
|
+
return False
|
|
270
|
+
plugin.enabled = False
|
|
271
|
+
self._save_installed_record(plugin_id, plugin.metadata.source, enabled=False)
|
|
272
|
+
return True
|
|
273
|
+
|
|
274
|
+
def uninstall(self, plugin_id: str) -> bool:
|
|
275
|
+
plugin = self.registry.get(plugin_id)
|
|
276
|
+
if plugin is None:
|
|
277
|
+
return False
|
|
278
|
+
|
|
279
|
+
# Run shutdown lifecycle commands
|
|
280
|
+
if plugin.metadata.root:
|
|
281
|
+
self._run_lifecycle_commands(
|
|
282
|
+
plugin.manifest.lifecycle.shutdown, plugin.metadata.root,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# Remove from disk
|
|
286
|
+
if plugin.metadata.root and plugin.metadata.root.exists():
|
|
287
|
+
shutil.rmtree(plugin.metadata.root)
|
|
288
|
+
|
|
289
|
+
# Remove from registry
|
|
290
|
+
self.registry.remove(plugin_id)
|
|
291
|
+
|
|
292
|
+
# Remove from installed records
|
|
293
|
+
self._remove_installed_record(plugin_id)
|
|
294
|
+
return True
|
|
295
|
+
|
|
296
|
+
def update(self, plugin_id: str, source: Path) -> PluginSummary | None:
|
|
297
|
+
"""Update a plugin by uninstalling and reinstalling."""
|
|
298
|
+
self.uninstall(plugin_id)
|
|
299
|
+
return self.install(source)
|
|
300
|
+
|
|
301
|
+
def shutdown_all(self) -> None:
|
|
302
|
+
"""Run shutdown lifecycle for all enabled plugins."""
|
|
303
|
+
for plugin in self.registry.enabled_plugins():
|
|
304
|
+
if plugin.metadata.root:
|
|
305
|
+
self._run_lifecycle_commands(
|
|
306
|
+
plugin.manifest.lifecycle.shutdown, plugin.metadata.root,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
# -----------------------------------------------------------------------
|
|
310
|
+
# Lifecycle execution
|
|
311
|
+
# -----------------------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
@staticmethod
|
|
314
|
+
def _run_lifecycle_commands(commands: list[str], cwd: Path) -> None:
|
|
315
|
+
"""Execute lifecycle commands in the plugin directory."""
|
|
316
|
+
for cmd in commands:
|
|
317
|
+
try:
|
|
318
|
+
result = subprocess.run(
|
|
319
|
+
cmd,
|
|
320
|
+
shell=True,
|
|
321
|
+
cwd=str(cwd),
|
|
322
|
+
capture_output=True,
|
|
323
|
+
text=True,
|
|
324
|
+
timeout=30,
|
|
325
|
+
)
|
|
326
|
+
if result.returncode != 0:
|
|
327
|
+
logger.warning(
|
|
328
|
+
"Plugin lifecycle command failed: %s (exit %d): %s",
|
|
329
|
+
cmd, result.returncode, result.stderr,
|
|
330
|
+
)
|
|
331
|
+
except subprocess.TimeoutExpired:
|
|
332
|
+
logger.warning("Plugin lifecycle command timed out: %s", cmd)
|
|
333
|
+
except Exception as exc:
|
|
334
|
+
logger.warning("Plugin lifecycle command error: %s: %s", cmd, exc)
|
|
335
|
+
|
|
336
|
+
# -----------------------------------------------------------------------
|
|
337
|
+
# Installed plugin persistence
|
|
338
|
+
# -----------------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
def _load_installed_records(self) -> dict[str, InstalledPluginRecord]:
|
|
341
|
+
"""Load the installed plugin registry from disk."""
|
|
342
|
+
if not self._installed_registry_path.exists():
|
|
343
|
+
return {}
|
|
344
|
+
try:
|
|
345
|
+
data = json.loads(self._installed_registry_path.read_text(encoding="utf-8"))
|
|
346
|
+
records = {}
|
|
347
|
+
for plugin_id, record_data in data.items():
|
|
348
|
+
records[plugin_id] = InstalledPluginRecord(
|
|
349
|
+
plugin_id=plugin_id,
|
|
350
|
+
source=record_data.get("source", ""),
|
|
351
|
+
installed_at_ms=record_data.get("installed_at_ms", 0),
|
|
352
|
+
enabled=record_data.get("enabled", True),
|
|
353
|
+
)
|
|
354
|
+
return records
|
|
355
|
+
except (json.JSONDecodeError, OSError):
|
|
356
|
+
return {}
|
|
357
|
+
|
|
358
|
+
def _save_installed_record(
|
|
359
|
+
self, plugin_id: str, source: str, enabled: bool
|
|
360
|
+
) -> None:
|
|
361
|
+
"""Save an installed plugin record."""
|
|
362
|
+
records = self._load_installed_records()
|
|
363
|
+
import time
|
|
364
|
+
|
|
365
|
+
records[plugin_id] = InstalledPluginRecord(
|
|
366
|
+
plugin_id=plugin_id,
|
|
367
|
+
source=source,
|
|
368
|
+
installed_at_ms=int(time.time() * 1000),
|
|
369
|
+
enabled=enabled,
|
|
370
|
+
)
|
|
371
|
+
self._write_installed_records(records)
|
|
372
|
+
|
|
373
|
+
def _remove_installed_record(self, plugin_id: str) -> None:
|
|
374
|
+
records = self._load_installed_records()
|
|
375
|
+
records.pop(plugin_id, None)
|
|
376
|
+
self._write_installed_records(records)
|
|
377
|
+
|
|
378
|
+
def _write_installed_records(
|
|
379
|
+
self, records: dict[str, InstalledPluginRecord]
|
|
380
|
+
) -> None:
|
|
381
|
+
self._installed_registry_path.parent.mkdir(parents=True, exist_ok=True)
|
|
382
|
+
data = {}
|
|
383
|
+
for pid, record in records.items():
|
|
384
|
+
data[pid] = {
|
|
385
|
+
"source": record.source,
|
|
386
|
+
"installed_at_ms": record.installed_at_ms,
|
|
387
|
+
"enabled": record.enabled,
|
|
388
|
+
}
|
|
389
|
+
self._installed_registry_path.write_text(
|
|
390
|
+
json.dumps(data, indent=2), encoding="utf-8",
|
|
391
|
+
)
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Plugin manifest and metadata with full validation.
|
|
2
|
+
|
|
3
|
+
Maps to: rust/crates/plugins/src/lib.rs (manifest types + validation)
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import enum
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PluginKind(enum.Enum):
|
|
19
|
+
BUILTIN = "builtin"
|
|
20
|
+
BUNDLED = "bundled"
|
|
21
|
+
EXTERNAL = "external"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PluginPermission(enum.Enum):
|
|
25
|
+
READ = "read"
|
|
26
|
+
WRITE = "write"
|
|
27
|
+
EXECUTE = "execute"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PluginToolPermission(enum.Enum):
|
|
31
|
+
READ_ONLY = "read-only"
|
|
32
|
+
WORKSPACE_WRITE = "workspace-write"
|
|
33
|
+
DANGER_FULL_ACCESS = "danger-full-access"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class PluginMetadata:
|
|
38
|
+
"""Plugin identity and metadata."""
|
|
39
|
+
|
|
40
|
+
id: str
|
|
41
|
+
name: str
|
|
42
|
+
version: str = "0.0.0"
|
|
43
|
+
description: str = ""
|
|
44
|
+
kind: PluginKind = PluginKind.EXTERNAL
|
|
45
|
+
source: str = ""
|
|
46
|
+
default_enabled: bool = True
|
|
47
|
+
root: Path | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class PluginToolManifest:
|
|
52
|
+
"""Tool provided by a plugin."""
|
|
53
|
+
|
|
54
|
+
name: str
|
|
55
|
+
description: str = ""
|
|
56
|
+
input_schema: dict[str, Any] = field(default_factory=dict)
|
|
57
|
+
required_permission: PluginToolPermission = PluginToolPermission.READ_ONLY
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class PluginCommandManifest:
|
|
62
|
+
"""Command provided by a plugin."""
|
|
63
|
+
|
|
64
|
+
name: str
|
|
65
|
+
description: str = ""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class PluginHooks:
|
|
70
|
+
"""Hook commands provided by a plugin."""
|
|
71
|
+
|
|
72
|
+
pre_tool_use: list[str] = field(default_factory=list)
|
|
73
|
+
post_tool_use: list[str] = field(default_factory=list)
|
|
74
|
+
post_tool_use_failure: list[str] = field(default_factory=list)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class PluginLifecycle:
|
|
79
|
+
"""Lifecycle commands for a plugin."""
|
|
80
|
+
|
|
81
|
+
init: list[str] = field(default_factory=list)
|
|
82
|
+
shutdown: list[str] = field(default_factory=list)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class PluginManifest:
|
|
87
|
+
"""Complete plugin descriptor loaded from plugin.json."""
|
|
88
|
+
|
|
89
|
+
name: str
|
|
90
|
+
version: str = "0.0.0"
|
|
91
|
+
description: str = ""
|
|
92
|
+
permissions: list[PluginPermission] = field(default_factory=list)
|
|
93
|
+
default_enabled: bool = True
|
|
94
|
+
hooks: PluginHooks = field(default_factory=PluginHooks)
|
|
95
|
+
lifecycle: PluginLifecycle = field(default_factory=PluginLifecycle)
|
|
96
|
+
tools: list[PluginToolManifest] = field(default_factory=list)
|
|
97
|
+
commands: list[PluginCommandManifest] = field(default_factory=list)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
# Validation
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
class ManifestValidationError:
|
|
106
|
+
"""Validation error details."""
|
|
107
|
+
|
|
108
|
+
field: str
|
|
109
|
+
message: str
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def validate_manifest(
|
|
113
|
+
manifest: PluginManifest, root: Path | None = None
|
|
114
|
+
) -> list[ManifestValidationError]:
|
|
115
|
+
"""Validate a plugin manifest for correctness.
|
|
116
|
+
|
|
117
|
+
Checks: required fields, hook path existence, tool schema validity,
|
|
118
|
+
lifecycle command existence, permission validity.
|
|
119
|
+
"""
|
|
120
|
+
errors: list[ManifestValidationError] = []
|
|
121
|
+
|
|
122
|
+
# Required fields
|
|
123
|
+
if not manifest.name:
|
|
124
|
+
errors.append(ManifestValidationError("name", "Plugin name is required"))
|
|
125
|
+
if not manifest.version:
|
|
126
|
+
errors.append(ManifestValidationError("version", "Plugin version is required"))
|
|
127
|
+
|
|
128
|
+
# Validate hook paths exist
|
|
129
|
+
if root:
|
|
130
|
+
all_hooks = (
|
|
131
|
+
manifest.hooks.pre_tool_use
|
|
132
|
+
+ manifest.hooks.post_tool_use
|
|
133
|
+
+ manifest.hooks.post_tool_use_failure
|
|
134
|
+
)
|
|
135
|
+
for hook_cmd in all_hooks:
|
|
136
|
+
# Check if hook command references a script in the plugin directory
|
|
137
|
+
hook_path = root / hook_cmd
|
|
138
|
+
if not hook_path.exists() and not _is_system_command(hook_cmd):
|
|
139
|
+
errors.append(ManifestValidationError(
|
|
140
|
+
"hooks",
|
|
141
|
+
f"Hook script not found: {hook_cmd} (checked {hook_path})",
|
|
142
|
+
))
|
|
143
|
+
|
|
144
|
+
# Validate lifecycle commands
|
|
145
|
+
for cmd in manifest.lifecycle.init + manifest.lifecycle.shutdown:
|
|
146
|
+
cmd_path = root / cmd
|
|
147
|
+
if not cmd_path.exists() and not _is_system_command(cmd):
|
|
148
|
+
errors.append(ManifestValidationError(
|
|
149
|
+
"lifecycle",
|
|
150
|
+
f"Lifecycle script not found: {cmd} (checked {cmd_path})",
|
|
151
|
+
))
|
|
152
|
+
|
|
153
|
+
# Validate tool schemas
|
|
154
|
+
for tool in manifest.tools:
|
|
155
|
+
if not tool.name:
|
|
156
|
+
errors.append(ManifestValidationError("tools", "Tool name is required"))
|
|
157
|
+
if tool.input_schema:
|
|
158
|
+
schema_type = tool.input_schema.get("type")
|
|
159
|
+
if schema_type and schema_type != "object":
|
|
160
|
+
errors.append(ManifestValidationError(
|
|
161
|
+
f"tools.{tool.name}",
|
|
162
|
+
f"Tool input_schema type must be 'object', got '{schema_type}'",
|
|
163
|
+
))
|
|
164
|
+
if "properties" not in tool.input_schema and schema_type == "object":
|
|
165
|
+
errors.append(ManifestValidationError(
|
|
166
|
+
f"tools.{tool.name}",
|
|
167
|
+
"Tool input_schema must have 'properties' field",
|
|
168
|
+
))
|
|
169
|
+
|
|
170
|
+
# Validate command names
|
|
171
|
+
for cmd in manifest.commands:
|
|
172
|
+
if not cmd.name:
|
|
173
|
+
errors.append(ManifestValidationError("commands", "Command name is required"))
|
|
174
|
+
if cmd.name.startswith("/"):
|
|
175
|
+
errors.append(ManifestValidationError(
|
|
176
|
+
f"commands.{cmd.name}",
|
|
177
|
+
"Command name should not start with '/'",
|
|
178
|
+
))
|
|
179
|
+
|
|
180
|
+
return errors
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _is_system_command(cmd: str) -> bool:
|
|
184
|
+
"""Check if a command looks like a system command (not a script path)."""
|
|
185
|
+
first_word = cmd.split()[0] if cmd.strip() else ""
|
|
186
|
+
return "/" not in first_word and "\\" not in first_word and "." not in first_word
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
# Loading
|
|
191
|
+
# ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
def load_manifest_from_directory(root: Path) -> PluginManifest | None:
|
|
194
|
+
"""Load a plugin manifest from a directory."""
|
|
195
|
+
manifest_path = root / ".axion-plugin" / "plugin.json"
|
|
196
|
+
if not manifest_path.exists():
|
|
197
|
+
manifest_path = root / ".claude-plugin" / "plugin.json" # backwards compat
|
|
198
|
+
if not manifest_path.exists():
|
|
199
|
+
manifest_path = root / "plugin.json"
|
|
200
|
+
if not manifest_path.exists():
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
205
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
206
|
+
logger.warning("Failed to parse manifest at %s: %s", manifest_path, exc)
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
return _parse_manifest(data, root)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _parse_manifest(data: dict[str, Any], root: Path | None = None) -> PluginManifest:
|
|
213
|
+
"""Parse a PluginManifest from a dict."""
|
|
214
|
+
hooks = PluginHooks()
|
|
215
|
+
if "hooks" in data:
|
|
216
|
+
h = data["hooks"]
|
|
217
|
+
hooks = PluginHooks(
|
|
218
|
+
pre_tool_use=h.get("preToolUse", []),
|
|
219
|
+
post_tool_use=h.get("postToolUse", []),
|
|
220
|
+
post_tool_use_failure=h.get("postToolUseFailure", []),
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
lifecycle = PluginLifecycle()
|
|
224
|
+
if "lifecycle" in data:
|
|
225
|
+
lc = data["lifecycle"]
|
|
226
|
+
lifecycle = PluginLifecycle(
|
|
227
|
+
init=lc.get("init", []),
|
|
228
|
+
shutdown=lc.get("shutdown", []),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
tools = []
|
|
232
|
+
for t in data.get("tools", []):
|
|
233
|
+
perm = PluginToolPermission.READ_ONLY
|
|
234
|
+
perm_str = t.get("requiredPermission", "read-only")
|
|
235
|
+
try:
|
|
236
|
+
perm = PluginToolPermission(perm_str)
|
|
237
|
+
except ValueError:
|
|
238
|
+
pass
|
|
239
|
+
tools.append(PluginToolManifest(
|
|
240
|
+
name=t.get("name", ""),
|
|
241
|
+
description=t.get("description", ""),
|
|
242
|
+
input_schema=t.get("inputSchema", {}),
|
|
243
|
+
required_permission=perm,
|
|
244
|
+
))
|
|
245
|
+
|
|
246
|
+
commands = []
|
|
247
|
+
for c in data.get("commands", []):
|
|
248
|
+
commands.append(PluginCommandManifest(
|
|
249
|
+
name=c.get("name", ""),
|
|
250
|
+
description=c.get("description", ""),
|
|
251
|
+
))
|
|
252
|
+
|
|
253
|
+
permissions = []
|
|
254
|
+
for p in data.get("permissions", []):
|
|
255
|
+
try:
|
|
256
|
+
permissions.append(PluginPermission(p))
|
|
257
|
+
except ValueError:
|
|
258
|
+
pass
|
|
259
|
+
|
|
260
|
+
return PluginManifest(
|
|
261
|
+
name=data.get("name", root.name if root else "unknown"),
|
|
262
|
+
version=data.get("version", "0.0.0"),
|
|
263
|
+
description=data.get("description", ""),
|
|
264
|
+
permissions=permissions,
|
|
265
|
+
default_enabled=data.get("defaultEnabled", True),
|
|
266
|
+
hooks=hooks,
|
|
267
|
+
lifecycle=lifecycle,
|
|
268
|
+
tools=tools,
|
|
269
|
+
commands=commands,
|
|
270
|
+
)
|
|
File without changes
|