Skip to main content

ninmu_plugins/
lib.rs

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(&registry)?;
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(&registry)?;
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(&registry)?;
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(&registry)?;
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(&registry)?;
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/// Environment variable lock for test isolation.
2286/// Guards against concurrent modification of `CLAW_CONFIG_HOME`.
2287#[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        // given
2687        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        // when
2702        let error =
2703            load_plugin_from_directory(&root).expect_err("missing lifecycle paths should fail");
2704
2705        // then
2706        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        // given
2730        let root = temp_dir("manifest-directory-paths");
2731        write_directory_path_plugin(&root, "directory-paths");
2732
2733        // when
2734        let error =
2735            load_plugin_from_directory(&root).expect_err("directory command paths should fail");
2736
2737        // then
2738        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(&registry).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(&registry).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(&registry).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        // given
3274        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        // when
3284        let report = manager
3285            .plugin_registry_report()
3286            .expect("report should tolerate invalid external plugins");
3287
3288        // then
3289        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        // given
3319        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        // when
3331        let report = manager
3332            .installed_plugin_registry_report()
3333            .expect("installed report should tolerate invalid installed plugins");
3334
3335        // then
3336        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        // given
3350        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        // when
3357        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        // then
3362        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        // given
3378        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        // when
3385        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        // then
3390        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    /// Regression test for ROADMAP #41: verify that `CLAW_CONFIG_HOME` isolation prevents
3520    /// host `~/.claw/plugins/` from bleeding into test runs.
3521    #[test]
3522    fn claw_config_home_isolation_prevents_host_plugin_leakage() {
3523        let _guard = env_guard();
3524
3525        // Create a temp directory to act as our isolated CLAW_CONFIG_HOME
3526        let config_home = temp_dir("isolated-home");
3527        let bundled_root = temp_dir("isolated-bundled");
3528
3529        // Set CLAW_CONFIG_HOME to our temp directory
3530        std::env::set_var("CLAW_CONFIG_HOME", &config_home);
3531
3532        // Create a test fixture plugin in the isolated config home
3533        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        // Create PluginManager with isolated bundled_root - it should use the temp config_home, not host ~/.claw/
3545        let mut config = PluginManagerConfig::new(&config_home);
3546        config.bundled_root = Some(bundled_root.clone());
3547        let manager = PluginManager::new(config);
3548
3549        // List installed plugins - should only see the test fixture, not host plugins
3550        let installed = manager
3551            .list_installed_plugins()
3552            .expect("installed plugins should list");
3553
3554        // Verify we only see the test fixture plugin
3555        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        // Cleanup
3566        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        // Shared base directory for all threads
3580        let base_dir = temp_dir("parallel-base");
3581
3582        // Track successful installations and any errors
3583        let success_count = Arc::new(AtomicUsize::new(0));
3584        let error_count = Arc::new(AtomicUsize::new(0));
3585
3586        // Spawn multiple threads to install plugins simultaneously
3587        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                // Create unique directories for this thread
3595                let config_home = base_dir.join(format!("config-{thread_id}"));
3596                let source_root = base_dir.join(format!("source-{thread_id}"));
3597
3598                // Write lifecycle plugin for this thread
3599                let _log_path =
3600                    write_lifecycle_plugin(&source_root, &format!("parallel-{thread_id}"), "1.0.0");
3601
3602                // Create PluginManager and install
3603                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                        // Initialize and shutdown the registry to trigger lifecycle hooks
3611                        let registry = manager.plugin_registry();
3612                        match registry {
3613                            Ok(registry) => {
3614                                if registry.initialize().is_ok() && registry.shutdown().is_ok() {
3615                                    // Verify lifecycle.log exists and has expected content
3616                                    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        // Wait for all threads to complete
3637        for handle in handles {
3638            handle.join().expect("thread should complete");
3639        }
3640
3641        // Verify all threads succeeded without collisions
3642        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        // Cleanup
3655        let _ = fs::remove_dir_all(base_dir);
3656    }
3657}