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.
Files changed (82) hide show
  1. axion/__init__.py +3 -0
  2. axion/api/__init__.py +0 -0
  3. axion/api/anthropic.py +460 -0
  4. axion/api/client.py +259 -0
  5. axion/api/error.py +161 -0
  6. axion/api/ollama.py +597 -0
  7. axion/api/openai_compat.py +805 -0
  8. axion/api/openai_responses.py +627 -0
  9. axion/api/prompt_cache.py +31 -0
  10. axion/api/sse.py +98 -0
  11. axion/api/types.py +451 -0
  12. axion/cli/__init__.py +0 -0
  13. axion/cli/init_cmd.py +50 -0
  14. axion/cli/input.py +290 -0
  15. axion/cli/main.py +2953 -0
  16. axion/cli/render.py +489 -0
  17. axion/cli/tui.py +766 -0
  18. axion/commands/__init__.py +0 -0
  19. axion/commands/handlers/__init__.py +0 -0
  20. axion/commands/handlers/agents.py +51 -0
  21. axion/commands/handlers/builtin_commands.py +367 -0
  22. axion/commands/handlers/mcp.py +59 -0
  23. axion/commands/handlers/models.py +75 -0
  24. axion/commands/handlers/plugins.py +55 -0
  25. axion/commands/handlers/skills.py +61 -0
  26. axion/commands/parsing.py +317 -0
  27. axion/commands/registry.py +166 -0
  28. axion/compat_harness/__init__.py +0 -0
  29. axion/compat_harness/extractor.py +145 -0
  30. axion/plugins/__init__.py +0 -0
  31. axion/plugins/hooks.py +22 -0
  32. axion/plugins/manager.py +391 -0
  33. axion/plugins/manifest.py +270 -0
  34. axion/runtime/__init__.py +0 -0
  35. axion/runtime/bash.py +388 -0
  36. axion/runtime/bootstrap.py +39 -0
  37. axion/runtime/claude_subscription.py +300 -0
  38. axion/runtime/compact.py +233 -0
  39. axion/runtime/config.py +397 -0
  40. axion/runtime/conversation.py +1073 -0
  41. axion/runtime/file_ops.py +613 -0
  42. axion/runtime/git.py +213 -0
  43. axion/runtime/hooks.py +235 -0
  44. axion/runtime/image.py +212 -0
  45. axion/runtime/lanes.py +282 -0
  46. axion/runtime/lsp.py +425 -0
  47. axion/runtime/mcp/__init__.py +0 -0
  48. axion/runtime/mcp/client.py +76 -0
  49. axion/runtime/mcp/lifecycle.py +96 -0
  50. axion/runtime/mcp/stdio.py +318 -0
  51. axion/runtime/mcp/tool_bridge.py +79 -0
  52. axion/runtime/memory.py +196 -0
  53. axion/runtime/oauth.py +329 -0
  54. axion/runtime/openai_subscription.py +346 -0
  55. axion/runtime/permissions.py +247 -0
  56. axion/runtime/plan_mode.py +96 -0
  57. axion/runtime/policy_engine.py +259 -0
  58. axion/runtime/prompt.py +586 -0
  59. axion/runtime/recovery.py +261 -0
  60. axion/runtime/remote.py +28 -0
  61. axion/runtime/sandbox.py +68 -0
  62. axion/runtime/scheduler.py +231 -0
  63. axion/runtime/session.py +365 -0
  64. axion/runtime/sharing.py +159 -0
  65. axion/runtime/skills.py +124 -0
  66. axion/runtime/tasks.py +258 -0
  67. axion/runtime/usage.py +241 -0
  68. axion/runtime/workers.py +186 -0
  69. axion/telemetry/__init__.py +0 -0
  70. axion/telemetry/events.py +67 -0
  71. axion/telemetry/profile.py +49 -0
  72. axion/telemetry/sink.py +60 -0
  73. axion/telemetry/tracer.py +95 -0
  74. axion/tools/__init__.py +0 -0
  75. axion/tools/lane_completion.py +33 -0
  76. axion/tools/registry.py +853 -0
  77. axion/tools/tool_search.py +226 -0
  78. axion_code-1.0.0.dist-info/METADATA +709 -0
  79. axion_code-1.0.0.dist-info/RECORD +82 -0
  80. axion_code-1.0.0.dist-info/WHEEL +4 -0
  81. axion_code-1.0.0.dist-info/entry_points.txt +2 -0
  82. axion_code-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -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