pub struct PluginManager { /* private fields */ }Expand description
Manages plugin lifecycle: install, remove, list.
All operations are synchronous. Plugin watchers and agent config overlays are applied separately by the agent bootstrap layer.
Implementations§
Source§impl PluginManager
impl PluginManager
Sourcepub fn default_plugins_dir() -> PathBuf
pub fn default_plugins_dir() -> PathBuf
Returns the canonical default plugins directory: ~/.local/share/zeph/plugins/.
Both the CLI and TUI must use this helper so they always point to the same directory.
Sourcepub fn new(
plugins_dir: PathBuf,
managed_skills_dir: PathBuf,
mcp_allowed_commands: Vec<String>,
base_allowed_commands: Vec<String>,
) -> Self
pub fn new( plugins_dir: PathBuf, managed_skills_dir: PathBuf, mcp_allowed_commands: Vec<String>, base_allowed_commands: Vec<String>, ) -> Self
Create a new manager.
§Parameters
plugins_dir— root installation directory for plugins.managed_skills_dir— directory for user-managed skills (conflict detection).mcp_allowed_commands— allowlist for MCP server commands from agent config.base_allowed_commands— host’stools.shell.allowed_commands. Used to emit a non-fatal warning when a plugin overlay would be silently dropped at load time (tighten-only invariant).
Sourcepub fn with_download_timeout_secs(self, secs: u64) -> Self
pub fn with_download_timeout_secs(self, secs: u64) -> Self
Override the HTTP download timeout used by Self::add_remote.
Each phase (connect and body read) is independently bounded by this value. The default is 30 seconds.
Sourcepub fn add(&self, source: &str) -> Result<AddResult, PluginError>
pub fn add(&self, source: &str) -> Result<AddResult, PluginError>
Install a plugin from a local directory path.
§Errors
Returns PluginError if the manifest is invalid, the source cannot be read,
there are skill name conflicts, MCP commands are not allowlisted, or config
overlay keys are not in the tighten-only safelist.
Sourcepub async fn add_remote(
&self,
url: &str,
expected_sha256: Option<&str>,
) -> Result<AddResult, PluginError>
pub async fn add_remote( &self, url: &str, expected_sha256: Option<&str>, ) -> Result<AddResult, PluginError>
Download and install a plugin from a remote URL with optional SHA-256 integrity pinning.
Downloads the archive at url, verifies its SHA-256 digest against expected_sha256
(when provided), extracts it to a temporary directory, and delegates to Self::add.
§Integrity check
When expected_sha256 is Some, the raw archive bytes are hashed with SHA-256 and
compared against the expected lowercase hex string. If the digests do not match,
PluginError::IntegrityCheckFailed is returned and the archive is never extracted.
When expected_sha256 is None, the archive is extracted without verification. Callers
are encouraged to always supply the expected hash; unverified installs are permitted by
default for backward compatibility but should be avoided in production.
§Errors
PluginError::DownloadFailed— HTTP request failed or returned a non-2xx status.PluginError::IntegrityCheckFailed— SHA-256 digest mismatch.PluginError::InvalidSource— archive cannot be extracted.- Any error that
Self::addcan return.
§Examples
let mgr = PluginManager::new(
"/tmp/plugins".into(),
"/tmp/managed".into(),
vec![],
vec![],
);
let result = mgr.add_remote(
"https://example.com/my-plugin.tar.gz",
Some("abc123def456..."),
).await?;
println!("installed: {}", result.name);Sourcepub fn scan_targets(
&self,
source: &str,
) -> Result<Vec<SkillScanInput>, PluginError>
pub fn scan_targets( &self, source: &str, ) -> Result<Vec<SkillScanInput>, PluginError>
Collect SkillScanInput entries for each skill in the plugin at source.
Validates the manifest and skill paths without copying any files. The returned
inputs are passed to SkillSemanticScanner::scan by the caller (core/commands
layer) before the blocking add() call proceeds. This keeps zeph-plugins
free of any LLM dependency.
Missing SKILL.md files return PluginError::SkillEntryMissing — a manifest
entry with no body is suspicious and treated as a blocking error, not a warning.
§Errors
PluginError::InvalidSource—sourcedoes not exist orplugin.tomlis missing/invalid.PluginError::SkillEntryMissing— a[[skills]]entry has noSKILL.md.PluginError::Io— filesystem error while readingSKILL.md.
§Examples
use zeph_plugins::PluginManager;
fn collect(mgr: &PluginManager, source: &str) -> Result<(), zeph_plugins::PluginError> {
let inputs = mgr.scan_targets(source)?;
for input in &inputs {
println!("skill: {} — {}", input.skill_name, input.declared_purpose);
}
Ok(())
}Sourcepub fn remove(&self, name: &str) -> Result<RemoveResult, PluginError>
pub fn remove(&self, name: &str) -> Result<RemoveResult, PluginError>
Remove an installed plugin by name.
Refuses to remove the plugin if any enabled plugin depends on it. The caller receives a
PluginError::DependencyRequired error with a formatted hint listing the dependents.
§Note on TOCTOU
The dependent check and directory removal are not atomic. In a multi-process environment a concurrent enable of a dependent could race with this remove. This is acceptable for a single-user CLI tool where plugin operations are manual.
§Errors
PluginError::NotFound— plugin is not installed.PluginError::DependencyRequired— at least one enabled plugin depends on this one.PluginError::Io— the plugin directory cannot be removed.
Sourcepub fn list_installed(&self) -> Result<Vec<InstalledPlugin>, PluginError>
pub fn list_installed(&self) -> Result<Vec<InstalledPlugin>, PluginError>
Sourcepub fn collect_skill_dirs(&self) -> Result<Vec<PathBuf>, PluginError>
pub fn collect_skill_dirs(&self) -> Result<Vec<PathBuf>, PluginError>
Returns all skill directory paths from installed plugins.
§Errors
Returns PluginError if the plugins directory cannot be read.
Sourcepub async fn check_auto_updates(&self) -> Vec<AutoUpdateResult>
pub async fn check_auto_updates(&self) -> Vec<AutoUpdateResult>
Check and apply updates for all installed plugins with auto_update = true.
For each eligible plugin the method:
- Reads
.plugin-source.tomlto retrieve the original download URL and SHA-256. - Downloads the archive from that URL.
- Compares the downloaded archive’s SHA-256 with the stored value.
- If the hashes differ, stages the new version in a temporary directory adjacent to the
plugin root, then atomically swaps via
rename— an interrupted update leaves the plugin intact rather than half-deleted. - Returns a result per plugin; failures are warnings and never abort startup.
Plugins installed from local paths (no .plugin-source.toml or no URL) are skipped
with AutoUpdateStatus::NoSource.
§Examples
let mgr = PluginManager::new(
"/tmp/plugins".into(),
"/tmp/managed".into(),
vec![],
vec![],
);
let results = mgr.check_auto_updates().await;
for r in &results {
println!("{}: {:?}", r.name, r.status);
}Sourcepub fn enable(&self, name: &str) -> Result<(), PluginError>
pub fn enable(&self, name: &str) -> Result<(), PluginError>
Enable an installed plugin by removing its .disabled marker file.
Before enabling the target, all plugins listed in plugin.dependencies are enabled
recursively (depth-first). The method detects dependency cycles and returns
PluginError::DependencyCycle before touching the filesystem.
A plugin with no .disabled marker is considered already enabled; this method is a no-op
for such plugins (idempotent).
§Errors
PluginError::NotFound— plugin is not installed.PluginError::MissingDependency— a declared dependency is not installed.PluginError::DependencyCycle— the dependency graph contains a cycle.PluginError::Io— the.disabledmarker cannot be removed.
Sourcepub fn disable(
&self,
name: &str,
force: bool,
) -> Result<DisableResult, PluginError>
pub fn disable( &self, name: &str, force: bool, ) -> Result<DisableResult, PluginError>
Disable an installed plugin by creating a .disabled marker file.
Refuses to disable the plugin if any enabled plugin depends on it, unless force is
true. When force is true the operation proceeds regardless, and the returned
DisableResult lists the dependents that were overridden so callers can warn the user.
Disabling an already-disabled plugin is a no-op (idempotent).
§Note on TOCTOU
The dependent check and marker creation are not atomic. In a multi-process environment a concurrent enable of a dependent could race with this disable. This is acceptable for a single-user CLI tool where plugin operations are manual.
§Errors
PluginError::NotFound— plugin is not installed.PluginError::DependencyRequired— at least one enabled plugin depends on this one andforceisfalse.PluginError::Io— the.disabledmarker cannot be written.
§Examples
let mgr = PluginManager::new(
"/tmp/plugins".into(),
"/tmp/managed".into(),
vec![],
vec![],
);
// Normal disable — fails if any enabled plugin depends on "my-plugin".
mgr.disable("my-plugin", false)?;
// Forced disable — proceeds even if dependents exist.
let result = mgr.disable("my-plugin", true)?;
if !result.forced_over_dependents.is_empty() {
eprintln!("Warning: disabled despite dependents: {:?}", result.forced_over_dependents);
}Sourcepub async fn add_remote_ephemeral(
&self,
url: &str,
sha256: Option<&str>,
) -> Result<TempDir, PluginError>
pub async fn add_remote_ephemeral( &self, url: &str, sha256: Option<&str>, ) -> Result<TempDir, PluginError>
Download a plugin archive and load it as a session-scoped ephemeral plugin.
Unlike Self::add_remote, this method:
- Requires
https://(neverhttp://) viavalidate_url_scheme_ephemeral. - Extracts into a
tempfile::TempDirthat is never copied to the permanent plugins store. - Runs a blocking (non-advisory) SKILL.md injection scan before returning.
- Returns ownership of the
TempDir. The caller must hold it for the session duration; dropping it cleans up the extracted archive automatically.
§Security invariants
- Never accepts
http://or any scheme other thanhttps://. - Never writes to
self.plugins_dir. - Never applies config overlays from the plugin manifest.
§Errors
PluginError::InsecureUrl— URL scheme is nothttps://.PluginError::DownloadFailed— HTTP request failed or returned a non-2xx status.PluginError::IntegrityCheckFailed— SHA-256 mismatch whensha256isSome.PluginError::InvalidSource— archive format is unsupported or extraction failed.PluginError::SemanticViolation— blocking skill scan detected injection patterns.PluginError::Io— failed to create temporary directory.
§Examples
let mgr = PluginManager::new(
"/tmp/plugins".into(),
"/tmp/managed".into(),
vec![],
vec![],
);
let _temp = mgr.add_remote_ephemeral(
"https://example.com/my-plugin.tar.gz",
Some("abc123def456..."),
).await?;
// _temp stays alive for the session; drop it to clean up