1mod hooks;
2#[cfg(test)]
3pub mod test_isolation;
4
5use std::collections::{BTreeMap, BTreeSet};
6use std::fmt::{Display, Formatter};
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::process::{Command, Stdio};
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use serde::{Deserialize, Serialize};
14use serde_json::{Map, Value};
15
16pub use hooks::{HookEvent, HookRunResult, HookRunner};
17
18const EXTERNAL_MARKETPLACE: &str = "external";
19const BUILTIN_MARKETPLACE: &str = "builtin";
20const BUNDLED_MARKETPLACE: &str = "bundled";
21const SETTINGS_FILE_NAME: &str = "settings.json";
22const REGISTRY_FILE_NAME: &str = "installed.json";
23const MANIFEST_FILE_NAME: &str = "plugin.json";
24const MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json";
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "lowercase")]
28pub enum PluginKind {
29 Builtin,
30 Bundled,
31 External,
32}
33
34impl Display for PluginKind {
35 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
36 match self {
37 Self::Builtin => write!(f, "builtin"),
38 Self::Bundled => write!(f, "bundled"),
39 Self::External => write!(f, "external"),
40 }
41 }
42}
43
44impl PluginKind {
45 #[must_use]
46 fn marketplace(self) -> &'static str {
47 match self {
48 Self::Builtin => BUILTIN_MARKETPLACE,
49 Self::Bundled => BUNDLED_MARKETPLACE,
50 Self::External => EXTERNAL_MARKETPLACE,
51 }
52 }
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct PluginMetadata {
57 pub id: String,
58 pub name: String,
59 pub version: String,
60 pub description: String,
61 pub kind: PluginKind,
62 pub source: String,
63 pub default_enabled: bool,
64 pub root: Option<PathBuf>,
65}
66
67#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
68pub struct PluginHooks {
69 #[serde(rename = "PreToolUse", default)]
70 pub pre_tool_use: Vec<String>,
71 #[serde(rename = "PostToolUse", default)]
72 pub post_tool_use: Vec<String>,
73 #[serde(rename = "PostToolUseFailure", default)]
74 pub post_tool_use_failure: Vec<String>,
75}
76
77impl PluginHooks {
78 #[must_use]
79 pub fn is_empty(&self) -> bool {
80 self.pre_tool_use.is_empty()
81 && self.post_tool_use.is_empty()
82 && self.post_tool_use_failure.is_empty()
83 }
84
85 #[must_use]
86 pub fn merged_with(&self, other: &Self) -> Self {
87 let mut merged = self.clone();
88 merged
89 .pre_tool_use
90 .extend(other.pre_tool_use.iter().cloned());
91 merged
92 .post_tool_use
93 .extend(other.post_tool_use.iter().cloned());
94 merged
95 .post_tool_use_failure
96 .extend(other.post_tool_use_failure.iter().cloned());
97 merged
98 }
99}
100
101#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
102pub struct PluginLifecycle {
103 #[serde(rename = "Init", default)]
104 pub init: Vec<String>,
105 #[serde(rename = "Shutdown", default)]
106 pub shutdown: Vec<String>,
107}
108
109impl PluginLifecycle {
110 #[must_use]
111 pub fn is_empty(&self) -> bool {
112 self.init.is_empty() && self.shutdown.is_empty()
113 }
114}
115
116#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
117pub struct PluginManifest {
118 pub name: String,
119 pub version: String,
120 pub description: String,
121 pub permissions: Vec<PluginPermission>,
122 #[serde(rename = "defaultEnabled", default)]
123 pub default_enabled: bool,
124 #[serde(default)]
125 pub hooks: PluginHooks,
126 #[serde(default)]
127 pub lifecycle: PluginLifecycle,
128 #[serde(default)]
129 pub tools: Vec<PluginToolManifest>,
130 #[serde(default)]
131 pub commands: Vec<PluginCommandManifest>,
132}
133
134#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
135#[serde(rename_all = "lowercase")]
136pub enum PluginPermission {
137 Read,
138 Write,
139 Execute,
140}
141
142impl PluginPermission {
143 #[must_use]
144 pub fn as_str(self) -> &'static str {
145 match self {
146 Self::Read => "read",
147 Self::Write => "write",
148 Self::Execute => "execute",
149 }
150 }
151
152 fn parse(value: &str) -> Option<Self> {
153 match value {
154 "read" => Some(Self::Read),
155 "write" => Some(Self::Write),
156 "execute" => Some(Self::Execute),
157 _ => None,
158 }
159 }
160}
161
162impl AsRef<str> for PluginPermission {
163 fn as_ref(&self) -> &str {
164 self.as_str()
165 }
166}
167
168#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
169pub struct PluginToolManifest {
170 pub name: String,
171 pub description: String,
172 #[serde(rename = "inputSchema")]
173 pub input_schema: Value,
174 pub command: String,
175 #[serde(default)]
176 pub args: Vec<String>,
177 pub required_permission: PluginToolPermission,
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
181#[serde(rename_all = "kebab-case")]
182pub enum PluginToolPermission {
183 ReadOnly,
184 WorkspaceWrite,
185 DangerFullAccess,
186}
187
188impl PluginToolPermission {
189 #[must_use]
190 pub fn as_str(self) -> &'static str {
191 match self {
192 Self::ReadOnly => "read-only",
193 Self::WorkspaceWrite => "workspace-write",
194 Self::DangerFullAccess => "danger-full-access",
195 }
196 }
197
198 fn parse(value: &str) -> Option<Self> {
199 match value {
200 "read-only" => Some(Self::ReadOnly),
201 "workspace-write" => Some(Self::WorkspaceWrite),
202 "danger-full-access" => Some(Self::DangerFullAccess),
203 _ => None,
204 }
205 }
206}
207
208#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
209pub struct PluginToolDefinition {
210 pub name: String,
211 #[serde(default)]
212 pub description: Option<String>,
213 #[serde(rename = "inputSchema")]
214 pub input_schema: Value,
215}
216
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
218pub struct PluginCommandManifest {
219 pub name: String,
220 pub description: String,
221 pub command: String,
222}
223
224#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
225struct RawPluginManifest {
226 pub name: String,
227 pub version: String,
228 pub description: String,
229 #[serde(default)]
230 pub permissions: Vec<String>,
231 #[serde(rename = "defaultEnabled", default)]
232 pub default_enabled: bool,
233 #[serde(default)]
234 pub hooks: PluginHooks,
235 #[serde(default)]
236 pub lifecycle: PluginLifecycle,
237 #[serde(default)]
238 pub tools: Vec<RawPluginToolManifest>,
239 #[serde(default)]
240 pub commands: Vec<PluginCommandManifest>,
241}
242
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
244struct RawPluginToolManifest {
245 pub name: String,
246 pub description: String,
247 #[serde(rename = "inputSchema")]
248 pub input_schema: Value,
249 pub command: String,
250 #[serde(default)]
251 pub args: Vec<String>,
252 #[serde(
253 rename = "requiredPermission",
254 default = "default_tool_permission_label"
255 )]
256 pub required_permission: String,
257}
258
259#[derive(Debug, Clone, PartialEq)]
260pub struct PluginTool {
261 plugin_id: String,
262 plugin_name: String,
263 definition: PluginToolDefinition,
264 command: String,
265 args: Vec<String>,
266 required_permission: PluginToolPermission,
267 root: Option<PathBuf>,
268}
269
270impl PluginTool {
271 #[must_use]
272 pub fn new(
273 plugin_id: impl Into<String>,
274 plugin_name: impl Into<String>,
275 definition: PluginToolDefinition,
276 command: impl Into<String>,
277 args: Vec<String>,
278 required_permission: PluginToolPermission,
279 root: Option<PathBuf>,
280 ) -> Self {
281 Self {
282 plugin_id: plugin_id.into(),
283 plugin_name: plugin_name.into(),
284 definition,
285 command: command.into(),
286 args,
287 required_permission,
288 root,
289 }
290 }
291
292 #[must_use]
293 pub fn plugin_id(&self) -> &str {
294 &self.plugin_id
295 }
296
297 #[must_use]
298 pub fn definition(&self) -> &PluginToolDefinition {
299 &self.definition
300 }
301
302 #[must_use]
303 pub fn required_permission(&self) -> &str {
304 self.required_permission.as_str()
305 }
306
307 pub fn execute(&self, input: &Value) -> Result<String, PluginError> {
308 let input_json = input.to_string();
309 let mut process = Command::new(&self.command);
310 process
311 .args(&self.args)
312 .stdin(Stdio::piped())
313 .stdout(Stdio::piped())
314 .stderr(Stdio::piped())
315 .env("CLAWD_PLUGIN_ID", &self.plugin_id)
316 .env("CLAWD_PLUGIN_NAME", &self.plugin_name)
317 .env("CLAWD_TOOL_NAME", &self.definition.name)
318 .env("CLAWD_TOOL_INPUT", &input_json);
319 if let Some(root) = &self.root {
320 process
321 .current_dir(root)
322 .env("CLAWD_PLUGIN_ROOT", root.display().to_string());
323 }
324
325 let mut child = process.spawn()?;
326 if let Some(stdin) = child.stdin.as_mut() {
327 use std::io::Write as _;
328 stdin.write_all(input_json.as_bytes())?;
329 }
330
331 let output = child.wait_with_output()?;
332 if output.status.success() {
333 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
334 } else {
335 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
336 Err(PluginError::CommandFailed(format!(
337 "plugin tool `{}` from `{}` failed for `{}`: {}",
338 self.definition.name,
339 self.plugin_id,
340 self.command,
341 if stderr.is_empty() {
342 format!("exit status {}", output.status)
343 } else {
344 stderr
345 }
346 )))
347 }
348 }
349}
350
351fn default_tool_permission_label() -> String {
352 "danger-full-access".to_string()
353}
354
355#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
356#[serde(tag = "type", rename_all = "snake_case")]
357pub enum PluginInstallSource {
358 LocalPath { path: PathBuf },
359 GitUrl { url: String },
360}
361
362#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
363pub struct InstalledPluginRecord {
364 #[serde(default = "default_plugin_kind")]
365 pub kind: PluginKind,
366 pub id: String,
367 pub name: String,
368 pub version: String,
369 pub description: String,
370 pub install_path: PathBuf,
371 pub source: PluginInstallSource,
372 pub installed_at_unix_ms: u128,
373 pub updated_at_unix_ms: u128,
374}
375
376#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
377pub struct InstalledPluginRegistry {
378 #[serde(default)]
379 pub plugins: BTreeMap<String, InstalledPluginRecord>,
380}
381
382fn default_plugin_kind() -> PluginKind {
383 PluginKind::External
384}
385
386#[derive(Debug, Clone, PartialEq)]
387pub struct BuiltinPlugin {
388 metadata: PluginMetadata,
389 hooks: PluginHooks,
390 lifecycle: PluginLifecycle,
391 tools: Vec<PluginTool>,
392}
393
394#[derive(Debug, Clone, PartialEq)]
395pub struct BundledPlugin {
396 metadata: PluginMetadata,
397 hooks: PluginHooks,
398 lifecycle: PluginLifecycle,
399 tools: Vec<PluginTool>,
400}
401
402#[derive(Debug, Clone, PartialEq)]
403pub struct ExternalPlugin {
404 metadata: PluginMetadata,
405 hooks: PluginHooks,
406 lifecycle: PluginLifecycle,
407 tools: Vec<PluginTool>,
408}
409
410pub trait Plugin {
411 fn metadata(&self) -> &PluginMetadata;
412 fn hooks(&self) -> &PluginHooks;
413 fn lifecycle(&self) -> &PluginLifecycle;
414 fn tools(&self) -> &[PluginTool];
415 fn validate(&self) -> Result<(), PluginError>;
416 fn initialize(&self) -> Result<(), PluginError>;
417 fn shutdown(&self) -> Result<(), PluginError>;
418}
419
420#[derive(Debug, Clone, PartialEq)]
421pub enum PluginDefinition {
422 Builtin(BuiltinPlugin),
423 Bundled(BundledPlugin),
424 External(ExternalPlugin),
425}
426
427impl Plugin for BuiltinPlugin {
428 fn metadata(&self) -> &PluginMetadata {
429 &self.metadata
430 }
431
432 fn hooks(&self) -> &PluginHooks {
433 &self.hooks
434 }
435
436 fn lifecycle(&self) -> &PluginLifecycle {
437 &self.lifecycle
438 }
439
440 fn tools(&self) -> &[PluginTool] {
441 &self.tools
442 }
443
444 fn validate(&self) -> Result<(), PluginError> {
445 Ok(())
446 }
447
448 fn initialize(&self) -> Result<(), PluginError> {
449 Ok(())
450 }
451
452 fn shutdown(&self) -> Result<(), PluginError> {
453 Ok(())
454 }
455}
456
457impl Plugin for BundledPlugin {
458 fn metadata(&self) -> &PluginMetadata {
459 &self.metadata
460 }
461
462 fn hooks(&self) -> &PluginHooks {
463 &self.hooks
464 }
465
466 fn lifecycle(&self) -> &PluginLifecycle {
467 &self.lifecycle
468 }
469
470 fn tools(&self) -> &[PluginTool] {
471 &self.tools
472 }
473
474 fn validate(&self) -> Result<(), PluginError> {
475 validate_hook_paths(self.metadata.root.as_deref(), &self.hooks)?;
476 validate_lifecycle_paths(self.metadata.root.as_deref(), &self.lifecycle)?;
477 validate_tool_paths(self.metadata.root.as_deref(), &self.tools)
478 }
479
480 fn initialize(&self) -> Result<(), PluginError> {
481 run_lifecycle_commands(
482 self.metadata(),
483 self.lifecycle(),
484 "init",
485 &self.lifecycle.init,
486 )
487 }
488
489 fn shutdown(&self) -> Result<(), PluginError> {
490 run_lifecycle_commands(
491 self.metadata(),
492 self.lifecycle(),
493 "shutdown",
494 &self.lifecycle.shutdown,
495 )
496 }
497}
498
499impl Plugin for ExternalPlugin {
500 fn metadata(&self) -> &PluginMetadata {
501 &self.metadata
502 }
503
504 fn hooks(&self) -> &PluginHooks {
505 &self.hooks
506 }
507
508 fn lifecycle(&self) -> &PluginLifecycle {
509 &self.lifecycle
510 }
511
512 fn tools(&self) -> &[PluginTool] {
513 &self.tools
514 }
515
516 fn validate(&self) -> Result<(), PluginError> {
517 validate_hook_paths(self.metadata.root.as_deref(), &self.hooks)?;
518 validate_lifecycle_paths(self.metadata.root.as_deref(), &self.lifecycle)?;
519 validate_tool_paths(self.metadata.root.as_deref(), &self.tools)
520 }
521
522 fn initialize(&self) -> Result<(), PluginError> {
523 run_lifecycle_commands(
524 self.metadata(),
525 self.lifecycle(),
526 "init",
527 &self.lifecycle.init,
528 )
529 }
530
531 fn shutdown(&self) -> Result<(), PluginError> {
532 run_lifecycle_commands(
533 self.metadata(),
534 self.lifecycle(),
535 "shutdown",
536 &self.lifecycle.shutdown,
537 )
538 }
539}
540
541impl Plugin for PluginDefinition {
542 fn metadata(&self) -> &PluginMetadata {
543 match self {
544 Self::Builtin(plugin) => plugin.metadata(),
545 Self::Bundled(plugin) => plugin.metadata(),
546 Self::External(plugin) => plugin.metadata(),
547 }
548 }
549
550 fn hooks(&self) -> &PluginHooks {
551 match self {
552 Self::Builtin(plugin) => plugin.hooks(),
553 Self::Bundled(plugin) => plugin.hooks(),
554 Self::External(plugin) => plugin.hooks(),
555 }
556 }
557
558 fn lifecycle(&self) -> &PluginLifecycle {
559 match self {
560 Self::Builtin(plugin) => plugin.lifecycle(),
561 Self::Bundled(plugin) => plugin.lifecycle(),
562 Self::External(plugin) => plugin.lifecycle(),
563 }
564 }
565
566 fn tools(&self) -> &[PluginTool] {
567 match self {
568 Self::Builtin(plugin) => plugin.tools(),
569 Self::Bundled(plugin) => plugin.tools(),
570 Self::External(plugin) => plugin.tools(),
571 }
572 }
573
574 fn validate(&self) -> Result<(), PluginError> {
575 match self {
576 Self::Builtin(plugin) => plugin.validate(),
577 Self::Bundled(plugin) => plugin.validate(),
578 Self::External(plugin) => plugin.validate(),
579 }
580 }
581
582 fn initialize(&self) -> Result<(), PluginError> {
583 match self {
584 Self::Builtin(plugin) => plugin.initialize(),
585 Self::Bundled(plugin) => plugin.initialize(),
586 Self::External(plugin) => plugin.initialize(),
587 }
588 }
589
590 fn shutdown(&self) -> Result<(), PluginError> {
591 match self {
592 Self::Builtin(plugin) => plugin.shutdown(),
593 Self::Bundled(plugin) => plugin.shutdown(),
594 Self::External(plugin) => plugin.shutdown(),
595 }
596 }
597}
598
599#[derive(Debug, Clone, PartialEq)]
600pub struct RegisteredPlugin {
601 definition: PluginDefinition,
602 enabled: bool,
603}
604
605impl RegisteredPlugin {
606 #[must_use]
607 pub fn new(definition: PluginDefinition, enabled: bool) -> Self {
608 Self {
609 definition,
610 enabled,
611 }
612 }
613
614 #[must_use]
615 pub fn metadata(&self) -> &PluginMetadata {
616 self.definition.metadata()
617 }
618
619 #[must_use]
620 pub fn hooks(&self) -> &PluginHooks {
621 self.definition.hooks()
622 }
623
624 #[must_use]
625 pub fn tools(&self) -> &[PluginTool] {
626 self.definition.tools()
627 }
628
629 #[must_use]
630 pub fn is_enabled(&self) -> bool {
631 self.enabled
632 }
633
634 pub fn validate(&self) -> Result<(), PluginError> {
635 self.definition.validate()
636 }
637
638 pub fn initialize(&self) -> Result<(), PluginError> {
639 self.definition.initialize()
640 }
641
642 pub fn shutdown(&self) -> Result<(), PluginError> {
643 self.definition.shutdown()
644 }
645
646 #[must_use]
647 pub fn summary(&self) -> PluginSummary {
648 PluginSummary {
649 metadata: self.metadata().clone(),
650 enabled: self.enabled,
651 }
652 }
653}
654
655#[derive(Debug, Clone, PartialEq, Eq)]
656pub struct PluginSummary {
657 pub metadata: PluginMetadata,
658 pub enabled: bool,
659}
660
661#[derive(Debug)]
662pub struct PluginLoadFailure {
663 pub plugin_root: PathBuf,
664 pub kind: PluginKind,
665 pub source: String,
666 error: Box<PluginError>,
667}
668
669impl PluginLoadFailure {
670 #[must_use]
671 pub fn new(plugin_root: PathBuf, kind: PluginKind, source: String, error: PluginError) -> Self {
672 Self {
673 plugin_root,
674 kind,
675 source,
676 error: Box::new(error),
677 }
678 }
679
680 #[must_use]
681 pub fn error(&self) -> &PluginError {
682 self.error.as_ref()
683 }
684}
685
686impl Display for PluginLoadFailure {
687 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
688 write!(
689 f,
690 "failed to load {} plugin from `{}` (source: {}): {}",
691 self.kind,
692 self.plugin_root.display(),
693 self.source,
694 self.error()
695 )
696 }
697}
698
699#[derive(Debug)]
700pub struct PluginRegistryReport {
701 registry: PluginRegistry,
702 failures: Vec<PluginLoadFailure>,
703}
704
705impl PluginRegistryReport {
706 #[must_use]
707 pub fn new(registry: PluginRegistry, failures: Vec<PluginLoadFailure>) -> Self {
708 Self { registry, failures }
709 }
710
711 #[must_use]
712 pub fn registry(&self) -> &PluginRegistry {
713 &self.registry
714 }
715
716 #[must_use]
717 pub fn failures(&self) -> &[PluginLoadFailure] {
718 &self.failures
719 }
720
721 #[must_use]
722 pub fn has_failures(&self) -> bool {
723 !self.failures.is_empty()
724 }
725
726 #[must_use]
727 pub fn summaries(&self) -> Vec<PluginSummary> {
728 self.registry.summaries()
729 }
730
731 pub fn into_registry(self) -> Result<PluginRegistry, PluginError> {
732 if self.failures.is_empty() {
733 Ok(self.registry)
734 } else {
735 Err(PluginError::LoadFailures(self.failures))
736 }
737 }
738}
739
740#[derive(Debug, Default)]
741struct PluginDiscovery {
742 plugins: Vec<PluginDefinition>,
743 failures: Vec<PluginLoadFailure>,
744}
745
746impl PluginDiscovery {
747 fn push_plugin(&mut self, plugin: PluginDefinition) {
748 self.plugins.push(plugin);
749 }
750
751 fn push_failure(&mut self, failure: PluginLoadFailure) {
752 self.failures.push(failure);
753 }
754
755 fn extend(&mut self, other: Self) {
756 self.plugins.extend(other.plugins);
757 self.failures.extend(other.failures);
758 }
759}
760
761#[derive(Debug, Clone, Default, PartialEq)]
762pub struct PluginRegistry {
763 plugins: Vec<RegisteredPlugin>,
764}
765
766impl PluginRegistry {
767 #[must_use]
768 pub fn new(mut plugins: Vec<RegisteredPlugin>) -> Self {
769 plugins.sort_by(|left, right| left.metadata().id.cmp(&right.metadata().id));
770 Self { plugins }
771 }
772
773 #[must_use]
774 pub fn plugins(&self) -> &[RegisteredPlugin] {
775 &self.plugins
776 }
777
778 #[must_use]
779 pub fn get(&self, plugin_id: &str) -> Option<&RegisteredPlugin> {
780 self.plugins
781 .iter()
782 .find(|plugin| plugin.metadata().id == plugin_id)
783 }
784
785 #[must_use]
786 pub fn contains(&self, plugin_id: &str) -> bool {
787 self.get(plugin_id).is_some()
788 }
789
790 #[must_use]
791 pub fn summaries(&self) -> Vec<PluginSummary> {
792 self.plugins.iter().map(RegisteredPlugin::summary).collect()
793 }
794
795 pub fn aggregated_hooks(&self) -> Result<PluginHooks, PluginError> {
796 self.plugins
797 .iter()
798 .filter(|plugin| plugin.is_enabled())
799 .try_fold(PluginHooks::default(), |acc, plugin| {
800 plugin.validate()?;
801 Ok(acc.merged_with(plugin.hooks()))
802 })
803 }
804
805 pub fn aggregated_tools(&self) -> Result<Vec<PluginTool>, PluginError> {
806 let mut tools = Vec::new();
807 let mut seen_names = BTreeMap::new();
808 for plugin in self.plugins.iter().filter(|plugin| plugin.is_enabled()) {
809 plugin.validate()?;
810 for tool in plugin.tools() {
811 if let Some(existing_plugin) =
812 seen_names.insert(tool.definition().name.clone(), tool.plugin_id().to_string())
813 {
814 return Err(PluginError::InvalidManifest(format!(
815 "plugin tool `{}` is defined by both `{existing_plugin}` and `{}`",
816 tool.definition().name,
817 tool.plugin_id()
818 )));
819 }
820 tools.push(tool.clone());
821 }
822 }
823 Ok(tools)
824 }
825
826 pub fn initialize(&self) -> Result<(), PluginError> {
827 for plugin in self.plugins.iter().filter(|plugin| plugin.is_enabled()) {
828 plugin.validate()?;
829 plugin.initialize()?;
830 }
831 Ok(())
832 }
833
834 pub fn shutdown(&self) -> Result<(), PluginError> {
835 for plugin in self
836 .plugins
837 .iter()
838 .rev()
839 .filter(|plugin| plugin.is_enabled())
840 {
841 plugin.shutdown()?;
842 }
843 Ok(())
844 }
845}
846
847#[derive(Debug, Clone, PartialEq, Eq)]
848pub struct PluginManagerConfig {
849 pub config_home: PathBuf,
850 pub enabled_plugins: BTreeMap<String, bool>,
851 pub external_dirs: Vec<PathBuf>,
852 pub install_root: Option<PathBuf>,
853 pub registry_path: Option<PathBuf>,
854 pub bundled_root: Option<PathBuf>,
855}
856
857impl PluginManagerConfig {
858 #[must_use]
859 pub fn new(config_home: impl Into<PathBuf>) -> Self {
860 Self {
861 config_home: config_home.into(),
862 enabled_plugins: BTreeMap::new(),
863 external_dirs: Vec::new(),
864 install_root: None,
865 registry_path: None,
866 bundled_root: None,
867 }
868 }
869}
870
871#[derive(Debug, Clone, PartialEq, Eq)]
872pub struct PluginManager {
873 config: PluginManagerConfig,
874}
875
876#[derive(Debug, Clone, PartialEq, Eq)]
877pub struct InstallOutcome {
878 pub plugin_id: String,
879 pub version: String,
880 pub install_path: PathBuf,
881}
882
883#[derive(Debug, Clone, PartialEq, Eq)]
884pub struct UpdateOutcome {
885 pub plugin_id: String,
886 pub old_version: String,
887 pub new_version: String,
888 pub install_path: PathBuf,
889}
890
891#[derive(Debug, Clone, PartialEq, Eq)]
892pub enum PluginManifestValidationError {
893 EmptyField {
894 field: &'static str,
895 },
896 EmptyEntryField {
897 kind: &'static str,
898 field: &'static str,
899 name: Option<String>,
900 },
901 InvalidPermission {
902 permission: String,
903 },
904 DuplicatePermission {
905 permission: String,
906 },
907 DuplicateEntry {
908 kind: &'static str,
909 name: String,
910 },
911 MissingPath {
912 kind: &'static str,
913 path: PathBuf,
914 },
915 PathIsDirectory {
916 kind: &'static str,
917 path: PathBuf,
918 },
919 InvalidToolInputSchema {
920 tool_name: String,
921 },
922 InvalidToolRequiredPermission {
923 tool_name: String,
924 permission: String,
925 },
926 UnsupportedManifestContract {
927 detail: String,
928 },
929}
930
931impl Display for PluginManifestValidationError {
932 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
933 match self {
934 Self::EmptyField { field } => {
935 write!(f, "plugin manifest {field} cannot be empty")
936 }
937 Self::EmptyEntryField { kind, field, name } => match name {
938 Some(name) if !name.is_empty() => {
939 write!(f, "plugin {kind} `{name}` {field} cannot be empty")
940 }
941 _ => write!(f, "plugin {kind} {field} cannot be empty"),
942 },
943 Self::InvalidPermission { permission } => {
944 write!(
945 f,
946 "plugin manifest permission `{permission}` must be one of read, write, or execute"
947 )
948 }
949 Self::DuplicatePermission { permission } => {
950 write!(f, "plugin manifest permission `{permission}` is duplicated")
951 }
952 Self::DuplicateEntry { kind, name } => {
953 write!(f, "plugin {kind} `{name}` is duplicated")
954 }
955 Self::MissingPath { kind, path } => {
956 write!(f, "{kind} path `{}` does not exist", path.display())
957 }
958 Self::PathIsDirectory { kind, path } => {
959 write!(f, "{kind} path `{}` must point to a file", path.display())
960 }
961 Self::InvalidToolInputSchema { tool_name } => {
962 write!(
963 f,
964 "plugin tool `{tool_name}` inputSchema must be a JSON object"
965 )
966 }
967 Self::InvalidToolRequiredPermission {
968 tool_name,
969 permission,
970 } => write!(
971 f,
972 "plugin tool `{tool_name}` requiredPermission `{permission}` must be read-only, workspace-write, or danger-full-access"
973 ),
974 Self::UnsupportedManifestContract { detail } => f.write_str(detail),
975 }
976 }
977}
978
979#[derive(Debug)]
980pub enum PluginError {
981 Io(std::io::Error),
982 Json(serde_json::Error),
983 ManifestValidation(Vec<PluginManifestValidationError>),
984 LoadFailures(Vec<PluginLoadFailure>),
985 InvalidManifest(String),
986 NotFound(String),
987 CommandFailed(String),
988}
989
990impl Display for PluginError {
991 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
992 match self {
993 Self::Io(error) => write!(f, "{error}"),
994 Self::Json(error) => write!(f, "{error}"),
995 Self::ManifestValidation(errors) => {
996 for (index, error) in errors.iter().enumerate() {
997 if index > 0 {
998 write!(f, "; ")?;
999 }
1000 write!(f, "{error}")?;
1001 }
1002 Ok(())
1003 }
1004 Self::LoadFailures(failures) => {
1005 for (index, failure) in failures.iter().enumerate() {
1006 if index > 0 {
1007 write!(f, "; ")?;
1008 }
1009 write!(f, "{failure}")?;
1010 }
1011 Ok(())
1012 }
1013 Self::InvalidManifest(message)
1014 | Self::NotFound(message)
1015 | Self::CommandFailed(message) => write!(f, "{message}"),
1016 }
1017 }
1018}
1019
1020impl std::error::Error for PluginError {}
1021
1022impl From<std::io::Error> for PluginError {
1023 fn from(value: std::io::Error) -> Self {
1024 Self::Io(value)
1025 }
1026}
1027
1028impl From<serde_json::Error> for PluginError {
1029 fn from(value: serde_json::Error) -> Self {
1030 Self::Json(value)
1031 }
1032}
1033
1034impl PluginManager {
1035 #[must_use]
1036 pub fn new(config: PluginManagerConfig) -> Self {
1037 Self { config }
1038 }
1039
1040 #[must_use]
1041 pub fn bundled_root() -> PathBuf {
1042 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("bundled")
1043 }
1044
1045 #[must_use]
1046 pub fn install_root(&self) -> PathBuf {
1047 self.config
1048 .install_root
1049 .clone()
1050 .unwrap_or_else(|| self.config.config_home.join("plugins").join("installed"))
1051 }
1052
1053 #[must_use]
1054 pub fn registry_path(&self) -> PathBuf {
1055 self.config.registry_path.clone().unwrap_or_else(|| {
1056 self.config
1057 .config_home
1058 .join("plugins")
1059 .join(REGISTRY_FILE_NAME)
1060 })
1061 }
1062
1063 #[must_use]
1064 pub fn settings_path(&self) -> PathBuf {
1065 self.config.config_home.join(SETTINGS_FILE_NAME)
1066 }
1067
1068 pub fn plugin_registry(&self) -> Result<PluginRegistry, PluginError> {
1069 self.plugin_registry_report()?.into_registry()
1070 }
1071
1072 pub fn plugin_registry_report(&self) -> Result<PluginRegistryReport, PluginError> {
1073 self.sync_bundled_plugins()?;
1074
1075 let mut discovery = PluginDiscovery::default();
1076 discovery.plugins.extend(builtin_plugins());
1077
1078 let installed = self.discover_installed_plugins_with_failures()?;
1079 discovery.extend(installed);
1080
1081 let external =
1082 self.discover_external_directory_plugins_with_failures(&discovery.plugins)?;
1083 discovery.extend(external);
1084
1085 Ok(self.build_registry_report(discovery))
1086 }
1087
1088 pub fn list_plugins(&self) -> Result<Vec<PluginSummary>, PluginError> {
1089 Ok(self.plugin_registry()?.summaries())
1090 }
1091
1092 pub fn list_installed_plugins(&self) -> Result<Vec<PluginSummary>, PluginError> {
1093 Ok(self.installed_plugin_registry()?.summaries())
1094 }
1095
1096 pub fn discover_plugins(&self) -> Result<Vec<PluginDefinition>, PluginError> {
1097 Ok(self
1098 .plugin_registry()?
1099 .plugins
1100 .into_iter()
1101 .map(|plugin| plugin.definition)
1102 .collect())
1103 }
1104
1105 pub fn aggregated_hooks(&self) -> Result<PluginHooks, PluginError> {
1106 self.plugin_registry()?.aggregated_hooks()
1107 }
1108
1109 pub fn aggregated_tools(&self) -> Result<Vec<PluginTool>, PluginError> {
1110 self.plugin_registry()?.aggregated_tools()
1111 }
1112
1113 pub fn validate_plugin_source(&self, source: &str) -> Result<PluginManifest, PluginError> {
1114 let path = resolve_local_source(source)?;
1115 load_plugin_from_directory(&path)
1116 }
1117
1118 pub fn install(&mut self, source: &str) -> Result<InstallOutcome, PluginError> {
1119 let install_source = parse_install_source(source)?;
1120 let temp_root = self.install_root().join(".tmp");
1121 let staged_source = materialize_source(&install_source, &temp_root)?;
1122 let cleanup_source = matches!(install_source, PluginInstallSource::GitUrl { .. });
1123 let manifest = load_plugin_from_directory(&staged_source)?;
1124
1125 let plugin_id = plugin_id(&manifest.name, EXTERNAL_MARKETPLACE);
1126 let install_path = self.install_root().join(sanitize_plugin_id(&plugin_id));
1127 if install_path.exists() {
1128 fs::remove_dir_all(&install_path)?;
1129 }
1130 copy_dir_all(&staged_source, &install_path)?;
1131 if cleanup_source {
1132 let _ = fs::remove_dir_all(&staged_source);
1133 }
1134
1135 let now = unix_time_ms();
1136 let record = InstalledPluginRecord {
1137 kind: PluginKind::External,
1138 id: plugin_id.clone(),
1139 name: manifest.name,
1140 version: manifest.version.clone(),
1141 description: manifest.description,
1142 install_path: install_path.clone(),
1143 source: install_source,
1144 installed_at_unix_ms: now,
1145 updated_at_unix_ms: now,
1146 };
1147
1148 let mut registry = self.load_registry()?;
1149 registry.plugins.insert(plugin_id.clone(), record);
1150 self.store_registry(®istry)?;
1151 self.write_enabled_state(&plugin_id, Some(true))?;
1152 self.config.enabled_plugins.insert(plugin_id.clone(), true);
1153
1154 Ok(InstallOutcome {
1155 plugin_id,
1156 version: manifest.version,
1157 install_path,
1158 })
1159 }
1160
1161 pub fn enable(&mut self, plugin_id: &str) -> Result<(), PluginError> {
1162 self.ensure_known_plugin(plugin_id)?;
1163 self.write_enabled_state(plugin_id, Some(true))?;
1164 self.config
1165 .enabled_plugins
1166 .insert(plugin_id.to_string(), true);
1167 Ok(())
1168 }
1169
1170 pub fn disable(&mut self, plugin_id: &str) -> Result<(), PluginError> {
1171 self.ensure_known_plugin(plugin_id)?;
1172 self.write_enabled_state(plugin_id, Some(false))?;
1173 self.config
1174 .enabled_plugins
1175 .insert(plugin_id.to_string(), false);
1176 Ok(())
1177 }
1178
1179 pub fn uninstall(&mut self, plugin_id: &str) -> Result<(), PluginError> {
1180 let mut registry = self.load_registry()?;
1181 let record = registry.plugins.remove(plugin_id).ok_or_else(|| {
1182 PluginError::NotFound(format!("plugin `{plugin_id}` is not installed"))
1183 })?;
1184 if record.kind == PluginKind::Bundled {
1185 registry.plugins.insert(plugin_id.to_string(), record);
1186 return Err(PluginError::CommandFailed(format!(
1187 "plugin `{plugin_id}` is bundled and managed automatically; disable it instead"
1188 )));
1189 }
1190 if record.install_path.exists() {
1191 fs::remove_dir_all(&record.install_path)?;
1192 }
1193 self.store_registry(®istry)?;
1194 self.write_enabled_state(plugin_id, None)?;
1195 self.config.enabled_plugins.remove(plugin_id);
1196 Ok(())
1197 }
1198
1199 pub fn update(&mut self, plugin_id: &str) -> Result<UpdateOutcome, PluginError> {
1200 let mut registry = self.load_registry()?;
1201 let record = registry.plugins.get(plugin_id).cloned().ok_or_else(|| {
1202 PluginError::NotFound(format!("plugin `{plugin_id}` is not installed"))
1203 })?;
1204
1205 let temp_root = self.install_root().join(".tmp");
1206 let staged_source = materialize_source(&record.source, &temp_root)?;
1207 let cleanup_source = matches!(record.source, PluginInstallSource::GitUrl { .. });
1208 let manifest = load_plugin_from_directory(&staged_source)?;
1209
1210 if record.install_path.exists() {
1211 fs::remove_dir_all(&record.install_path)?;
1212 }
1213 copy_dir_all(&staged_source, &record.install_path)?;
1214 if cleanup_source {
1215 let _ = fs::remove_dir_all(&staged_source);
1216 }
1217
1218 let updated_record = InstalledPluginRecord {
1219 version: manifest.version.clone(),
1220 description: manifest.description,
1221 updated_at_unix_ms: unix_time_ms(),
1222 ..record.clone()
1223 };
1224 registry
1225 .plugins
1226 .insert(plugin_id.to_string(), updated_record);
1227 self.store_registry(®istry)?;
1228
1229 Ok(UpdateOutcome {
1230 plugin_id: plugin_id.to_string(),
1231 old_version: record.version,
1232 new_version: manifest.version,
1233 install_path: record.install_path,
1234 })
1235 }
1236
1237 fn discover_installed_plugins_with_failures(&self) -> Result<PluginDiscovery, PluginError> {
1238 let mut registry = self.load_registry()?;
1239 let mut discovery = PluginDiscovery::default();
1240 let mut seen_ids = BTreeSet::<String>::new();
1241 let mut seen_paths = BTreeSet::<PathBuf>::new();
1242 let mut stale_registry_ids = Vec::new();
1243
1244 for install_path in discover_plugin_dirs(&self.install_root())? {
1245 let matched_record = registry
1246 .plugins
1247 .values()
1248 .find(|record| record.install_path == install_path);
1249 let kind = matched_record.map_or(PluginKind::External, |record| record.kind);
1250 let source = matched_record.map_or_else(
1251 || install_path.display().to_string(),
1252 |record| describe_install_source(&record.source),
1253 );
1254 match load_plugin_definition(&install_path, kind, source.clone(), kind.marketplace()) {
1255 Ok(plugin) => {
1256 if seen_ids.insert(plugin.metadata().id.clone()) {
1257 seen_paths.insert(install_path);
1258 discovery.push_plugin(plugin);
1259 }
1260 }
1261 Err(error) => {
1262 discovery.push_failure(PluginLoadFailure::new(
1263 install_path,
1264 kind,
1265 source,
1266 error,
1267 ));
1268 }
1269 }
1270 }
1271
1272 for record in registry.plugins.values() {
1273 if seen_paths.contains(&record.install_path) {
1274 continue;
1275 }
1276 if !record.install_path.exists() || plugin_manifest_path(&record.install_path).is_err()
1277 {
1278 stale_registry_ids.push(record.id.clone());
1279 continue;
1280 }
1281 let source = describe_install_source(&record.source);
1282 match load_plugin_definition(
1283 &record.install_path,
1284 record.kind,
1285 source.clone(),
1286 record.kind.marketplace(),
1287 ) {
1288 Ok(plugin) => {
1289 if seen_ids.insert(plugin.metadata().id.clone()) {
1290 seen_paths.insert(record.install_path.clone());
1291 discovery.push_plugin(plugin);
1292 }
1293 }
1294 Err(error) => {
1295 discovery.push_failure(PluginLoadFailure::new(
1296 record.install_path.clone(),
1297 record.kind,
1298 source,
1299 error,
1300 ));
1301 }
1302 }
1303 }
1304
1305 if !stale_registry_ids.is_empty() {
1306 for plugin_id in stale_registry_ids {
1307 registry.plugins.remove(&plugin_id);
1308 }
1309 self.store_registry(®istry)?;
1310 }
1311
1312 Ok(discovery)
1313 }
1314
1315 fn discover_external_directory_plugins_with_failures(
1316 &self,
1317 existing_plugins: &[PluginDefinition],
1318 ) -> Result<PluginDiscovery, PluginError> {
1319 let mut discovery = PluginDiscovery::default();
1320
1321 for directory in &self.config.external_dirs {
1322 for root in discover_plugin_dirs(directory)? {
1323 let source = root.display().to_string();
1324 match load_plugin_definition(
1325 &root,
1326 PluginKind::External,
1327 source.clone(),
1328 EXTERNAL_MARKETPLACE,
1329 ) {
1330 Ok(plugin) => {
1331 if existing_plugins
1332 .iter()
1333 .chain(discovery.plugins.iter())
1334 .all(|existing| existing.metadata().id != plugin.metadata().id)
1335 {
1336 discovery.push_plugin(plugin);
1337 }
1338 }
1339 Err(error) => {
1340 discovery.push_failure(PluginLoadFailure::new(
1341 root,
1342 PluginKind::External,
1343 source,
1344 error,
1345 ));
1346 }
1347 }
1348 }
1349 }
1350
1351 Ok(discovery)
1352 }
1353
1354 pub fn installed_plugin_registry_report(&self) -> Result<PluginRegistryReport, PluginError> {
1355 self.sync_bundled_plugins()?;
1356 Ok(self.build_registry_report(self.discover_installed_plugins_with_failures()?))
1357 }
1358
1359 fn sync_bundled_plugins(&self) -> Result<(), PluginError> {
1360 let bundled_root = self
1361 .config
1362 .bundled_root
1363 .clone()
1364 .unwrap_or_else(Self::bundled_root);
1365 let bundled_plugins = discover_plugin_dirs(&bundled_root)?;
1366 let mut registry = self.load_registry()?;
1367 let mut changed = false;
1368 let install_root = self.install_root();
1369 let mut active_bundled_ids = BTreeSet::new();
1370
1371 for source_root in bundled_plugins {
1372 let manifest = load_plugin_from_directory(&source_root)?;
1373 let plugin_id = plugin_id(&manifest.name, BUNDLED_MARKETPLACE);
1374 active_bundled_ids.insert(plugin_id.clone());
1375 let install_path = install_root.join(sanitize_plugin_id(&plugin_id));
1376 let now = unix_time_ms();
1377 let existing_record = registry.plugins.get(&plugin_id);
1378 let installed_copy_is_valid =
1379 install_path.exists() && load_plugin_from_directory(&install_path).is_ok();
1380 let needs_sync = existing_record.is_none_or(|record| {
1381 record.kind != PluginKind::Bundled
1382 || record.version != manifest.version
1383 || record.name != manifest.name
1384 || record.description != manifest.description
1385 || record.install_path != install_path
1386 || !record.install_path.exists()
1387 || !installed_copy_is_valid
1388 });
1389
1390 if !needs_sync {
1391 continue;
1392 }
1393
1394 if install_path.exists() {
1395 fs::remove_dir_all(&install_path)?;
1396 }
1397 copy_dir_all(&source_root, &install_path)?;
1398
1399 let installed_at_unix_ms =
1400 existing_record.map_or(now, |record| record.installed_at_unix_ms);
1401 registry.plugins.insert(
1402 plugin_id.clone(),
1403 InstalledPluginRecord {
1404 kind: PluginKind::Bundled,
1405 id: plugin_id,
1406 name: manifest.name,
1407 version: manifest.version,
1408 description: manifest.description,
1409 install_path,
1410 source: PluginInstallSource::LocalPath { path: source_root },
1411 installed_at_unix_ms,
1412 updated_at_unix_ms: now,
1413 },
1414 );
1415 changed = true;
1416 }
1417
1418 let stale_bundled_ids = registry
1419 .plugins
1420 .iter()
1421 .filter_map(|(plugin_id, record)| {
1422 (record.kind == PluginKind::Bundled && !active_bundled_ids.contains(plugin_id))
1423 .then_some(plugin_id.clone())
1424 })
1425 .collect::<Vec<_>>();
1426
1427 for plugin_id in stale_bundled_ids {
1428 if let Some(record) = registry.plugins.remove(&plugin_id) {
1429 if record.install_path.exists() {
1430 fs::remove_dir_all(&record.install_path)?;
1431 }
1432 changed = true;
1433 }
1434 }
1435
1436 if changed {
1437 self.store_registry(®istry)?;
1438 }
1439
1440 Ok(())
1441 }
1442
1443 fn is_enabled(&self, metadata: &PluginMetadata) -> bool {
1444 self.config
1445 .enabled_plugins
1446 .get(&metadata.id)
1447 .copied()
1448 .unwrap_or(match metadata.kind {
1449 PluginKind::External => false,
1450 PluginKind::Builtin | PluginKind::Bundled => metadata.default_enabled,
1451 })
1452 }
1453
1454 fn ensure_known_plugin(&self, plugin_id: &str) -> Result<(), PluginError> {
1455 if self.plugin_registry()?.contains(plugin_id) {
1456 Ok(())
1457 } else {
1458 Err(PluginError::NotFound(format!(
1459 "plugin `{plugin_id}` is not installed or discoverable"
1460 )))
1461 }
1462 }
1463
1464 fn load_registry(&self) -> Result<InstalledPluginRegistry, PluginError> {
1465 let path = self.registry_path();
1466 match fs::read_to_string(&path) {
1467 Ok(contents) if contents.trim().is_empty() => Ok(InstalledPluginRegistry::default()),
1468 Ok(contents) => Ok(serde_json::from_str(&contents)?),
1469 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1470 Ok(InstalledPluginRegistry::default())
1471 }
1472 Err(error) => Err(PluginError::Io(error)),
1473 }
1474 }
1475
1476 fn store_registry(&self, registry: &InstalledPluginRegistry) -> Result<(), PluginError> {
1477 let path = self.registry_path();
1478 if let Some(parent) = path.parent() {
1479 fs::create_dir_all(parent)?;
1480 }
1481 fs::write(path, serde_json::to_string_pretty(registry)?)?;
1482 Ok(())
1483 }
1484
1485 fn write_enabled_state(
1486 &self,
1487 plugin_id: &str,
1488 enabled: Option<bool>,
1489 ) -> Result<(), PluginError> {
1490 update_settings_json(&self.settings_path(), |root| {
1491 let enabled_plugins = ensure_object(root, "enabledPlugins");
1492 match enabled {
1493 Some(value) => {
1494 enabled_plugins.insert(plugin_id.to_string(), Value::Bool(value));
1495 }
1496 None => {
1497 enabled_plugins.remove(plugin_id);
1498 }
1499 }
1500 })
1501 }
1502
1503 fn installed_plugin_registry(&self) -> Result<PluginRegistry, PluginError> {
1504 self.installed_plugin_registry_report()?.into_registry()
1505 }
1506
1507 fn build_registry_report(&self, discovery: PluginDiscovery) -> PluginRegistryReport {
1508 PluginRegistryReport::new(
1509 PluginRegistry::new(
1510 discovery
1511 .plugins
1512 .into_iter()
1513 .map(|plugin| {
1514 let enabled = self.is_enabled(plugin.metadata());
1515 RegisteredPlugin::new(plugin, enabled)
1516 })
1517 .collect(),
1518 ),
1519 discovery.failures,
1520 )
1521 }
1522}
1523
1524#[must_use]
1525pub fn builtin_plugins() -> Vec<PluginDefinition> {
1526 vec![PluginDefinition::Builtin(BuiltinPlugin {
1527 metadata: PluginMetadata {
1528 id: plugin_id("example-builtin", BUILTIN_MARKETPLACE),
1529 name: "example-builtin".to_string(),
1530 version: "0.1.0".to_string(),
1531 description: "Example built-in plugin scaffold for the Rust plugin system".to_string(),
1532 kind: PluginKind::Builtin,
1533 source: BUILTIN_MARKETPLACE.to_string(),
1534 default_enabled: false,
1535 root: None,
1536 },
1537 hooks: PluginHooks::default(),
1538 lifecycle: PluginLifecycle::default(),
1539 tools: Vec::new(),
1540 })]
1541}
1542
1543fn load_plugin_definition(
1544 root: &Path,
1545 kind: PluginKind,
1546 source: String,
1547 marketplace: &str,
1548) -> Result<PluginDefinition, PluginError> {
1549 let manifest = load_plugin_from_directory(root)?;
1550 let metadata = PluginMetadata {
1551 id: plugin_id(&manifest.name, marketplace),
1552 name: manifest.name,
1553 version: manifest.version,
1554 description: manifest.description,
1555 kind,
1556 source,
1557 default_enabled: manifest.default_enabled,
1558 root: Some(root.to_path_buf()),
1559 };
1560 let hooks = resolve_hooks(root, &manifest.hooks);
1561 let lifecycle = resolve_lifecycle(root, &manifest.lifecycle);
1562 let tools = resolve_tools(root, &metadata.id, &metadata.name, &manifest.tools);
1563 Ok(match kind {
1564 PluginKind::Builtin => PluginDefinition::Builtin(BuiltinPlugin {
1565 metadata,
1566 hooks,
1567 lifecycle,
1568 tools,
1569 }),
1570 PluginKind::Bundled => PluginDefinition::Bundled(BundledPlugin {
1571 metadata,
1572 hooks,
1573 lifecycle,
1574 tools,
1575 }),
1576 PluginKind::External => PluginDefinition::External(ExternalPlugin {
1577 metadata,
1578 hooks,
1579 lifecycle,
1580 tools,
1581 }),
1582 })
1583}
1584
1585pub fn load_plugin_from_directory(root: &Path) -> Result<PluginManifest, PluginError> {
1586 load_manifest_from_directory(root)
1587}
1588
1589fn load_manifest_from_directory(root: &Path) -> Result<PluginManifest, PluginError> {
1590 let manifest_path = plugin_manifest_path(root)?;
1591 load_manifest_from_path(root, &manifest_path)
1592}
1593
1594fn load_manifest_from_path(
1595 root: &Path,
1596 manifest_path: &Path,
1597) -> Result<PluginManifest, PluginError> {
1598 let contents = fs::read_to_string(manifest_path).map_err(|error| {
1599 PluginError::NotFound(format!(
1600 "plugin manifest not found at {}: {error}",
1601 manifest_path.display()
1602 ))
1603 })?;
1604 let raw_json: Value = serde_json::from_str(&contents)?;
1605 let compatibility_errors = detect_claude_code_manifest_contract_gaps(&raw_json);
1606 if !compatibility_errors.is_empty() {
1607 return Err(PluginError::ManifestValidation(compatibility_errors));
1608 }
1609 let raw_manifest: RawPluginManifest = serde_json::from_value(raw_json)?;
1610 build_plugin_manifest(root, raw_manifest)
1611}
1612
1613fn detect_claude_code_manifest_contract_gaps(
1614 raw_manifest: &Value,
1615) -> Vec<PluginManifestValidationError> {
1616 let Some(root) = raw_manifest.as_object() else {
1617 return Vec::new();
1618 };
1619
1620 let mut errors = Vec::new();
1621
1622 for (field, detail) in [
1623 (
1624 "skills",
1625 "plugin manifest field `skills` uses the Claude Code plugin contract; `claw` does not load plugin-managed skills and instead discovers skills from local roots such as `.claw/skills`, `.omc/skills`, `.agents/skills`, `~/.omc/skills`, and `~/.claude/skills/omc-learned`.",
1626 ),
1627 (
1628 "mcpServers",
1629 "plugin manifest field `mcpServers` uses the Claude Code plugin contract; `claw` does not import MCP servers from plugin manifests.",
1630 ),
1631 (
1632 "agents",
1633 "plugin manifest field `agents` uses the Claude Code plugin contract; `claw` does not load plugin-managed agent markdown catalogs from plugin manifests.",
1634 ),
1635 ] {
1636 if root.contains_key(field) {
1637 errors.push(PluginManifestValidationError::UnsupportedManifestContract {
1638 detail: detail.to_string(),
1639 });
1640 }
1641 }
1642
1643 if root
1644 .get("commands")
1645 .and_then(Value::as_array)
1646 .is_some_and(|commands| commands.iter().any(Value::is_string))
1647 {
1648 errors.push(PluginManifestValidationError::UnsupportedManifestContract {
1649 detail: "plugin manifest field `commands` uses Claude Code-style directory globs; `claw` slash dispatch is still built-in and does not load plugin slash command markdown files.".to_string(),
1650 });
1651 }
1652
1653 if let Some(hooks) = root.get("hooks").and_then(Value::as_object) {
1654 for hook_name in hooks.keys() {
1655 if !matches!(
1656 hook_name.as_str(),
1657 "PreToolUse" | "PostToolUse" | "PostToolUseFailure"
1658 ) {
1659 errors.push(PluginManifestValidationError::UnsupportedManifestContract {
1660 detail: format!(
1661 "plugin hook `{hook_name}` uses the Claude Code lifecycle contract; `claw` plugins currently support only PreToolUse, PostToolUse, and PostToolUseFailure."
1662 ),
1663 });
1664 }
1665 }
1666 }
1667
1668 errors
1669}
1670
1671fn plugin_manifest_path(root: &Path) -> Result<PathBuf, PluginError> {
1672 let direct_path = root.join(MANIFEST_FILE_NAME);
1673 if direct_path.exists() {
1674 return Ok(direct_path);
1675 }
1676
1677 let packaged_path = root.join(MANIFEST_RELATIVE_PATH);
1678 if packaged_path.exists() {
1679 return Ok(packaged_path);
1680 }
1681
1682 Err(PluginError::NotFound(format!(
1683 "plugin manifest not found at {} or {}",
1684 direct_path.display(),
1685 packaged_path.display()
1686 )))
1687}
1688
1689fn build_plugin_manifest(
1690 root: &Path,
1691 raw: RawPluginManifest,
1692) -> Result<PluginManifest, PluginError> {
1693 let mut errors = Vec::new();
1694
1695 validate_required_manifest_field("name", &raw.name, &mut errors);
1696 validate_required_manifest_field("version", &raw.version, &mut errors);
1697 validate_required_manifest_field("description", &raw.description, &mut errors);
1698
1699 let permissions = build_manifest_permissions(&raw.permissions, &mut errors);
1700 validate_command_entries(root, raw.hooks.pre_tool_use.iter(), "hook", &mut errors);
1701 validate_command_entries(root, raw.hooks.post_tool_use.iter(), "hook", &mut errors);
1702 validate_command_entries(
1703 root,
1704 raw.hooks.post_tool_use_failure.iter(),
1705 "hook",
1706 &mut errors,
1707 );
1708 validate_command_entries(
1709 root,
1710 raw.lifecycle.init.iter(),
1711 "lifecycle command",
1712 &mut errors,
1713 );
1714 validate_command_entries(
1715 root,
1716 raw.lifecycle.shutdown.iter(),
1717 "lifecycle command",
1718 &mut errors,
1719 );
1720 let tools = build_manifest_tools(root, raw.tools, &mut errors);
1721 let commands = build_manifest_commands(root, raw.commands, &mut errors);
1722
1723 if !errors.is_empty() {
1724 return Err(PluginError::ManifestValidation(errors));
1725 }
1726
1727 Ok(PluginManifest {
1728 name: raw.name,
1729 version: raw.version,
1730 description: raw.description,
1731 permissions,
1732 default_enabled: raw.default_enabled,
1733 hooks: raw.hooks,
1734 lifecycle: raw.lifecycle,
1735 tools,
1736 commands,
1737 })
1738}
1739
1740fn validate_required_manifest_field(
1741 field: &'static str,
1742 value: &str,
1743 errors: &mut Vec<PluginManifestValidationError>,
1744) {
1745 if value.trim().is_empty() {
1746 errors.push(PluginManifestValidationError::EmptyField { field });
1747 }
1748}
1749
1750fn build_manifest_permissions(
1751 permissions: &[String],
1752 errors: &mut Vec<PluginManifestValidationError>,
1753) -> Vec<PluginPermission> {
1754 let mut seen = BTreeSet::new();
1755 let mut validated = Vec::new();
1756
1757 for permission in permissions {
1758 let permission = permission.trim();
1759 if permission.is_empty() {
1760 errors.push(PluginManifestValidationError::EmptyEntryField {
1761 kind: "permission",
1762 field: "value",
1763 name: None,
1764 });
1765 continue;
1766 }
1767 if !seen.insert(permission.to_string()) {
1768 errors.push(PluginManifestValidationError::DuplicatePermission {
1769 permission: permission.to_string(),
1770 });
1771 continue;
1772 }
1773 match PluginPermission::parse(permission) {
1774 Some(permission) => validated.push(permission),
1775 None => errors.push(PluginManifestValidationError::InvalidPermission {
1776 permission: permission.to_string(),
1777 }),
1778 }
1779 }
1780
1781 validated
1782}
1783
1784fn build_manifest_tools(
1785 root: &Path,
1786 tools: Vec<RawPluginToolManifest>,
1787 errors: &mut Vec<PluginManifestValidationError>,
1788) -> Vec<PluginToolManifest> {
1789 let mut seen = BTreeSet::new();
1790 let mut validated = Vec::new();
1791
1792 for tool in tools {
1793 let name = tool.name.trim().to_string();
1794 if name.is_empty() {
1795 errors.push(PluginManifestValidationError::EmptyEntryField {
1796 kind: "tool",
1797 field: "name",
1798 name: None,
1799 });
1800 continue;
1801 }
1802 if !seen.insert(name.clone()) {
1803 errors.push(PluginManifestValidationError::DuplicateEntry { kind: "tool", name });
1804 continue;
1805 }
1806 if tool.description.trim().is_empty() {
1807 errors.push(PluginManifestValidationError::EmptyEntryField {
1808 kind: "tool",
1809 field: "description",
1810 name: Some(name.clone()),
1811 });
1812 }
1813 if tool.command.trim().is_empty() {
1814 errors.push(PluginManifestValidationError::EmptyEntryField {
1815 kind: "tool",
1816 field: "command",
1817 name: Some(name.clone()),
1818 });
1819 } else {
1820 validate_command_entry(root, &tool.command, "tool", errors);
1821 }
1822 if !tool.input_schema.is_object() {
1823 errors.push(PluginManifestValidationError::InvalidToolInputSchema {
1824 tool_name: name.clone(),
1825 });
1826 }
1827 let Some(required_permission) =
1828 PluginToolPermission::parse(tool.required_permission.trim())
1829 else {
1830 errors.push(
1831 PluginManifestValidationError::InvalidToolRequiredPermission {
1832 tool_name: name.clone(),
1833 permission: tool.required_permission.trim().to_string(),
1834 },
1835 );
1836 continue;
1837 };
1838
1839 validated.push(PluginToolManifest {
1840 name,
1841 description: tool.description,
1842 input_schema: tool.input_schema,
1843 command: tool.command,
1844 args: tool.args,
1845 required_permission,
1846 });
1847 }
1848
1849 validated
1850}
1851
1852fn build_manifest_commands(
1853 root: &Path,
1854 commands: Vec<PluginCommandManifest>,
1855 errors: &mut Vec<PluginManifestValidationError>,
1856) -> Vec<PluginCommandManifest> {
1857 let mut seen = BTreeSet::new();
1858 let mut validated = Vec::new();
1859
1860 for command in commands {
1861 let name = command.name.trim().to_string();
1862 if name.is_empty() {
1863 errors.push(PluginManifestValidationError::EmptyEntryField {
1864 kind: "command",
1865 field: "name",
1866 name: None,
1867 });
1868 continue;
1869 }
1870 if !seen.insert(name.clone()) {
1871 errors.push(PluginManifestValidationError::DuplicateEntry {
1872 kind: "command",
1873 name,
1874 });
1875 continue;
1876 }
1877 if command.description.trim().is_empty() {
1878 errors.push(PluginManifestValidationError::EmptyEntryField {
1879 kind: "command",
1880 field: "description",
1881 name: Some(name.clone()),
1882 });
1883 }
1884 if command.command.trim().is_empty() {
1885 errors.push(PluginManifestValidationError::EmptyEntryField {
1886 kind: "command",
1887 field: "command",
1888 name: Some(name.clone()),
1889 });
1890 } else {
1891 validate_command_entry(root, &command.command, "command", errors);
1892 }
1893 validated.push(command);
1894 }
1895
1896 validated
1897}
1898
1899fn validate_command_entries<'a>(
1900 root: &Path,
1901 entries: impl Iterator<Item = &'a String>,
1902 kind: &'static str,
1903 errors: &mut Vec<PluginManifestValidationError>,
1904) {
1905 for entry in entries {
1906 validate_command_entry(root, entry, kind, errors);
1907 }
1908}
1909
1910fn validate_command_entry(
1911 root: &Path,
1912 entry: &str,
1913 kind: &'static str,
1914 errors: &mut Vec<PluginManifestValidationError>,
1915) {
1916 if entry.trim().is_empty() {
1917 errors.push(PluginManifestValidationError::EmptyEntryField {
1918 kind,
1919 field: "command",
1920 name: None,
1921 });
1922 return;
1923 }
1924 if is_literal_command(entry) {
1925 return;
1926 }
1927
1928 let path = if Path::new(entry).is_absolute() {
1929 PathBuf::from(entry)
1930 } else {
1931 root.join(entry)
1932 };
1933 if !path.exists() {
1934 errors.push(PluginManifestValidationError::MissingPath { kind, path });
1935 } else if !path.is_file() {
1936 errors.push(PluginManifestValidationError::PathIsDirectory { kind, path });
1937 }
1938}
1939
1940fn resolve_hooks(root: &Path, hooks: &PluginHooks) -> PluginHooks {
1941 PluginHooks {
1942 pre_tool_use: hooks
1943 .pre_tool_use
1944 .iter()
1945 .map(|entry| resolve_hook_entry(root, entry))
1946 .collect(),
1947 post_tool_use: hooks
1948 .post_tool_use
1949 .iter()
1950 .map(|entry| resolve_hook_entry(root, entry))
1951 .collect(),
1952 post_tool_use_failure: hooks
1953 .post_tool_use_failure
1954 .iter()
1955 .map(|entry| resolve_hook_entry(root, entry))
1956 .collect(),
1957 }
1958}
1959
1960fn resolve_lifecycle(root: &Path, lifecycle: &PluginLifecycle) -> PluginLifecycle {
1961 PluginLifecycle {
1962 init: lifecycle
1963 .init
1964 .iter()
1965 .map(|entry| resolve_hook_entry(root, entry))
1966 .collect(),
1967 shutdown: lifecycle
1968 .shutdown
1969 .iter()
1970 .map(|entry| resolve_hook_entry(root, entry))
1971 .collect(),
1972 }
1973}
1974
1975fn resolve_tools(
1976 root: &Path,
1977 plugin_id: &str,
1978 plugin_name: &str,
1979 tools: &[PluginToolManifest],
1980) -> Vec<PluginTool> {
1981 tools
1982 .iter()
1983 .map(|tool| {
1984 PluginTool::new(
1985 plugin_id,
1986 plugin_name,
1987 PluginToolDefinition {
1988 name: tool.name.clone(),
1989 description: Some(tool.description.clone()),
1990 input_schema: tool.input_schema.clone(),
1991 },
1992 resolve_hook_entry(root, &tool.command),
1993 tool.args.clone(),
1994 tool.required_permission,
1995 Some(root.to_path_buf()),
1996 )
1997 })
1998 .collect()
1999}
2000
2001fn validate_hook_paths(root: Option<&Path>, hooks: &PluginHooks) -> Result<(), PluginError> {
2002 let Some(root) = root else {
2003 return Ok(());
2004 };
2005 for entry in hooks
2006 .pre_tool_use
2007 .iter()
2008 .chain(hooks.post_tool_use.iter())
2009 .chain(hooks.post_tool_use_failure.iter())
2010 {
2011 validate_command_path(root, entry, "hook")?;
2012 }
2013 Ok(())
2014}
2015
2016fn validate_lifecycle_paths(
2017 root: Option<&Path>,
2018 lifecycle: &PluginLifecycle,
2019) -> Result<(), PluginError> {
2020 let Some(root) = root else {
2021 return Ok(());
2022 };
2023 for entry in lifecycle.init.iter().chain(lifecycle.shutdown.iter()) {
2024 validate_command_path(root, entry, "lifecycle command")?;
2025 }
2026 Ok(())
2027}
2028
2029fn validate_tool_paths(root: Option<&Path>, tools: &[PluginTool]) -> Result<(), PluginError> {
2030 let Some(root) = root else {
2031 return Ok(());
2032 };
2033 for tool in tools {
2034 validate_command_path(root, &tool.command, "tool")?;
2035 }
2036 Ok(())
2037}
2038
2039fn validate_command_path(root: &Path, entry: &str, kind: &str) -> Result<(), PluginError> {
2040 if is_literal_command(entry) {
2041 return Ok(());
2042 }
2043 let path = if Path::new(entry).is_absolute() {
2044 PathBuf::from(entry)
2045 } else {
2046 root.join(entry)
2047 };
2048 if !path.exists() {
2049 return Err(PluginError::InvalidManifest(format!(
2050 "{kind} path `{}` does not exist",
2051 path.display()
2052 )));
2053 }
2054 if !path.is_file() {
2055 return Err(PluginError::InvalidManifest(format!(
2056 "{kind} path `{}` must point to a file",
2057 path.display()
2058 )));
2059 }
2060 Ok(())
2061}
2062
2063fn resolve_hook_entry(root: &Path, entry: &str) -> String {
2064 if is_literal_command(entry) {
2065 entry.to_string()
2066 } else {
2067 root.join(entry).display().to_string()
2068 }
2069}
2070
2071fn is_literal_command(entry: &str) -> bool {
2072 !entry.starts_with("./") && !entry.starts_with("../") && !Path::new(entry).is_absolute()
2073}
2074
2075fn run_lifecycle_commands(
2076 metadata: &PluginMetadata,
2077 lifecycle: &PluginLifecycle,
2078 phase: &str,
2079 commands: &[String],
2080) -> Result<(), PluginError> {
2081 if lifecycle.is_empty() || commands.is_empty() {
2082 return Ok(());
2083 }
2084
2085 for command in commands {
2086 let mut process = if Path::new(command).exists() {
2087 if cfg!(windows) {
2088 let mut process = Command::new("cmd");
2089 process.arg("/C").arg(command);
2090 process
2091 } else {
2092 let mut process = Command::new("sh");
2093 process.arg(command);
2094 process
2095 }
2096 } else if cfg!(windows) {
2097 let mut process = Command::new("cmd");
2098 process.arg("/C").arg(command);
2099 process
2100 } else {
2101 let mut process = Command::new("sh");
2102 process.arg("-lc").arg(command);
2103 process
2104 };
2105 if let Some(root) = &metadata.root {
2106 process.current_dir(root);
2107 }
2108 let output = process.output()?;
2109
2110 if !output.status.success() {
2111 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
2112 return Err(PluginError::CommandFailed(format!(
2113 "plugin `{}` {} failed for `{}`: {}",
2114 metadata.id,
2115 phase,
2116 command,
2117 if stderr.is_empty() {
2118 format!("exit status {}", output.status)
2119 } else {
2120 stderr
2121 }
2122 )));
2123 }
2124 }
2125
2126 Ok(())
2127}
2128
2129fn resolve_local_source(source: &str) -> Result<PathBuf, PluginError> {
2130 let path = PathBuf::from(source);
2131 if path.exists() {
2132 Ok(path)
2133 } else {
2134 Err(PluginError::NotFound(format!(
2135 "plugin source `{source}` was not found"
2136 )))
2137 }
2138}
2139
2140fn parse_install_source(source: &str) -> Result<PluginInstallSource, PluginError> {
2141 if source.starts_with("http://")
2142 || source.starts_with("https://")
2143 || source.starts_with("git@")
2144 || Path::new(source)
2145 .extension()
2146 .is_some_and(|extension| extension.eq_ignore_ascii_case("git"))
2147 {
2148 Ok(PluginInstallSource::GitUrl {
2149 url: source.to_string(),
2150 })
2151 } else {
2152 Ok(PluginInstallSource::LocalPath {
2153 path: resolve_local_source(source)?,
2154 })
2155 }
2156}
2157
2158fn materialize_source(
2159 source: &PluginInstallSource,
2160 temp_root: &Path,
2161) -> Result<PathBuf, PluginError> {
2162 fs::create_dir_all(temp_root)?;
2163 match source {
2164 PluginInstallSource::LocalPath { path } => Ok(path.clone()),
2165 PluginInstallSource::GitUrl { url } => {
2166 static MATERIALIZE_COUNTER: AtomicU64 = AtomicU64::new(0);
2167 let unique = MATERIALIZE_COUNTER.fetch_add(1, Ordering::Relaxed);
2168 let nanos = SystemTime::now()
2169 .duration_since(UNIX_EPOCH)
2170 .unwrap()
2171 .as_nanos();
2172 let destination = temp_root.join(format!("plugin-{nanos}-{unique}"));
2173 let output = Command::new("git")
2174 .arg("clone")
2175 .arg("--depth")
2176 .arg("1")
2177 .arg(url)
2178 .arg(&destination)
2179 .output()?;
2180 if !output.status.success() {
2181 return Err(PluginError::CommandFailed(format!(
2182 "git clone failed for `{url}`: {}",
2183 String::from_utf8_lossy(&output.stderr).trim()
2184 )));
2185 }
2186 Ok(destination)
2187 }
2188 }
2189}
2190
2191fn discover_plugin_dirs(root: &Path) -> Result<Vec<PathBuf>, PluginError> {
2192 match fs::read_dir(root) {
2193 Ok(entries) => {
2194 let mut paths = Vec::new();
2195 for entry in entries {
2196 let path = entry?.path();
2197 if path.is_dir() && plugin_manifest_path(&path).is_ok() {
2198 paths.push(path);
2199 }
2200 }
2201 paths.sort();
2202 Ok(paths)
2203 }
2204 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
2205 Err(error) => Err(PluginError::Io(error)),
2206 }
2207}
2208
2209fn plugin_id(name: &str, marketplace: &str) -> String {
2210 format!("{name}@{marketplace}")
2211}
2212
2213fn sanitize_plugin_id(plugin_id: &str) -> String {
2214 plugin_id
2215 .chars()
2216 .map(|ch| match ch {
2217 '/' | '\\' | '@' | ':' => '-',
2218 other => other,
2219 })
2220 .collect()
2221}
2222
2223fn describe_install_source(source: &PluginInstallSource) -> String {
2224 match source {
2225 PluginInstallSource::LocalPath { path } => path.display().to_string(),
2226 PluginInstallSource::GitUrl { url } => url.clone(),
2227 }
2228}
2229
2230fn unix_time_ms() -> u128 {
2231 SystemTime::now()
2232 .duration_since(UNIX_EPOCH)
2233 .expect("time should be after epoch")
2234 .as_millis()
2235}
2236
2237fn copy_dir_all(source: &Path, destination: &Path) -> Result<(), PluginError> {
2238 fs::create_dir_all(destination)?;
2239 for entry in fs::read_dir(source)? {
2240 let entry = entry?;
2241 let target = destination.join(entry.file_name());
2242 if entry.file_type()?.is_dir() {
2243 copy_dir_all(&entry.path(), &target)?;
2244 } else {
2245 fs::copy(entry.path(), target)?;
2246 }
2247 }
2248 Ok(())
2249}
2250
2251fn update_settings_json(
2252 path: &Path,
2253 mut update: impl FnMut(&mut Map<String, Value>),
2254) -> Result<(), PluginError> {
2255 if let Some(parent) = path.parent() {
2256 fs::create_dir_all(parent)?;
2257 }
2258 let mut root = match fs::read_to_string(path) {
2259 Ok(contents) if !contents.trim().is_empty() => serde_json::from_str::<Value>(&contents)?,
2260 Ok(_) => Value::Object(Map::new()),
2261 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Value::Object(Map::new()),
2262 Err(error) => return Err(PluginError::Io(error)),
2263 };
2264
2265 let object = root.as_object_mut().ok_or_else(|| {
2266 PluginError::InvalidManifest(format!(
2267 "settings file {} must contain a JSON object",
2268 path.display()
2269 ))
2270 })?;
2271 update(object);
2272 fs::write(path, serde_json::to_string_pretty(&root)?)?;
2273 Ok(())
2274}
2275
2276fn ensure_object<'a>(root: &'a mut Map<String, Value>, key: &str) -> &'a mut Map<String, Value> {
2277 if !root.get(key).is_some_and(Value::is_object) {
2278 root.insert(key.to_string(), Value::Object(Map::new()));
2279 }
2280 root.get_mut(key)
2281 .and_then(Value::as_object_mut)
2282 .expect("object should exist")
2283}
2284
2285#[cfg(test)]
2288fn env_lock() -> &'static std::sync::Mutex<()> {
2289 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2290 &ENV_LOCK
2291}
2292
2293#[cfg(test)]
2294mod tests {
2295 use super::*;
2296
2297 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
2298 env_lock()
2299 .lock()
2300 .unwrap_or_else(std::sync::PoisonError::into_inner)
2301 }
2302
2303 fn temp_dir(label: &str) -> PathBuf {
2304 let nanos = std::time::SystemTime::now()
2305 .duration_since(std::time::UNIX_EPOCH)
2306 .expect("time should be after epoch")
2307 .as_nanos();
2308 std::env::temp_dir().join(format!("plugins-{label}-{nanos}"))
2309 }
2310
2311 #[test]
2312 fn env_guard_recovers_after_poisoning() {
2313 let poisoned = std::thread::spawn(|| {
2314 let _guard = env_guard();
2315 panic!("poison env lock");
2316 })
2317 .join();
2318 assert!(poisoned.is_err(), "poisoning thread should panic");
2319
2320 let _guard = env_guard();
2321 }
2322
2323 fn write_file(path: &Path, contents: &str) {
2324 if let Some(parent) = path.parent() {
2325 fs::create_dir_all(parent).expect("parent dir");
2326 }
2327 fs::write(path, contents).expect("write file");
2328 }
2329
2330 fn write_loader_plugin(root: &Path) {
2331 write_file(
2332 root.join("hooks").join("pre.sh").as_path(),
2333 "#!/bin/sh\nprintf 'pre'\n",
2334 );
2335 write_file(
2336 root.join("tools").join("echo-tool.sh").as_path(),
2337 "#!/bin/sh\ncat\n",
2338 );
2339 write_file(
2340 root.join("commands").join("sync.sh").as_path(),
2341 "#!/bin/sh\nprintf 'sync'\n",
2342 );
2343 write_file(
2344 root.join(MANIFEST_FILE_NAME).as_path(),
2345 r#"{
2346 "name": "loader-demo",
2347 "version": "1.2.3",
2348 "description": "Manifest loader test plugin",
2349 "permissions": ["read", "write"],
2350 "hooks": {
2351 "PreToolUse": ["./hooks/pre.sh"]
2352 },
2353 "tools": [
2354 {
2355 "name": "echo_tool",
2356 "description": "Echoes JSON input",
2357 "inputSchema": {
2358 "type": "object"
2359 },
2360 "command": "./tools/echo-tool.sh",
2361 "requiredPermission": "workspace-write"
2362 }
2363 ],
2364 "commands": [
2365 {
2366 "name": "sync",
2367 "description": "Sync command",
2368 "command": "./commands/sync.sh"
2369 }
2370 ]
2371}"#,
2372 );
2373 }
2374
2375 fn write_external_plugin(root: &Path, name: &str, version: &str) {
2376 write_file(
2377 root.join("hooks").join("pre.sh").as_path(),
2378 "#!/bin/sh\nprintf 'pre'\n",
2379 );
2380 write_file(
2381 root.join("hooks").join("post.sh").as_path(),
2382 "#!/bin/sh\nprintf 'post'\n",
2383 );
2384 write_file(
2385 root.join(MANIFEST_RELATIVE_PATH).as_path(),
2386 format!(
2387 "{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"test plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/pre.sh\"],\n \"PostToolUse\": [\"./hooks/post.sh\"]\n }}\n}}"
2388 )
2389 .as_str(),
2390 );
2391 }
2392
2393 fn write_broken_plugin(root: &Path, name: &str) {
2394 write_file(
2395 root.join(MANIFEST_RELATIVE_PATH).as_path(),
2396 format!(
2397 "{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"broken plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/missing.sh\"]\n }}\n}}"
2398 )
2399 .as_str(),
2400 );
2401 }
2402
2403 fn write_directory_path_plugin(root: &Path, name: &str) {
2404 fs::create_dir_all(root.join("hooks").join("pre-dir")).expect("hook dir");
2405 fs::create_dir_all(root.join("tools").join("tool-dir")).expect("tool dir");
2406 fs::create_dir_all(root.join("commands").join("sync-dir")).expect("command dir");
2407 fs::create_dir_all(root.join("lifecycle").join("init-dir")).expect("lifecycle dir");
2408 write_file(
2409 root.join(MANIFEST_FILE_NAME).as_path(),
2410 format!(
2411 "{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"directory path plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/pre-dir\"]\n }},\n \"lifecycle\": {{\n \"Init\": [\"./lifecycle/init-dir\"]\n }},\n \"tools\": [\n {{\n \"name\": \"dir_tool\",\n \"description\": \"Directory tool\",\n \"inputSchema\": {{\"type\": \"object\"}},\n \"command\": \"./tools/tool-dir\"\n }}\n ],\n \"commands\": [\n {{\n \"name\": \"sync\",\n \"description\": \"Directory command\",\n \"command\": \"./commands/sync-dir\"\n }}\n ]\n}}"
2412 )
2413 .as_str(),
2414 );
2415 }
2416
2417 fn write_broken_failure_hook_plugin(root: &Path, name: &str) {
2418 write_file(
2419 root.join(MANIFEST_RELATIVE_PATH).as_path(),
2420 format!(
2421 "{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"broken plugin\",\n \"hooks\": {{\n \"PostToolUseFailure\": [\"./hooks/missing-failure.sh\"]\n }}\n}}"
2422 )
2423 .as_str(),
2424 );
2425 }
2426
2427 fn write_lifecycle_plugin(root: &Path, name: &str, version: &str) -> PathBuf {
2428 let log_path = root.join("lifecycle.log");
2429 write_file(
2430 root.join("lifecycle").join("init.sh").as_path(),
2431 "#!/bin/sh\nprintf 'init\\n' >> lifecycle.log\n",
2432 );
2433 write_file(
2434 root.join("lifecycle").join("shutdown.sh").as_path(),
2435 "#!/bin/sh\nprintf 'shutdown\\n' >> lifecycle.log\n",
2436 );
2437 write_file(
2438 root.join(MANIFEST_RELATIVE_PATH).as_path(),
2439 format!(
2440 "{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"lifecycle plugin\",\n \"lifecycle\": {{\n \"Init\": [\"./lifecycle/init.sh\"],\n \"Shutdown\": [\"./lifecycle/shutdown.sh\"]\n }}\n}}"
2441 )
2442 .as_str(),
2443 );
2444 log_path
2445 }
2446
2447 fn write_tool_plugin(root: &Path, name: &str, version: &str) {
2448 write_tool_plugin_with_name(root, name, version, "plugin_echo");
2449 }
2450
2451 fn write_tool_plugin_with_name(root: &Path, name: &str, version: &str, tool_name: &str) {
2452 let script_path = root.join("tools").join("echo-json.sh");
2453 write_file(
2454 &script_path,
2455 "#!/bin/sh\nINPUT=$(cat)\nprintf '{\"plugin\":\"%s\",\"tool\":\"%s\",\"input\":%s}\\n' \"$CLAWD_PLUGIN_ID\" \"$CLAWD_TOOL_NAME\" \"$INPUT\"\n",
2456 );
2457 #[cfg(unix)]
2458 {
2459 use std::os::unix::fs::PermissionsExt;
2460
2461 let mut permissions = fs::metadata(&script_path).expect("metadata").permissions();
2462 permissions.set_mode(0o755);
2463 fs::set_permissions(&script_path, permissions).expect("chmod");
2464 }
2465 write_file(
2466 root.join(MANIFEST_RELATIVE_PATH).as_path(),
2467 format!(
2468 "{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"tool plugin\",\n \"tools\": [\n {{\n \"name\": \"{tool_name}\",\n \"description\": \"Echo JSON input\",\n \"inputSchema\": {{\"type\": \"object\", \"properties\": {{\"message\": {{\"type\": \"string\"}}}}, \"required\": [\"message\"], \"additionalProperties\": false}},\n \"command\": \"./tools/echo-json.sh\",\n \"requiredPermission\": \"workspace-write\"\n }}\n ]\n}}"
2469 )
2470 .as_str(),
2471 );
2472 }
2473
2474 fn write_bundled_plugin(root: &Path, name: &str, version: &str, default_enabled: bool) {
2475 write_file(
2476 root.join(MANIFEST_RELATIVE_PATH).as_path(),
2477 format!(
2478 "{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"bundled plugin\",\n \"defaultEnabled\": {}\n}}",
2479 if default_enabled { "true" } else { "false" }
2480 )
2481 .as_str(),
2482 );
2483 }
2484
2485 fn load_enabled_plugins(path: &Path) -> BTreeMap<String, bool> {
2486 let contents = fs::read_to_string(path).expect("settings should exist");
2487 let root: Value = serde_json::from_str(&contents).expect("settings json");
2488 root.get("enabledPlugins")
2489 .and_then(Value::as_object)
2490 .map(|enabled_plugins| {
2491 enabled_plugins
2492 .iter()
2493 .map(|(plugin_id, value)| {
2494 (
2495 plugin_id.clone(),
2496 value.as_bool().expect("plugin state should be a bool"),
2497 )
2498 })
2499 .collect()
2500 })
2501 .unwrap_or_default()
2502 }
2503
2504 #[test]
2505 fn load_plugin_from_directory_validates_required_fields() {
2506 let _guard = env_guard();
2507 let root = temp_dir("manifest-required");
2508 write_file(
2509 root.join(MANIFEST_FILE_NAME).as_path(),
2510 r#"{"name":"","version":"1.0.0","description":"desc"}"#,
2511 );
2512
2513 let error = load_plugin_from_directory(&root).expect_err("empty name should fail");
2514 assert!(error.to_string().contains("name cannot be empty"));
2515
2516 let _ = fs::remove_dir_all(root);
2517 }
2518
2519 #[test]
2520 fn load_plugin_from_directory_reads_root_manifest_and_validates_entries() {
2521 let _guard = env_guard();
2522 let root = temp_dir("manifest-root");
2523 write_loader_plugin(&root);
2524
2525 let manifest = load_plugin_from_directory(&root).expect("manifest should load");
2526 assert_eq!(manifest.name, "loader-demo");
2527 assert_eq!(manifest.version, "1.2.3");
2528 assert_eq!(
2529 manifest
2530 .permissions
2531 .iter()
2532 .map(|permission| permission.as_str())
2533 .collect::<Vec<_>>(),
2534 vec!["read", "write"]
2535 );
2536 assert_eq!(manifest.hooks.pre_tool_use, vec!["./hooks/pre.sh"]);
2537 assert_eq!(manifest.tools.len(), 1);
2538 assert_eq!(manifest.tools[0].name, "echo_tool");
2539 assert_eq!(
2540 manifest.tools[0].required_permission,
2541 PluginToolPermission::WorkspaceWrite
2542 );
2543 assert_eq!(manifest.commands.len(), 1);
2544 assert_eq!(manifest.commands[0].name, "sync");
2545
2546 let _ = fs::remove_dir_all(root);
2547 }
2548
2549 #[test]
2550 fn load_plugin_from_directory_supports_packaged_manifest_path() {
2551 let _guard = env_guard();
2552 let root = temp_dir("manifest-packaged");
2553 write_external_plugin(&root, "packaged-demo", "1.0.0");
2554
2555 let manifest = load_plugin_from_directory(&root).expect("packaged manifest should load");
2556 assert_eq!(manifest.name, "packaged-demo");
2557 assert!(manifest.tools.is_empty());
2558 assert!(manifest.commands.is_empty());
2559
2560 let _ = fs::remove_dir_all(root);
2561 }
2562
2563 #[test]
2564 fn load_plugin_from_directory_defaults_optional_fields() {
2565 let _guard = env_guard();
2566 let root = temp_dir("manifest-defaults");
2567 write_file(
2568 root.join(MANIFEST_FILE_NAME).as_path(),
2569 r#"{
2570 "name": "minimal",
2571 "version": "0.1.0",
2572 "description": "Minimal manifest"
2573}"#,
2574 );
2575
2576 let manifest = load_plugin_from_directory(&root).expect("minimal manifest should load");
2577 assert!(manifest.permissions.is_empty());
2578 assert!(manifest.hooks.is_empty());
2579 assert!(manifest.tools.is_empty());
2580 assert!(manifest.commands.is_empty());
2581
2582 let _ = fs::remove_dir_all(root);
2583 }
2584
2585 #[test]
2586 fn load_plugin_from_directory_rejects_duplicate_permissions_and_commands() {
2587 let _guard = env_guard();
2588 let root = temp_dir("manifest-duplicates");
2589 write_file(
2590 root.join("commands").join("sync.sh").as_path(),
2591 "#!/bin/sh\nprintf 'sync'\n",
2592 );
2593 write_file(
2594 root.join(MANIFEST_FILE_NAME).as_path(),
2595 r#"{
2596 "name": "duplicate-manifest",
2597 "version": "1.0.0",
2598 "description": "Duplicate validation",
2599 "permissions": ["read", "read"],
2600 "commands": [
2601 {"name": "sync", "description": "Sync one", "command": "./commands/sync.sh"},
2602 {"name": "sync", "description": "Sync two", "command": "./commands/sync.sh"}
2603 ]
2604}"#,
2605 );
2606
2607 let error = load_plugin_from_directory(&root).expect_err("duplicates should fail");
2608 match error {
2609 PluginError::ManifestValidation(errors) => {
2610 assert!(errors.iter().any(|error| matches!(
2611 error,
2612 PluginManifestValidationError::DuplicatePermission { permission }
2613 if permission == "read"
2614 )));
2615 assert!(errors.iter().any(|error| matches!(
2616 error,
2617 PluginManifestValidationError::DuplicateEntry { kind, name }
2618 if *kind == "command" && name == "sync"
2619 )));
2620 }
2621 other => panic!("expected manifest validation errors, got {other}"),
2622 }
2623
2624 let _ = fs::remove_dir_all(root);
2625 }
2626
2627 #[test]
2628 fn load_plugin_from_directory_rejects_claude_code_manifest_contracts_with_guidance() {
2629 let root = temp_dir("manifest-claude-code-contract");
2630 write_file(
2631 root.join(MANIFEST_FILE_NAME).as_path(),
2632 r#"{
2633 "name": "oh-my-claudecode",
2634 "version": "4.10.2",
2635 "description": "Claude Code plugin manifest",
2636 "hooks": {
2637 "SessionStart": ["scripts/session-start.mjs"]
2638 },
2639 "agents": ["agents/*.md"],
2640 "commands": ["commands/**/*.md"],
2641 "skills": "./skills/",
2642 "mcpServers": "./.mcp.json"
2643}"#,
2644 );
2645
2646 let error = load_plugin_from_directory(&root)
2647 .expect_err("Claude Code plugin manifest should fail with guidance");
2648 let rendered = error.to_string();
2649 assert!(rendered.contains("field `skills` uses the Claude Code plugin contract"));
2650 assert!(rendered.contains("field `mcpServers` uses the Claude Code plugin contract"));
2651 assert!(rendered.contains("field `agents` uses the Claude Code plugin contract"));
2652 assert!(rendered.contains("field `commands` uses Claude Code-style directory globs"));
2653 assert!(rendered.contains("hook `SessionStart` uses the Claude Code lifecycle contract"));
2654
2655 let _ = fs::remove_dir_all(root);
2656 }
2657
2658 #[test]
2659 fn load_plugin_from_directory_rejects_missing_tool_or_command_paths() {
2660 let root = temp_dir("manifest-paths");
2661 write_file(
2662 root.join(MANIFEST_FILE_NAME).as_path(),
2663 r#"{
2664 "name": "missing-paths",
2665 "version": "1.0.0",
2666 "description": "Missing path validation",
2667 "tools": [
2668 {
2669 "name": "tool_one",
2670 "description": "Missing tool script",
2671 "inputSchema": {"type": "object"},
2672 "command": "./tools/missing.sh"
2673 }
2674 ]
2675}"#,
2676 );
2677
2678 let error = load_plugin_from_directory(&root).expect_err("missing paths should fail");
2679 assert!(error.to_string().contains("does not exist"));
2680
2681 let _ = fs::remove_dir_all(root);
2682 }
2683
2684 #[test]
2685 fn load_plugin_from_directory_rejects_missing_lifecycle_paths() {
2686 let root = temp_dir("manifest-lifecycle-paths");
2688 write_file(
2689 root.join(MANIFEST_FILE_NAME).as_path(),
2690 r#"{
2691 "name": "missing-lifecycle-paths",
2692 "version": "1.0.0",
2693 "description": "Missing lifecycle path validation",
2694 "lifecycle": {
2695 "Init": ["./lifecycle/init.sh"],
2696 "Shutdown": ["./lifecycle/shutdown.sh"]
2697 }
2698}"#,
2699 );
2700
2701 let error =
2703 load_plugin_from_directory(&root).expect_err("missing lifecycle paths should fail");
2704
2705 match error {
2707 PluginError::ManifestValidation(errors) => {
2708 assert!(errors.iter().any(|error| matches!(
2709 error,
2710 PluginManifestValidationError::MissingPath { kind, path }
2711 if *kind == "lifecycle command"
2712 && path.ends_with(Path::new("lifecycle/init.sh"))
2713 )));
2714 assert!(errors.iter().any(|error| matches!(
2715 error,
2716 PluginManifestValidationError::MissingPath { kind, path }
2717 if *kind == "lifecycle command"
2718 && path.ends_with(Path::new("lifecycle/shutdown.sh"))
2719 )));
2720 }
2721 other => panic!("expected manifest validation errors, got {other}"),
2722 }
2723
2724 let _ = fs::remove_dir_all(root);
2725 }
2726
2727 #[test]
2728 fn load_plugin_from_directory_rejects_directory_command_paths() {
2729 let root = temp_dir("manifest-directory-paths");
2731 write_directory_path_plugin(&root, "directory-paths");
2732
2733 let error =
2735 load_plugin_from_directory(&root).expect_err("directory command paths should fail");
2736
2737 match error {
2739 PluginError::ManifestValidation(errors) => {
2740 assert!(errors.iter().any(|error| matches!(
2741 error,
2742 PluginManifestValidationError::PathIsDirectory { kind, path }
2743 if *kind == "hook" && path.ends_with(Path::new("hooks/pre-dir"))
2744 )));
2745 assert!(errors.iter().any(|error| matches!(
2746 error,
2747 PluginManifestValidationError::PathIsDirectory { kind, path }
2748 if *kind == "lifecycle command"
2749 && path.ends_with(Path::new("lifecycle/init-dir"))
2750 )));
2751 assert!(errors.iter().any(|error| matches!(
2752 error,
2753 PluginManifestValidationError::PathIsDirectory { kind, path }
2754 if *kind == "tool" && path.ends_with(Path::new("tools/tool-dir"))
2755 )));
2756 assert!(errors.iter().any(|error| matches!(
2757 error,
2758 PluginManifestValidationError::PathIsDirectory { kind, path }
2759 if *kind == "command" && path.ends_with(Path::new("commands/sync-dir"))
2760 )));
2761 }
2762 other => panic!("expected manifest validation errors, got {other}"),
2763 }
2764
2765 let _ = fs::remove_dir_all(root);
2766 }
2767
2768 #[test]
2769 fn load_plugin_from_directory_rejects_invalid_permissions() {
2770 let root = temp_dir("manifest-invalid-permissions");
2771 write_file(
2772 root.join(MANIFEST_FILE_NAME).as_path(),
2773 r#"{
2774 "name": "invalid-permissions",
2775 "version": "1.0.0",
2776 "description": "Invalid permission validation",
2777 "permissions": ["admin"]
2778}"#,
2779 );
2780
2781 let error = load_plugin_from_directory(&root).expect_err("invalid permissions should fail");
2782 match error {
2783 PluginError::ManifestValidation(errors) => {
2784 assert!(errors.iter().any(|error| matches!(
2785 error,
2786 PluginManifestValidationError::InvalidPermission { permission }
2787 if permission == "admin"
2788 )));
2789 }
2790 other => panic!("expected manifest validation errors, got {other}"),
2791 }
2792
2793 let _ = fs::remove_dir_all(root);
2794 }
2795
2796 #[test]
2797 fn load_plugin_from_directory_rejects_invalid_tool_required_permission() {
2798 let root = temp_dir("manifest-invalid-tool-permission");
2799 write_file(
2800 root.join("tools").join("echo.sh").as_path(),
2801 "#!/bin/sh\ncat\n",
2802 );
2803 write_file(
2804 root.join(MANIFEST_FILE_NAME).as_path(),
2805 r#"{
2806 "name": "invalid-tool-permission",
2807 "version": "1.0.0",
2808 "description": "Invalid tool permission validation",
2809 "tools": [
2810 {
2811 "name": "echo_tool",
2812 "description": "Echo tool",
2813 "inputSchema": {"type": "object"},
2814 "command": "./tools/echo.sh",
2815 "requiredPermission": "admin"
2816 }
2817 ]
2818}"#,
2819 );
2820
2821 let error =
2822 load_plugin_from_directory(&root).expect_err("invalid tool permission should fail");
2823 match error {
2824 PluginError::ManifestValidation(errors) => {
2825 assert!(errors.iter().any(|error| matches!(
2826 error,
2827 PluginManifestValidationError::InvalidToolRequiredPermission {
2828 tool_name,
2829 permission
2830 } if tool_name == "echo_tool" && permission == "admin"
2831 )));
2832 }
2833 other => panic!("expected manifest validation errors, got {other}"),
2834 }
2835
2836 let _ = fs::remove_dir_all(root);
2837 }
2838
2839 #[test]
2840 fn load_plugin_from_directory_accumulates_multiple_validation_errors() {
2841 let root = temp_dir("manifest-multi-error");
2842 write_file(
2843 root.join(MANIFEST_FILE_NAME).as_path(),
2844 r#"{
2845 "name": "",
2846 "version": "1.0.0",
2847 "description": "",
2848 "permissions": ["admin"],
2849 "commands": [
2850 {"name": "", "description": "", "command": "./commands/missing.sh"}
2851 ]
2852}"#,
2853 );
2854
2855 let error =
2856 load_plugin_from_directory(&root).expect_err("multiple manifest errors should fail");
2857 match error {
2858 PluginError::ManifestValidation(errors) => {
2859 assert!(errors.len() >= 4);
2860 assert!(errors.iter().any(|error| matches!(
2861 error,
2862 PluginManifestValidationError::EmptyField { field } if *field == "name"
2863 )));
2864 assert!(errors.iter().any(|error| matches!(
2865 error,
2866 PluginManifestValidationError::EmptyField { field }
2867 if *field == "description"
2868 )));
2869 assert!(errors.iter().any(|error| matches!(
2870 error,
2871 PluginManifestValidationError::InvalidPermission { permission }
2872 if permission == "admin"
2873 )));
2874 }
2875 other => panic!("expected manifest validation errors, got {other}"),
2876 }
2877
2878 let _ = fs::remove_dir_all(root);
2879 }
2880
2881 #[test]
2882 fn discovers_builtin_and_bundled_plugins() {
2883 let _guard = env_guard();
2884 let manager = PluginManager::new(PluginManagerConfig::new(temp_dir("discover")));
2885 let plugins = manager.list_plugins().expect("plugins should list");
2886 assert!(plugins
2887 .iter()
2888 .any(|plugin| plugin.metadata.kind == PluginKind::Builtin));
2889 assert!(plugins
2890 .iter()
2891 .any(|plugin| plugin.metadata.kind == PluginKind::Bundled));
2892 }
2893
2894 #[test]
2895 fn installs_enables_updates_and_uninstalls_external_plugins() {
2896 let _guard = env_guard();
2897 let config_home = temp_dir("home");
2898 let source_root = temp_dir("source");
2899 write_external_plugin(&source_root, "demo", "1.0.0");
2900
2901 let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
2902 let install = manager
2903 .install(source_root.to_str().expect("utf8 path"))
2904 .expect("install should succeed");
2905 assert_eq!(install.plugin_id, "demo@external");
2906 assert!(manager
2907 .list_plugins()
2908 .expect("list plugins")
2909 .iter()
2910 .any(|plugin| plugin.metadata.id == "demo@external" && plugin.enabled));
2911
2912 let hooks = manager.aggregated_hooks().expect("hooks should aggregate");
2913 assert_eq!(hooks.pre_tool_use.len(), 1);
2914 assert!(hooks.pre_tool_use[0].contains("pre.sh"));
2915
2916 manager
2917 .disable("demo@external")
2918 .expect("disable should work");
2919 assert!(manager
2920 .aggregated_hooks()
2921 .expect("hooks after disable")
2922 .is_empty());
2923 manager.enable("demo@external").expect("enable should work");
2924
2925 write_external_plugin(&source_root, "demo", "2.0.0");
2926 let update = manager.update("demo@external").expect("update should work");
2927 assert_eq!(update.old_version, "1.0.0");
2928 assert_eq!(update.new_version, "2.0.0");
2929
2930 manager
2931 .uninstall("demo@external")
2932 .expect("uninstall should work");
2933 assert!(!manager
2934 .list_plugins()
2935 .expect("list plugins")
2936 .iter()
2937 .any(|plugin| plugin.metadata.id == "demo@external"));
2938
2939 let _ = fs::remove_dir_all(config_home);
2940 let _ = fs::remove_dir_all(source_root);
2941 }
2942
2943 #[test]
2944 fn auto_installs_bundled_plugins_into_the_registry() {
2945 let _guard = env_guard();
2946 let config_home = temp_dir("bundled-home");
2947 let bundled_root = temp_dir("bundled-root");
2948 write_bundled_plugin(&bundled_root.join("starter"), "starter", "0.1.0", false);
2949
2950 let mut config = PluginManagerConfig::new(&config_home);
2951 config.bundled_root = Some(bundled_root.clone());
2952 let manager = PluginManager::new(config);
2953
2954 let installed = manager
2955 .list_installed_plugins()
2956 .expect("bundled plugins should auto-install");
2957 assert!(installed.iter().any(|plugin| {
2958 plugin.metadata.id == "starter@bundled"
2959 && plugin.metadata.kind == PluginKind::Bundled
2960 && !plugin.enabled
2961 }));
2962
2963 let registry = manager.load_registry().expect("registry should exist");
2964 let record = registry
2965 .plugins
2966 .get("starter@bundled")
2967 .expect("bundled plugin should be recorded");
2968 assert_eq!(record.kind, PluginKind::Bundled);
2969 assert!(record.install_path.exists());
2970
2971 let _ = fs::remove_dir_all(config_home);
2972 let _ = fs::remove_dir_all(bundled_root);
2973 }
2974
2975 #[test]
2976 fn default_bundled_root_loads_repo_bundles_as_installed_plugins() {
2977 let _guard = env_guard();
2978 let config_home = temp_dir("default-bundled-home");
2979 let manager = PluginManager::new(PluginManagerConfig::new(&config_home));
2980
2981 let installed = manager
2982 .list_installed_plugins()
2983 .expect("default bundled plugins should auto-install");
2984 assert!(installed
2985 .iter()
2986 .any(|plugin| plugin.metadata.id == "example-bundled@bundled"));
2987 assert!(installed
2988 .iter()
2989 .any(|plugin| plugin.metadata.id == "sample-hooks@bundled"));
2990
2991 let _ = fs::remove_dir_all(config_home);
2992 }
2993
2994 #[test]
2995 fn bundled_sync_prunes_removed_bundled_registry_entries() {
2996 let _guard = env_guard();
2997 let config_home = temp_dir("bundled-prune-home");
2998 let bundled_root = temp_dir("bundled-prune-root");
2999 let stale_install_path = config_home
3000 .join("plugins")
3001 .join("installed")
3002 .join("stale-bundled-external");
3003 write_bundled_plugin(&bundled_root.join("active"), "active", "0.1.0", false);
3004 write_file(
3005 stale_install_path.join(MANIFEST_RELATIVE_PATH).as_path(),
3006 r#"{
3007 "name": "stale",
3008 "version": "0.1.0",
3009 "description": "stale bundled plugin"
3010}"#,
3011 );
3012
3013 let mut config = PluginManagerConfig::new(&config_home);
3014 config.bundled_root = Some(bundled_root.clone());
3015 config.install_root = Some(config_home.join("plugins").join("installed"));
3016 let manager = PluginManager::new(config);
3017
3018 let mut registry = InstalledPluginRegistry::default();
3019 registry.plugins.insert(
3020 "stale@bundled".to_string(),
3021 InstalledPluginRecord {
3022 kind: PluginKind::Bundled,
3023 id: "stale@bundled".to_string(),
3024 name: "stale".to_string(),
3025 version: "0.1.0".to_string(),
3026 description: "stale bundled plugin".to_string(),
3027 install_path: stale_install_path.clone(),
3028 source: PluginInstallSource::LocalPath {
3029 path: bundled_root.join("stale"),
3030 },
3031 installed_at_unix_ms: 1,
3032 updated_at_unix_ms: 1,
3033 },
3034 );
3035 manager.store_registry(®istry).expect("store registry");
3036 manager
3037 .write_enabled_state("stale@bundled", Some(true))
3038 .expect("seed bundled enabled state");
3039
3040 let installed = manager
3041 .list_installed_plugins()
3042 .expect("bundled sync should succeed");
3043 assert!(installed
3044 .iter()
3045 .any(|plugin| plugin.metadata.id == "active@bundled"));
3046 assert!(!installed
3047 .iter()
3048 .any(|plugin| plugin.metadata.id == "stale@bundled"));
3049
3050 let registry = manager.load_registry().expect("load registry");
3051 assert!(!registry.plugins.contains_key("stale@bundled"));
3052 assert!(!stale_install_path.exists());
3053
3054 let _ = fs::remove_dir_all(config_home);
3055 let _ = fs::remove_dir_all(bundled_root);
3056 }
3057
3058 #[test]
3059 fn installed_plugin_discovery_keeps_registry_entries_outside_install_root() {
3060 let _guard = env_guard();
3061 let config_home = temp_dir("registry-fallback-home");
3062 let bundled_root = temp_dir("registry-fallback-bundled");
3063 let install_root = config_home.join("plugins").join("installed");
3064 let external_install_path = temp_dir("registry-fallback-external");
3065 write_file(
3066 external_install_path.join(MANIFEST_FILE_NAME).as_path(),
3067 r#"{
3068 "name": "registry-fallback",
3069 "version": "1.0.0",
3070 "description": "Registry fallback plugin"
3071}"#,
3072 );
3073
3074 let mut config = PluginManagerConfig::new(&config_home);
3075 config.bundled_root = Some(bundled_root.clone());
3076 config.install_root = Some(install_root.clone());
3077 let manager = PluginManager::new(config);
3078
3079 let mut registry = InstalledPluginRegistry::default();
3080 registry.plugins.insert(
3081 "registry-fallback@external".to_string(),
3082 InstalledPluginRecord {
3083 kind: PluginKind::External,
3084 id: "registry-fallback@external".to_string(),
3085 name: "registry-fallback".to_string(),
3086 version: "1.0.0".to_string(),
3087 description: "Registry fallback plugin".to_string(),
3088 install_path: external_install_path.clone(),
3089 source: PluginInstallSource::LocalPath {
3090 path: external_install_path.clone(),
3091 },
3092 installed_at_unix_ms: 1,
3093 updated_at_unix_ms: 1,
3094 },
3095 );
3096 manager.store_registry(®istry).expect("store registry");
3097 manager
3098 .write_enabled_state("stale-external@external", Some(true))
3099 .expect("seed stale external enabled state");
3100
3101 let installed = manager
3102 .list_installed_plugins()
3103 .expect("registry fallback plugin should load");
3104 assert!(installed
3105 .iter()
3106 .any(|plugin| plugin.metadata.id == "registry-fallback@external"));
3107
3108 let _ = fs::remove_dir_all(config_home);
3109 let _ = fs::remove_dir_all(bundled_root);
3110 let _ = fs::remove_dir_all(external_install_path);
3111 }
3112
3113 #[test]
3114 fn installed_plugin_discovery_prunes_stale_registry_entries() {
3115 let _guard = env_guard();
3116 let config_home = temp_dir("registry-prune-home");
3117 let bundled_root = temp_dir("registry-prune-bundled");
3118 let install_root = config_home.join("plugins").join("installed");
3119 let missing_install_path = temp_dir("registry-prune-missing");
3120
3121 let mut config = PluginManagerConfig::new(&config_home);
3122 config.bundled_root = Some(bundled_root.clone());
3123 config.install_root = Some(install_root);
3124 let manager = PluginManager::new(config);
3125
3126 let mut registry = InstalledPluginRegistry::default();
3127 registry.plugins.insert(
3128 "stale-external@external".to_string(),
3129 InstalledPluginRecord {
3130 kind: PluginKind::External,
3131 id: "stale-external@external".to_string(),
3132 name: "stale-external".to_string(),
3133 version: "1.0.0".to_string(),
3134 description: "stale external plugin".to_string(),
3135 install_path: missing_install_path.clone(),
3136 source: PluginInstallSource::LocalPath {
3137 path: missing_install_path.clone(),
3138 },
3139 installed_at_unix_ms: 1,
3140 updated_at_unix_ms: 1,
3141 },
3142 );
3143 manager.store_registry(®istry).expect("store registry");
3144
3145 let installed = manager
3146 .list_installed_plugins()
3147 .expect("stale registry entries should be pruned");
3148 assert!(!installed
3149 .iter()
3150 .any(|plugin| plugin.metadata.id == "stale-external@external"));
3151
3152 let registry = manager.load_registry().expect("load registry");
3153 assert!(!registry.plugins.contains_key("stale-external@external"));
3154
3155 let _ = fs::remove_dir_all(config_home);
3156 let _ = fs::remove_dir_all(bundled_root);
3157 }
3158
3159 #[test]
3160 fn persists_bundled_plugin_enable_state_across_reloads() {
3161 let _guard = env_guard();
3162 let config_home = temp_dir("bundled-state-home");
3163 let bundled_root = temp_dir("bundled-state-root");
3164 write_bundled_plugin(&bundled_root.join("starter"), "starter", "0.1.0", false);
3165
3166 let mut config = PluginManagerConfig::new(&config_home);
3167 config.bundled_root = Some(bundled_root.clone());
3168 let mut manager = PluginManager::new(config.clone());
3169
3170 manager
3171 .enable("starter@bundled")
3172 .expect("enable bundled plugin should succeed");
3173 assert_eq!(
3174 load_enabled_plugins(&manager.settings_path()).get("starter@bundled"),
3175 Some(&true)
3176 );
3177
3178 let mut reloaded_config = PluginManagerConfig::new(&config_home);
3179 reloaded_config.bundled_root = Some(bundled_root.clone());
3180 reloaded_config.enabled_plugins = load_enabled_plugins(&manager.settings_path());
3181 let reloaded_manager = PluginManager::new(reloaded_config);
3182 let reloaded = reloaded_manager
3183 .list_installed_plugins()
3184 .expect("bundled plugins should still be listed");
3185 assert!(reloaded
3186 .iter()
3187 .any(|plugin| { plugin.metadata.id == "starter@bundled" && plugin.enabled }));
3188
3189 let _ = fs::remove_dir_all(config_home);
3190 let _ = fs::remove_dir_all(bundled_root);
3191 }
3192
3193 #[test]
3194 fn persists_bundled_plugin_disable_state_across_reloads() {
3195 let _guard = env_guard();
3196 let config_home = temp_dir("bundled-disabled-home");
3197 let bundled_root = temp_dir("bundled-disabled-root");
3198 write_bundled_plugin(&bundled_root.join("starter"), "starter", "0.1.0", true);
3199
3200 let mut config = PluginManagerConfig::new(&config_home);
3201 config.bundled_root = Some(bundled_root.clone());
3202 let mut manager = PluginManager::new(config);
3203
3204 manager
3205 .disable("starter@bundled")
3206 .expect("disable bundled plugin should succeed");
3207 assert_eq!(
3208 load_enabled_plugins(&manager.settings_path()).get("starter@bundled"),
3209 Some(&false)
3210 );
3211
3212 let mut reloaded_config = PluginManagerConfig::new(&config_home);
3213 reloaded_config.bundled_root = Some(bundled_root.clone());
3214 reloaded_config.enabled_plugins = load_enabled_plugins(&manager.settings_path());
3215 let reloaded_manager = PluginManager::new(reloaded_config);
3216 let reloaded = reloaded_manager
3217 .list_installed_plugins()
3218 .expect("bundled plugins should still be listed");
3219 assert!(reloaded
3220 .iter()
3221 .any(|plugin| { plugin.metadata.id == "starter@bundled" && !plugin.enabled }));
3222
3223 let _ = fs::remove_dir_all(config_home);
3224 let _ = fs::remove_dir_all(bundled_root);
3225 }
3226
3227 #[test]
3228 fn validates_plugin_source_before_install() {
3229 let _guard = env_guard();
3230 let config_home = temp_dir("validate-home");
3231 let source_root = temp_dir("validate-source");
3232 write_external_plugin(&source_root, "validator", "1.0.0");
3233 let manager = PluginManager::new(PluginManagerConfig::new(&config_home));
3234 let manifest = manager
3235 .validate_plugin_source(source_root.to_str().expect("utf8 path"))
3236 .expect("manifest should validate");
3237 assert_eq!(manifest.name, "validator");
3238 let _ = fs::remove_dir_all(config_home);
3239 let _ = fs::remove_dir_all(source_root);
3240 }
3241
3242 #[test]
3243 fn plugin_registry_tracks_enabled_state_and_lookup() {
3244 let _guard = env_guard();
3245 let config_home = temp_dir("registry-home");
3246 let source_root = temp_dir("registry-source");
3247 write_external_plugin(&source_root, "registry-demo", "1.0.0");
3248
3249 let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
3250 manager
3251 .install(source_root.to_str().expect("utf8 path"))
3252 .expect("install should succeed");
3253 manager
3254 .disable("registry-demo@external")
3255 .expect("disable should succeed");
3256
3257 let registry = manager.plugin_registry().expect("registry should build");
3258 let plugin = registry
3259 .get("registry-demo@external")
3260 .expect("installed plugin should be discoverable");
3261 assert_eq!(plugin.metadata().name, "registry-demo");
3262 assert!(!plugin.is_enabled());
3263 assert!(registry.contains("registry-demo@external"));
3264 assert!(!registry.contains("missing@external"));
3265
3266 let _ = fs::remove_dir_all(config_home);
3267 let _ = fs::remove_dir_all(source_root);
3268 }
3269
3270 #[test]
3271 fn plugin_registry_report_collects_load_failures_without_dropping_valid_plugins() {
3272 let _guard = env_guard();
3273 let config_home = temp_dir("report-home");
3275 let external_root = temp_dir("report-external");
3276 write_external_plugin(&external_root.join("valid"), "valid-report", "1.0.0");
3277 write_broken_plugin(&external_root.join("broken"), "broken-report");
3278
3279 let mut config = PluginManagerConfig::new(&config_home);
3280 config.external_dirs = vec![external_root.clone()];
3281 let manager = PluginManager::new(config);
3282
3283 let report = manager
3285 .plugin_registry_report()
3286 .expect("report should tolerate invalid external plugins");
3287
3288 assert!(report.registry().contains("valid-report@external"));
3290 assert_eq!(report.failures().len(), 1);
3291 assert_eq!(report.failures()[0].kind, PluginKind::External);
3292 assert!(report.failures()[0]
3293 .plugin_root
3294 .ends_with(Path::new("broken")));
3295 assert!(report.failures()[0]
3296 .error()
3297 .to_string()
3298 .contains("does not exist"));
3299
3300 let error = manager
3301 .plugin_registry()
3302 .expect_err("strict registry should surface load failures");
3303 match error {
3304 PluginError::LoadFailures(failures) => {
3305 assert_eq!(failures.len(), 1);
3306 assert!(failures[0].plugin_root.ends_with(Path::new("broken")));
3307 }
3308 other => panic!("expected load failures, got {other}"),
3309 }
3310
3311 let _ = fs::remove_dir_all(config_home);
3312 let _ = fs::remove_dir_all(external_root);
3313 }
3314
3315 #[test]
3316 fn installed_plugin_registry_report_collects_load_failures_from_install_root() {
3317 let _guard = env_guard();
3318 let config_home = temp_dir("installed-report-home");
3320 let bundled_root = temp_dir("installed-report-bundled");
3321 let install_root = config_home.join("plugins").join("installed");
3322 write_external_plugin(&install_root.join("valid"), "installed-valid", "1.0.0");
3323 write_broken_plugin(&install_root.join("broken"), "installed-broken");
3324
3325 let mut config = PluginManagerConfig::new(&config_home);
3326 config.bundled_root = Some(bundled_root.clone());
3327 config.install_root = Some(install_root);
3328 let manager = PluginManager::new(config);
3329
3330 let report = manager
3332 .installed_plugin_registry_report()
3333 .expect("installed report should tolerate invalid installed plugins");
3334
3335 assert!(report.registry().contains("installed-valid@external"));
3337 assert_eq!(report.failures().len(), 1);
3338 assert!(report.failures()[0]
3339 .plugin_root
3340 .ends_with(Path::new("broken")));
3341
3342 let _ = fs::remove_dir_all(config_home);
3343 let _ = fs::remove_dir_all(bundled_root);
3344 }
3345
3346 #[test]
3347 fn rejects_plugin_sources_with_missing_hook_paths() {
3348 let _guard = env_guard();
3349 let config_home = temp_dir("broken-home");
3351 let source_root = temp_dir("broken-source");
3352 write_broken_plugin(&source_root, "broken");
3353
3354 let manager = PluginManager::new(PluginManagerConfig::new(&config_home));
3355
3356 let error = manager
3358 .validate_plugin_source(source_root.to_str().expect("utf8 path"))
3359 .expect_err("missing hook file should fail validation");
3360
3361 assert!(error.to_string().contains("does not exist"));
3363
3364 let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
3365 let install_error = manager
3366 .install(source_root.to_str().expect("utf8 path"))
3367 .expect_err("install should reject invalid hook paths");
3368 assert!(install_error.to_string().contains("does not exist"));
3369
3370 let _ = fs::remove_dir_all(config_home);
3371 let _ = fs::remove_dir_all(source_root);
3372 }
3373
3374 #[test]
3375 fn rejects_plugin_sources_with_missing_failure_hook_paths() {
3376 let _guard = env_guard();
3377 let config_home = temp_dir("broken-failure-home");
3379 let source_root = temp_dir("broken-failure-source");
3380 write_broken_failure_hook_plugin(&source_root, "broken-failure");
3381
3382 let manager = PluginManager::new(PluginManagerConfig::new(&config_home));
3383
3384 let error = manager
3386 .validate_plugin_source(source_root.to_str().expect("utf8 path"))
3387 .expect_err("missing failure hook file should fail validation");
3388
3389 assert!(error.to_string().contains("does not exist"));
3391
3392 let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
3393 let install_error = manager
3394 .install(source_root.to_str().expect("utf8 path"))
3395 .expect_err("install should reject invalid failure hook paths");
3396 assert!(install_error.to_string().contains("does not exist"));
3397
3398 let _ = fs::remove_dir_all(config_home);
3399 let _ = fs::remove_dir_all(source_root);
3400 }
3401
3402 #[test]
3403 fn plugin_registry_runs_initialize_and_shutdown_for_enabled_plugins() {
3404 let _guard = env_guard();
3405 let config_home = temp_dir("lifecycle-home");
3406 let source_root = temp_dir("lifecycle-source");
3407 let _ = write_lifecycle_plugin(&source_root, "lifecycle-demo", "1.0.0");
3408
3409 let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
3410 let install = manager
3411 .install(source_root.to_str().expect("utf8 path"))
3412 .expect("install should succeed");
3413 let log_path = install.install_path.join("lifecycle.log");
3414
3415 let registry = manager.plugin_registry().expect("registry should build");
3416 registry.initialize().expect("init should succeed");
3417 registry.shutdown().expect("shutdown should succeed");
3418
3419 let log = fs::read_to_string(&log_path).expect("lifecycle log should exist");
3420 assert_eq!(log, "init\nshutdown\n");
3421
3422 let _ = fs::remove_dir_all(config_home);
3423 let _ = fs::remove_dir_all(source_root);
3424 }
3425
3426 #[test]
3427 fn aggregates_and_executes_plugin_tools() {
3428 let _guard = env_guard();
3429 let config_home = temp_dir("tool-home");
3430 let source_root = temp_dir("tool-source");
3431 write_tool_plugin(&source_root, "tool-demo", "1.0.0");
3432
3433 let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
3434 manager
3435 .install(source_root.to_str().expect("utf8 path"))
3436 .expect("install should succeed");
3437
3438 let tools = manager.aggregated_tools().expect("tools should aggregate");
3439 assert_eq!(tools.len(), 1);
3440 assert_eq!(tools[0].definition().name, "plugin_echo");
3441 assert_eq!(tools[0].required_permission(), "workspace-write");
3442
3443 let output = tools[0]
3444 .execute(&serde_json::json!({ "message": "hello" }))
3445 .expect("plugin tool should execute");
3446 let payload: Value = serde_json::from_str(&output).expect("valid json");
3447 assert_eq!(payload["plugin"], "tool-demo@external");
3448 assert_eq!(payload["tool"], "plugin_echo");
3449 assert_eq!(payload["input"]["message"], "hello");
3450
3451 let _ = fs::remove_dir_all(config_home);
3452 let _ = fs::remove_dir_all(source_root);
3453 }
3454
3455 #[test]
3456 fn list_installed_plugins_scans_install_root_without_registry_entries() {
3457 let _guard = env_guard();
3458 let config_home = temp_dir("installed-scan-home");
3459 let bundled_root = temp_dir("installed-scan-bundled");
3460 let install_root = config_home.join("plugins").join("installed");
3461 let installed_plugin_root = install_root.join("scan-demo");
3462 write_file(
3463 installed_plugin_root.join(MANIFEST_FILE_NAME).as_path(),
3464 r#"{
3465 "name": "scan-demo",
3466 "version": "1.0.0",
3467 "description": "Scanned from install root"
3468}"#,
3469 );
3470
3471 let mut config = PluginManagerConfig::new(&config_home);
3472 config.bundled_root = Some(bundled_root.clone());
3473 config.install_root = Some(install_root);
3474 let manager = PluginManager::new(config);
3475
3476 let installed = manager
3477 .list_installed_plugins()
3478 .expect("installed plugins should scan directories");
3479 assert!(installed
3480 .iter()
3481 .any(|plugin| plugin.metadata.id == "scan-demo@external"));
3482
3483 let _ = fs::remove_dir_all(config_home);
3484 let _ = fs::remove_dir_all(bundled_root);
3485 }
3486
3487 #[test]
3488 fn list_installed_plugins_scans_packaged_manifests_in_install_root() {
3489 let _guard = env_guard();
3490 let config_home = temp_dir("installed-packaged-scan-home");
3491 let bundled_root = temp_dir("installed-packaged-scan-bundled");
3492 let install_root = config_home.join("plugins").join("installed");
3493 let installed_plugin_root = install_root.join("scan-packaged");
3494 write_file(
3495 installed_plugin_root.join(MANIFEST_RELATIVE_PATH).as_path(),
3496 r#"{
3497 "name": "scan-packaged",
3498 "version": "1.0.0",
3499 "description": "Packaged manifest in install root"
3500}"#,
3501 );
3502
3503 let mut config = PluginManagerConfig::new(&config_home);
3504 config.bundled_root = Some(bundled_root.clone());
3505 config.install_root = Some(install_root);
3506 let manager = PluginManager::new(config);
3507
3508 let installed = manager
3509 .list_installed_plugins()
3510 .expect("installed plugins should scan packaged manifests");
3511 assert!(installed
3512 .iter()
3513 .any(|plugin| plugin.metadata.id == "scan-packaged@external"));
3514
3515 let _ = fs::remove_dir_all(config_home);
3516 let _ = fs::remove_dir_all(bundled_root);
3517 }
3518
3519 #[test]
3522 fn claw_config_home_isolation_prevents_host_plugin_leakage() {
3523 let _guard = env_guard();
3524
3525 let config_home = temp_dir("isolated-home");
3527 let bundled_root = temp_dir("isolated-bundled");
3528
3529 std::env::set_var("CLAW_CONFIG_HOME", &config_home);
3531
3532 let install_root = config_home.join("plugins").join("installed");
3534 let fixture_plugin_root = install_root.join("isolated-test-plugin");
3535 write_file(
3536 fixture_plugin_root.join(MANIFEST_RELATIVE_PATH).as_path(),
3537 r#"{
3538 "name": "isolated-test-plugin",
3539 "version": "1.0.0",
3540 "description": "Test fixture plugin in isolated config home"
3541}"#,
3542 );
3543
3544 let mut config = PluginManagerConfig::new(&config_home);
3546 config.bundled_root = Some(bundled_root.clone());
3547 let manager = PluginManager::new(config);
3548
3549 let installed = manager
3551 .list_installed_plugins()
3552 .expect("installed plugins should list");
3553
3554 assert_eq!(
3556 installed.len(),
3557 1,
3558 "should only see the test fixture plugin, not host ~/.claw/plugins/"
3559 );
3560 assert_eq!(
3561 installed[0].metadata.id, "isolated-test-plugin@external",
3562 "should see the test fixture plugin"
3563 );
3564
3565 std::env::remove_var("CLAW_CONFIG_HOME");
3567 let _ = fs::remove_dir_all(config_home);
3568 let _ = fs::remove_dir_all(bundled_root);
3569 }
3570
3571 #[test]
3572 fn plugin_lifecycle_handles_parallel_execution() {
3573 use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
3574 use std::sync::Arc;
3575 use std::thread;
3576
3577 let _guard = env_guard();
3578
3579 let base_dir = temp_dir("parallel-base");
3581
3582 let success_count = Arc::new(AtomicUsize::new(0));
3584 let error_count = Arc::new(AtomicUsize::new(0));
3585
3586 let mut handles = Vec::new();
3588 for thread_id in 0..5 {
3589 let base_dir = base_dir.clone();
3590 let success_count = Arc::clone(&success_count);
3591 let error_count = Arc::clone(&error_count);
3592
3593 let handle = thread::spawn(move || {
3594 let config_home = base_dir.join(format!("config-{thread_id}"));
3596 let source_root = base_dir.join(format!("source-{thread_id}"));
3597
3598 let _log_path =
3600 write_lifecycle_plugin(&source_root, &format!("parallel-{thread_id}"), "1.0.0");
3601
3602 let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
3604 let install_result = manager.install(source_root.to_str().expect("utf8 path"));
3605
3606 match install_result {
3607 Ok(install) => {
3608 let log_path = install.install_path.join("lifecycle.log");
3609
3610 let registry = manager.plugin_registry();
3612 match registry {
3613 Ok(registry) => {
3614 if registry.initialize().is_ok() && registry.shutdown().is_ok() {
3615 if let Ok(log) = fs::read_to_string(&log_path) {
3617 if log == "init\nshutdown\n" {
3618 success_count.fetch_add(1, AtomicOrdering::Relaxed);
3619 }
3620 }
3621 }
3622 }
3623 Err(_) => {
3624 error_count.fetch_add(1, AtomicOrdering::Relaxed);
3625 }
3626 }
3627 }
3628 Err(_) => {
3629 error_count.fetch_add(1, AtomicOrdering::Relaxed);
3630 }
3631 }
3632 });
3633 handles.push(handle);
3634 }
3635
3636 for handle in handles {
3638 handle.join().expect("thread should complete");
3639 }
3640
3641 let successes = success_count.load(AtomicOrdering::Relaxed);
3643 let errors = error_count.load(AtomicOrdering::Relaxed);
3644
3645 assert_eq!(
3646 successes, 5,
3647 "all 5 parallel plugin installations should succeed"
3648 );
3649 assert_eq!(
3650 errors, 0,
3651 "no errors should occur during parallel execution"
3652 );
3653
3654 let _ = fs::remove_dir_all(base_dir);
3656 }
3657}