use std::collections::{BTreeMap, HashMap, HashSet};
use std::env;
use std::path::{Path, PathBuf};
use clap::ValueEnum;
use serde::{Deserialize, Serialize};
use crate::hashing::blake3_hex;
use crate::lockfile::LockedPackage;
use crate::manifest::{DependencyComponent, HookEvent, HookSessionSource, HookSpec, HookTool};
use crate::paths::{display_path, strip_path_prefix};
use crate::resolver::{PackageSource, ResolvedPackage};
mod output;
mod profile;
mod virtual_plugin;
pub mod agents;
pub mod claude;
pub mod codex;
pub mod copilot;
pub mod cursor;
pub mod opencode;
#[cfg(test)]
pub(crate) use output::build_output_plan;
pub(crate) use output::{
OutputPlan, OutputPlanOptions, PackageOwnedPaths, build_output_plan_with_options,
};
pub(crate) use profile::{
PreferredSurface, VirtualPluginSurface, artifact_supported, preferred_surface,
runtime_root_name, virtual_plugin_surface,
};
pub(crate) use virtual_plugin::{
VirtualPluginBackend, VirtualPluginEntry, emit_virtual_plugin_files,
virtual_plugin_entries_for_manifest, virtual_plugin_entries_for_package,
virtual_plugin_install_root_for_package, virtual_plugin_install_root_relative_for_alias,
};
#[derive(Debug, Clone)]
pub struct ManagedFile {
pub path: PathBuf,
pub contents: Vec<u8>,
}
#[derive(Debug, Clone)]
pub(crate) struct ManagedHookSpec {
pub package_alias: String,
pub emitted_from_root: bool,
pub hook: HookSpec,
}
#[derive(Debug, Clone)]
pub(crate) struct ManagedActivationHook {
pub package_alias: String,
pub context: String,
}
pub(crate) fn hook_event_supported_by_adapter(adapter: Adapter, event: HookEvent) -> bool {
profile::hook_event_supported(adapter, event)
}
pub(crate) fn session_start_source_supported_by_adapter(
adapter: Adapter,
source: HookSessionSource,
) -> bool {
profile::session_start_source_supported(adapter, source)
}
pub(crate) fn hook_supported_by_adapter(hook: &HookSpec, adapter: Adapter) -> bool {
hook_event_supported_by_adapter(adapter, hook.event)
&& (!matches!(hook.event, HookEvent::SessionStart)
|| hook
.matcher
.as_ref()
.map(|matcher| matcher.sources.is_empty())
.unwrap_or(true)
|| hook.matcher.as_ref().is_some_and(|matcher| {
matcher
.sources
.iter()
.any(|source| session_start_source_supported_by_adapter(adapter, *source))
}))
&& tool_matchers_supported_by_adapter(hook, adapter)
}
pub(crate) fn effective_session_start_sources(
hook: &HookSpec,
adapter: Adapter,
) -> Vec<HookSessionSource> {
if !matches!(hook.event, HookEvent::SessionStart) {
return Vec::new();
}
let configured = hook
.matcher
.as_ref()
.map(|matcher| matcher.sources.as_slice())
.unwrap_or_default();
let mut sources = if configured.is_empty() {
vec![HookSessionSource::Startup, HookSessionSource::Resume]
} else {
configured
.iter()
.copied()
.filter(|source| session_start_source_supported_by_adapter(adapter, *source))
.collect::<Vec<_>>()
};
sources.sort_by_key(|source| match source {
HookSessionSource::Startup => 0,
HookSessionSource::Resume => 1,
HookSessionSource::Clear => 2,
HookSessionSource::Compact => 3,
});
sources.dedup_by_key(|source| source.as_str());
sources
}
fn tool_matchers_supported_by_adapter(hook: &HookSpec, adapter: Adapter) -> bool {
if !matches!(
hook.event,
HookEvent::PreToolUse | HookEvent::PermissionRequest | HookEvent::PostToolUse
) {
return true;
}
let configured = hook
.matcher
.as_ref()
.map(|matcher| matcher.tool_names.as_slice())
.unwrap_or_default();
configured.is_empty()
|| configured
.iter()
.any(|tool| hook_tool_matcher_for_adapter(adapter, *tool).is_some())
}
pub(crate) fn hook_tool_matchers_for_adapter(
hook: &HookSpec,
adapter: Adapter,
) -> Vec<&'static str> {
hook.matcher
.as_ref()
.map(|matcher| matcher.tool_names.as_slice())
.unwrap_or_default()
.iter()
.filter_map(|tool| hook_tool_matcher_for_adapter(adapter, *tool))
.collect()
}
pub(crate) fn hook_tool_matcher_for_adapter(
adapter: Adapter,
tool: HookTool,
) -> Option<&'static str> {
profile::hook_tool_matcher(adapter, tool)
}
pub(crate) fn virtual_plugin_backend(
adapter: Adapter,
) -> Option<&'static dyn VirtualPluginBackend> {
match adapter {
Adapter::Codex => Some(&codex::VIRTUAL_PLUGIN_BACKEND),
Adapter::OpenCode => Some(&opencode::VIRTUAL_PLUGIN_BACKEND),
Adapter::Agents | Adapter::Claude | Adapter::Copilot | Adapter::Cursor => None,
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, ValueEnum,
)]
#[serde(rename_all = "lowercase")]
pub enum Adapter {
#[value(name = "agents")]
Agents,
#[value(name = "claude")]
Claude,
#[value(name = "codex")]
Codex,
#[value(name = "copilot")]
Copilot,
#[value(name = "cursor")]
Cursor,
#[value(name = "opencode", alias = "open-code")]
OpenCode,
}
impl Adapter {
pub const ALL: [Self; 6] = [
Self::Agents,
Self::Claude,
Self::Codex,
Self::Copilot,
Self::Cursor,
Self::OpenCode,
];
const fn bit(self) -> u8 {
match self {
Self::Agents => 1 << 0,
Self::Claude => 1 << 1,
Self::Codex => 1 << 2,
Self::Copilot => 1 << 3,
Self::Cursor => 1 << 4,
Self::OpenCode => 1 << 5,
}
}
pub const fn as_str(self) -> &'static str {
match self {
Self::Agents => "agents",
Self::Claude => "claude",
Self::Codex => "codex",
Self::Copilot => "copilot",
Self::Cursor => "cursor",
Self::OpenCode => "opencode",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Adapters(u8);
impl Adapters {
pub const NONE: Self = Self(0);
pub const AGENTS: Self = Self(Adapter::Agents.bit());
pub const CLAUDE: Self = Self(Adapter::Claude.bit());
pub const CODEX: Self = Self(Adapter::Codex.bit());
pub const COPILOT: Self = Self(Adapter::Copilot.bit());
pub const CURSOR: Self = Self(Adapter::Cursor.bit());
pub const OPENCODE: Self = Self(Adapter::OpenCode.bit());
pub const fn contains(self, adapter: Adapter) -> bool {
self.0 & adapter.bit() != 0
}
pub const fn union(self, other: Self) -> Self {
Self(self.0 | other.0)
}
#[cfg(test)]
pub const fn intersects(self, other: Self) -> bool {
self.0 & other.0 != 0
}
pub const fn is_empty(self) -> bool {
self.0 == 0
}
pub fn from_slice(adapters: &[Adapter]) -> Self {
adapters
.iter()
.copied()
.fold(Self::NONE, |selected, adapter| {
selected.union(adapter.into())
})
}
pub fn to_vec(self) -> Vec<Adapter> {
self.iter().collect()
}
pub fn iter(self) -> impl Iterator<Item = Adapter> {
Adapter::ALL
.into_iter()
.filter(move |adapter| self.contains(*adapter))
}
}
impl From<Adapter> for Adapters {
fn from(value: Adapter) -> Self {
Self(value.bit())
}
}
impl std::fmt::Display for Adapter {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArtifactKind {
Skill,
Agent,
Rule,
Command,
}
#[derive(Debug, Clone, Default)]
pub struct ManagedArtifactNames {
duplicate_skills: HashSet<String>,
duplicate_agents: HashSet<String>,
duplicate_rules: HashSet<String>,
duplicate_commands: HashSet<String>,
}
impl ManagedArtifactNames {
pub fn from_resolved_packages<'a>(
packages: impl IntoIterator<Item = &'a ResolvedPackage>,
) -> Self {
let mut duplicate_skills = HashMap::new();
let mut duplicate_agents = HashMap::new();
let mut duplicate_rules = HashMap::new();
let mut duplicate_commands = HashMap::new();
for package in packages {
if !package.emits_runtime_outputs() {
continue;
}
if package.selects_component(DependencyComponent::Skills) {
track_duplicates(
&mut duplicate_skills,
package
.manifest
.discovered
.skills
.iter()
.map(|skill| &skill.id),
);
}
if package.selects_component(DependencyComponent::Agents) {
track_duplicates(
&mut duplicate_agents,
package.manifest.discovered.unique_agent_ids(),
);
}
if package.selects_component(DependencyComponent::Rules) {
track_duplicates(
&mut duplicate_rules,
package
.manifest
.discovered
.rules
.iter()
.map(|rule| &rule.id),
);
}
if package.selects_component(DependencyComponent::Commands) {
track_duplicates(
&mut duplicate_commands,
package
.manifest
.discovered
.commands
.iter()
.map(|command| &command.id),
);
}
}
Self {
duplicate_skills: collect_duplicates(duplicate_skills),
duplicate_agents: collect_duplicates(duplicate_agents),
duplicate_rules: collect_duplicates(duplicate_rules),
duplicate_commands: collect_duplicates(duplicate_commands),
}
}
pub fn from_locked_packages<'a>(packages: impl IntoIterator<Item = &'a LockedPackage>) -> Self {
let mut duplicate_skills = HashMap::new();
let mut duplicate_agents = HashMap::new();
let mut duplicate_rules = HashMap::new();
let mut duplicate_commands = HashMap::new();
for package in packages {
track_duplicates(&mut duplicate_skills, package.skills.iter());
track_duplicates(&mut duplicate_agents, package.agents.iter());
track_duplicates(&mut duplicate_rules, package.rules.iter());
track_duplicates(&mut duplicate_commands, package.commands.iter());
}
Self {
duplicate_skills: collect_duplicates(duplicate_skills),
duplicate_agents: collect_duplicates(duplicate_agents),
duplicate_rules: collect_duplicates(duplicate_rules),
duplicate_commands: collect_duplicates(duplicate_commands),
}
}
pub fn managed_skill_id(&self, package: &ResolvedPackage, skill_id: &str) -> String {
self.artifact_id(ArtifactKind::Skill, skill_id, package_short_id(package))
}
pub fn managed_artifact_id(
&self,
package: &ResolvedPackage,
kind: ArtifactKind,
artifact_id: &str,
) -> String {
self.artifact_id(kind, artifact_id, package_short_id(package))
}
pub fn managed_file_name(
&self,
package: &ResolvedPackage,
kind: ArtifactKind,
artifact_id: &str,
extension: &str,
) -> String {
format!(
"{}.{}",
self.artifact_id(kind, artifact_id, package_short_id(package)),
extension.trim_start_matches('.')
)
}
pub fn locked_managed_skill_id(&self, package: &LockedPackage, skill_id: &str) -> String {
self.artifact_id(
ArtifactKind::Skill,
skill_id,
locked_package_short_id(package),
)
}
pub fn locked_managed_artifact_id(
&self,
package: &LockedPackage,
kind: ArtifactKind,
artifact_id: &str,
) -> String {
self.artifact_id(kind, artifact_id, locked_package_short_id(package))
}
pub fn locked_managed_file_name(
&self,
package: &LockedPackage,
kind: ArtifactKind,
artifact_id: &str,
extension: &str,
) -> String {
format!(
"{}.{}",
self.artifact_id(kind, artifact_id, locked_package_short_id(package)),
extension.trim_start_matches('.')
)
}
fn artifact_id(
&self,
kind: ArtifactKind,
artifact_id: &str,
package_short_id: String,
) -> String {
if self.requires_suffix(kind, artifact_id) {
format!("{artifact_id}_{package_short_id}")
} else {
artifact_id.to_string()
}
}
fn requires_suffix(&self, kind: ArtifactKind, artifact_id: &str) -> bool {
match kind {
ArtifactKind::Skill => self.duplicate_skills.contains(artifact_id),
ArtifactKind::Agent => self.duplicate_agents.contains(artifact_id),
ArtifactKind::Rule => self.duplicate_rules.contains(artifact_id),
ArtifactKind::Command => self.duplicate_commands.contains(artifact_id),
}
}
}
#[derive(Debug, Clone, Default)]
pub(crate) struct ManagedPackageIdentities {
by_alias: HashMap<String, PackageIdentityNames>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PackageIdentityNames {
plugin_name: String,
managed_package_id: String,
marketplace_plugin_name: String,
}
#[derive(Debug, Clone)]
struct PackageIdentityParts {
alias: String,
plugin_name: String,
suffix: PackageIdentitySuffix,
source_collision_id: String,
}
#[derive(Debug, Clone)]
enum PackageIdentitySuffix {
Stable(String),
Hash { short: String, long: String },
}
impl ManagedPackageIdentities {
pub(crate) fn from_resolved_packages<'a>(
packages: impl IntoIterator<Item = &'a ResolvedPackage>,
) -> Self {
let parts = packages
.into_iter()
.map(package_identity_parts)
.collect::<Vec<_>>();
let managed_package_ids = managed_package_ids(&parts);
let marketplace_plugin_names = marketplace_plugin_names(&parts, &managed_package_ids);
let by_alias = parts
.into_iter()
.map(|part| {
let alias = part.alias;
(
alias.clone(),
PackageIdentityNames {
plugin_name: part.plugin_name,
managed_package_id: managed_package_ids
.get(&alias)
.expect("managed id must exist for every package")
.clone(),
marketplace_plugin_name: marketplace_plugin_names
.get(&alias)
.expect("marketplace name must exist for every package")
.clone(),
},
)
})
.collect();
Self { by_alias }
}
pub(crate) fn managed_package_id(&self, package: &ResolvedPackage) -> String {
self.by_alias
.get(&package.alias)
.map(|identity| identity.managed_package_id.clone())
.unwrap_or_else(|| managed_package_id_without_peer_collisions(package))
}
pub(crate) fn marketplace_plugin_name(&self, package: &ResolvedPackage) -> String {
self.by_alias
.get(&package.alias)
.map(|identity| identity.marketplace_plugin_name.clone())
.unwrap_or_else(|| normalized_package_plugin_name(package))
}
}
pub(crate) fn normalized_package_plugin_name(package: &ResolvedPackage) -> String {
if let Some(name) = package.manifest.manifest.name.as_deref() {
return normalize_package_name_segment(name, "agentpack");
}
let fallback = source_package_name(package).unwrap_or_else(|| package.alias.clone());
normalize_package_name_segment(&fallback, "agentpack")
}
fn source_package_name(package: &ResolvedPackage) -> Option<String> {
match &package.source {
PackageSource::Root => Some(package.manifest.effective_name()),
PackageSource::Path { path, .. } => path
.file_name()
.and_then(|value| value.to_str())
.map(ToOwned::to_owned),
PackageSource::Git { url, subpath, .. } => subpath
.as_ref()
.and_then(|path| path.file_name())
.and_then(|value| value.to_str())
.map(ToOwned::to_owned)
.or_else(|| source_url_tail(url)),
}
}
fn source_url_tail(url: &str) -> Option<String> {
let trimmed = url
.trim()
.trim_end_matches(['/', '\\'])
.trim_end_matches(".git");
trimmed
.rsplit(['/', '\\'])
.next()
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn managed_package_id_without_peer_collisions(package: &ResolvedPackage) -> String {
let parts = package_identity_parts(package);
format!("{}+{}", parts.plugin_name, parts.suffix.preferred_segment())
}
fn package_identity_parts(package: &ResolvedPackage) -> PackageIdentityParts {
let plugin_name = normalized_package_plugin_name(package);
let source_collision_id = short_identity_hash(&package_source_key(package), 4);
let suffix = package_identity_suffix(package);
PackageIdentityParts {
alias: package.alias.clone(),
plugin_name,
suffix,
source_collision_id,
}
}
fn package_identity_suffix(package: &ResolvedPackage) -> PackageIdentitySuffix {
match &package.source {
PackageSource::Git {
tag, branch, rev, ..
} => {
if let Some(tag) = tag.as_deref().filter(|value| !value.trim().is_empty()) {
return PackageIdentitySuffix::Stable(normalize_package_source_segment(
tag, "version",
));
}
if let Some(version) = package.manifest.effective_version() {
return PackageIdentitySuffix::Stable(normalize_package_source_segment(
&version.to_string(),
"version",
));
}
if let Some(branch) = branch.as_deref().filter(|value| !value.trim().is_empty()) {
return PackageIdentitySuffix::Stable(normalize_package_source_segment(
branch, "branch",
));
}
PackageIdentitySuffix::Hash {
short: short_source_id_with_len(rev, 4),
long: short_source_id_with_len(rev, 8),
}
}
PackageSource::Path { tag, .. } => {
if let Some(tag) = tag.as_deref().filter(|value| !value.trim().is_empty()) {
return PackageIdentitySuffix::Stable(normalize_package_source_segment(
tag, "version",
));
}
if let Some(version) = package.manifest.effective_version() {
return PackageIdentitySuffix::Stable(normalize_package_source_segment(
&version.to_string(),
"version",
));
}
let digest = strip_digest_prefix(&package.digest);
PackageIdentitySuffix::Hash {
short: short_source_id_with_len(digest, 4),
long: short_source_id_with_len(digest, 8),
}
}
PackageSource::Root => {
if let Some(version) = package.manifest.effective_version() {
return PackageIdentitySuffix::Stable(normalize_package_source_segment(
&version.to_string(),
"version",
));
}
let digest = strip_digest_prefix(&package.digest);
PackageIdentitySuffix::Hash {
short: short_source_id_with_len(digest, 4),
long: short_source_id_with_len(digest, 8),
}
}
}
}
fn managed_package_ids(parts: &[PackageIdentityParts]) -> HashMap<String, String> {
parts
.iter()
.map(|part| {
let preferred = format!("{}+{}", part.plugin_name, part.suffix.preferred_segment());
let collides_with_other_source = parts.iter().any(|other| {
other.alias != part.alias
&& format!("{}+{}", other.plugin_name, other.suffix.preferred_segment())
== preferred
});
let managed_id = if collides_with_other_source {
collision_adjusted_managed_package_id(part, parts)
} else {
preferred
};
(part.alias.clone(), managed_id)
})
.collect()
}
fn collision_adjusted_managed_package_id(
part: &PackageIdentityParts,
parts: &[PackageIdentityParts],
) -> String {
match &part.suffix {
PackageIdentitySuffix::Stable(segment) => {
format!(
"{}+{}+{}",
part.plugin_name, segment, part.source_collision_id
)
}
PackageIdentitySuffix::Hash { short, long } => {
let long_candidate = format!("{}+{}", part.plugin_name, long);
let long_collides = parts.iter().any(|other| {
other.alias != part.alias
&& matches!(
&other.suffix,
PackageIdentitySuffix::Hash { long: other_long, .. }
if format!("{}+{}", other.plugin_name, other_long) == long_candidate
)
});
if long_collides {
format!(
"{}+{}+{}",
part.plugin_name, short, part.source_collision_id
)
} else {
long_candidate
}
}
}
}
fn marketplace_plugin_names(
parts: &[PackageIdentityParts],
managed_package_ids: &HashMap<String, String>,
) -> HashMap<String, String> {
let mut by_plugin_name: BTreeMap<&str, Vec<&PackageIdentityParts>> = BTreeMap::new();
for part in parts {
by_plugin_name
.entry(part.plugin_name.as_str())
.or_default()
.push(part);
}
let mut names = HashMap::new();
for (plugin_name, mut group) in by_plugin_name {
if group.len() == 1 {
names.insert(group[0].alias.clone(), plugin_name.to_string());
continue;
}
group.sort_by(|left, right| {
managed_package_ids
.get(&left.alias)
.cmp(&managed_package_ids.get(&right.alias))
.then(left.alias.cmp(&right.alias))
});
for (index, part) in group.into_iter().enumerate() {
let name = if index == 0 {
plugin_name.to_string()
} else {
format!(
"{}+{}",
plugin_name,
unique_marketplace_collision_suffix(part, parts)
)
};
names.insert(part.alias.clone(), name);
}
}
names
}
fn unique_marketplace_collision_suffix(
part: &PackageIdentityParts,
parts: &[PackageIdentityParts],
) -> String {
let short = part.suffix.source_segment(4);
let candidate = format!("{}+{}", part.plugin_name, short);
let collides = parts.iter().any(|other| {
other.alias != part.alias
&& other.plugin_name == part.plugin_name
&& format!("{}+{}", other.plugin_name, other.suffix.source_segment(4)) == candidate
});
if !collides {
return short;
}
let long = part.suffix.source_segment(8);
let candidate = format!("{}+{}", part.plugin_name, long);
let collides = parts.iter().any(|other| {
other.alias != part.alias
&& other.plugin_name == part.plugin_name
&& format!("{}+{}", other.plugin_name, other.suffix.source_segment(8)) == candidate
});
if collides {
format!("{}+{}", short, part.source_collision_id)
} else {
long
}
}
impl PackageIdentitySuffix {
fn preferred_segment(&self) -> &str {
match self {
Self::Stable(segment) => segment,
Self::Hash { short, .. } => short,
}
}
fn source_segment(&self, len: usize) -> String {
match self {
Self::Stable(segment) => {
if segment.chars().count() <= len {
segment.clone()
} else {
segment.chars().take(len).collect()
}
}
Self::Hash { short, long } if len <= 4 => short.clone(),
Self::Hash { long, .. } => long.clone(),
}
}
}
fn package_source_key(package: &ResolvedPackage) -> String {
match &package.source {
PackageSource::Root => format!("root\0{}", package.digest),
PackageSource::Path { path, tag } => format!(
"path\0{}\0{}\0{}",
display_path(path),
tag.as_deref().unwrap_or_default(),
package.digest
),
PackageSource::Git {
url,
subpath,
tag,
branch,
rev,
} => format!(
"git\0{}\0{}\0{}\0{}\0{}",
url,
subpath
.as_ref()
.map(|path| display_path(path))
.unwrap_or_default(),
tag.as_deref().unwrap_or_default(),
branch.as_deref().unwrap_or_default(),
rev
),
}
}
fn short_identity_hash(value: &str, len: usize) -> String {
short_source_id_with_len(&blake3_hex(value.as_bytes()), len)
}
fn normalize_package_name_segment(value: &str, fallback: &str) -> String {
normalize_package_identity_segment(value, fallback, false)
}
fn normalize_package_source_segment(value: &str, fallback: &str) -> String {
normalize_package_identity_segment(value, fallback, true)
}
fn normalize_package_identity_segment(value: &str, fallback: &str, allow_dot: bool) -> String {
let mut normalized = String::new();
for character in value.chars() {
if character.is_ascii_alphanumeric() || (allow_dot && character == '.') {
normalized.push(character.to_ascii_lowercase());
} else if !normalized.ends_with('-') {
normalized.push('-');
}
}
let normalized = normalized.trim_matches(['-', '.']).to_string();
if normalized.is_empty() {
fallback.to_string()
} else {
normalized
}
}
impl ArtifactKind {
pub fn supported_adapters(self) -> Adapters {
profile::supported_adapters(self)
}
pub const fn plural_name(self) -> &'static str {
match self {
Self::Skill => "skills",
Self::Agent => "agents",
Self::Rule => "rules",
Self::Command => "commands",
}
}
}
fn track_duplicates<'a>(
counts: &mut HashMap<String, usize>,
ids: impl IntoIterator<Item = &'a String>,
) {
for id in ids {
*counts.entry(id.clone()).or_default() += 1;
}
}
fn collect_duplicates(counts: HashMap<String, usize>) -> HashSet<String> {
counts
.into_iter()
.filter_map(|(id, count)| (count > 1).then_some(id))
.collect()
}
pub fn managed_skill_id(
names: &ManagedArtifactNames,
package: &ResolvedPackage,
skill_id: &str,
) -> String {
names.managed_skill_id(package, skill_id)
}
pub(crate) fn managed_runtime_skill_id(
names: &ManagedArtifactNames,
adapter: Adapter,
package: &ResolvedPackage,
skill_id: &str,
) -> String {
let local_names;
let names = if preferred_surface(adapter) == PreferredSurface::PackagePluginWorkspaceMarketplace
{
local_names = ManagedArtifactNames::from_resolved_packages([package]);
&local_names
} else {
names
};
managed_skill_id(names, package, skill_id)
}
pub fn managed_artifact_id(
names: &ManagedArtifactNames,
package: &ResolvedPackage,
kind: ArtifactKind,
artifact_id: &str,
) -> String {
names.managed_artifact_id(package, kind, artifact_id)
}
pub fn locked_managed_artifact_id(
names: &ManagedArtifactNames,
package: &LockedPackage,
kind: ArtifactKind,
artifact_id: &str,
) -> String {
names.locked_managed_artifact_id(package, kind, artifact_id)
}
pub fn runtime_root(project_root: &Path, adapter: Adapter) -> PathBuf {
project_root.join(profile::runtime_root_name(adapter))
}
pub(crate) const MANAGED_MARKETPLACE_NAME: &str = "nodus";
const NODUS_HOME_ENV: &str = "NODUS_HOME";
pub(crate) fn global_nodus_home(_project_root: &Path) -> PathBuf {
if let Some(path) = env::var_os(NODUS_HOME_ENV) {
return PathBuf::from(path);
}
#[cfg(test)]
{
_project_root.join(".nodus-global")
}
#[cfg(all(not(test), target_os = "windows"))]
{
if let Some(profile) = env::var_os("USERPROFILE") {
return PathBuf::from(profile).join(".nodus");
}
if let (Some(drive), Some(path)) = (env::var_os("HOMEDRIVE"), env::var_os("HOMEPATH")) {
return PathBuf::from(drive).join(path).join(".nodus");
}
}
#[cfg(all(not(test), not(target_os = "windows")))]
{
if let Some(home) = env::var_os("HOME") {
return PathBuf::from(home).join(".nodus");
}
}
#[cfg(not(test))]
PathBuf::from(".nodus")
}
pub(crate) const NODUS_HOME_TOKEN: &str = "${NODUS_HOME}";
pub(crate) fn encode_nodus_home_relative(home: &Path, path: &Path) -> Option<String> {
let relative = strip_path_prefix(path, home)?;
let rendered = display_path(relative);
if rendered.is_empty() || rendered == "." {
Some(NODUS_HOME_TOKEN.to_string())
} else {
Some(format!("{NODUS_HOME_TOKEN}/{rendered}"))
}
}
pub(crate) fn expand_nodus_home_relative(home: &Path, value: &str) -> Option<PathBuf> {
let remainder = value.strip_prefix(NODUS_HOME_TOKEN)?;
let remainder = remainder.strip_prefix('/').unwrap_or(remainder);
if remainder.is_empty() {
Some(home.to_path_buf())
} else {
Some(home.join(remainder))
}
}
pub(crate) fn native_marketplace_root(project_root: &Path, adapter: Adapter) -> PathBuf {
let global_home = global_nodus_home(project_root);
match adapter {
Adapter::Claude => global_home,
Adapter::Codex => global_home.join("marketplaces").join(adapter.as_str()),
Adapter::Agents | Adapter::Copilot | Adapter::Cursor | Adapter::OpenCode => {
global_home.join("marketplaces").join(adapter.as_str())
}
}
}
pub(crate) fn native_marketplace_source_path(project_root: &Path, adapter: Adapter) -> String {
display_path(&native_marketplace_root(project_root, adapter))
}
pub(crate) fn native_marketplace_plugin_source_path(
project_root: &Path,
adapter: Adapter,
plugin_root: &Path,
) -> String {
match adapter {
Adapter::Claude => {
let marketplace_root = native_marketplace_root(project_root, adapter);
if let Some(relative) = strip_path_prefix(plugin_root, &marketplace_root) {
return local_marketplace_path(relative);
}
if let Some(relative) = relative_path_from(&marketplace_root, plugin_root) {
return local_marketplace_path(&relative);
}
}
Adapter::Codex => {
let marketplace_root = native_marketplace_root(project_root, adapter);
if let Some(relative) = strip_path_prefix(plugin_root, &marketplace_root) {
return local_marketplace_path(relative);
}
}
Adapter::Agents | Adapter::Copilot | Adapter::Cursor | Adapter::OpenCode => {}
}
display_path(plugin_root)
}
fn relative_path_from(base: &Path, target: &Path) -> Option<PathBuf> {
let base = dunce::simplified(base).to_path_buf();
let target = dunce::simplified(target).to_path_buf();
if !base.is_absolute() || !target.is_absolute() {
return None;
}
let base_components = base.components().collect::<Vec<_>>();
let target_components = target.components().collect::<Vec<_>>();
let common_len = base_components
.iter()
.zip(&target_components)
.take_while(|(left, right)| left == right)
.count();
if common_len == 0 {
return None;
}
let mut relative = PathBuf::new();
for component in &base_components[common_len..] {
match component {
std::path::Component::Normal(_) => relative.push(".."),
std::path::Component::CurDir => {}
std::path::Component::ParentDir
| std::path::Component::RootDir
| std::path::Component::Prefix(_) => return None,
}
}
for component in &target_components[common_len..] {
match component {
std::path::Component::Normal(segment) => relative.push(segment),
std::path::Component::CurDir => {}
std::path::Component::ParentDir
| std::path::Component::RootDir
| std::path::Component::Prefix(_) => return None,
}
}
Some(relative)
}
fn local_marketplace_path(relative: &Path) -> String {
let path = display_path(relative);
if path.starts_with("./") || path.starts_with("../") {
path
} else {
format!("./{path}")
}
}
pub(crate) fn native_marketplace_path(project_root: &Path, adapter: Adapter) -> Option<PathBuf> {
let root = native_marketplace_root(project_root, adapter);
match adapter {
Adapter::Claude => Some(root.join(".claude-plugin/marketplace.json")),
Adapter::Codex => Some(root.join(".agents/plugins/marketplace.json")),
Adapter::Agents | Adapter::Copilot | Adapter::Cursor | Adapter::OpenCode => None,
}
}
pub(crate) fn native_package_plugin_root(
project_root: &Path,
adapter: Adapter,
package: &ResolvedPackage,
package_identities: &ManagedPackageIdentities,
) -> PathBuf {
if matches!(package.source, PackageSource::Root)
|| project_root_is_native_package_plugin_root(project_root, adapter)
{
return project_root.to_path_buf();
}
match adapter {
Adapter::Claude => global_nodus_home(project_root)
.join("packages")
.join(package_identities.managed_package_id(package))
.join("claude-plugin"),
Adapter::Codex => native_marketplace_root(project_root, adapter)
.join("plugins")
.join(package_identities.managed_package_id(package)),
Adapter::Agents | Adapter::Copilot | Adapter::Cursor | Adapter::OpenCode => {
unreachable!("only native plugin adapters have package plugin roots")
}
}
}
fn project_root_is_native_package_plugin_root(project_root: &Path, adapter: Adapter) -> bool {
if adapter == Adapter::Codex && project_root_is_codex_marketplace_plugin_root(project_root) {
return true;
}
let Some(plugin_dir) = project_root.file_name().and_then(|value| value.to_str()) else {
return false;
};
let expected_plugin_dir = match adapter {
Adapter::Claude => "claude-plugin",
Adapter::Codex => "codex-plugin",
Adapter::Agents | Adapter::Copilot | Adapter::Cursor | Adapter::OpenCode => return false,
};
if plugin_dir != expected_plugin_dir {
return false;
}
project_root.parent().is_some()
}
fn project_root_is_codex_marketplace_plugin_root(project_root: &Path) -> bool {
let Some(plugins_dir) = project_root.parent() else {
return false;
};
if plugins_dir.file_name().and_then(|value| value.to_str()) != Some("plugins") {
return false;
}
let Some(codex_marketplace_dir) = plugins_dir.parent() else {
return false;
};
codex_marketplace_dir
.file_name()
.and_then(|value| value.to_str())
== Some("codex")
&& codex_marketplace_dir
.parent()
.and_then(|path| path.file_name())
.and_then(|value| value.to_str())
== Some("marketplaces")
}
pub(crate) fn managed_runtime_root(
project_root: &Path,
adapter: Adapter,
package: &ResolvedPackage,
) -> PathBuf {
if project_root_is_native_package_plugin_root(project_root, adapter) {
return project_root.to_path_buf();
}
if preferred_surface(adapter) == PreferredSurface::PackagePluginWorkspaceMarketplace
&& matches!(package.source, PackageSource::Root)
{
return runtime_root(project_root, adapter);
}
runtime_root(project_root, adapter)
}
pub fn managed_skill_root(
names: &ManagedArtifactNames,
project_root: &Path,
adapter: Adapter,
package: &ResolvedPackage,
skill_id: &str,
) -> PathBuf {
let local_names;
let names = if preferred_surface(adapter) == PreferredSurface::PackagePluginWorkspaceMarketplace
{
local_names = ManagedArtifactNames::from_resolved_packages([package]);
&local_names
} else {
names
};
managed_runtime_root(project_root, adapter, package)
.join("skills")
.join(managed_skill_id(names, package, skill_id))
}
pub fn managed_artifact_path(
names: &ManagedArtifactNames,
project_root: &Path,
adapter: Adapter,
kind: ArtifactKind,
package: &ResolvedPackage,
artifact_id: &str,
) -> Option<PathBuf> {
let codex_agent_override = matches!((adapter, kind), (Adapter::Codex, ArtifactKind::Agent));
let local_names;
let names = if !codex_agent_override
&& preferred_surface(adapter) == PreferredSurface::PackagePluginWorkspaceMarketplace
{
local_names = ManagedArtifactNames::from_resolved_packages([package]);
&local_names
} else {
names
};
let runtime_root = if codex_agent_override {
runtime_root(project_root, adapter)
} else {
managed_runtime_root(project_root, adapter, package)
};
match (adapter, kind) {
(Adapter::Agents, ArtifactKind::Command) => {
Some(runtime_root.join("commands").join(managed_file_name(
names,
package,
kind,
artifact_id,
"md",
)))
}
(Adapter::Claude, ArtifactKind::Agent) => {
Some(runtime_root.join("agents").join(managed_file_name(
names,
package,
kind,
artifact_id,
"md",
)))
}
(Adapter::Codex, ArtifactKind::Agent) => {
Some(runtime_root.join("agents").join(managed_file_name(
names,
package,
kind,
artifact_id,
"toml",
)))
}
(Adapter::Claude, ArtifactKind::Command) => {
Some(runtime_root.join("commands").join(managed_file_name(
names,
package,
kind,
artifact_id,
"md",
)))
}
(Adapter::Copilot, ArtifactKind::Agent) => Some(runtime_root.join("agents").join(
managed_file_name(names, package, kind, artifact_id, "agent.md"),
)),
(Adapter::Claude, ArtifactKind::Rule) => {
Some(runtime_root.join("rules").join(managed_file_name(
names,
package,
kind,
artifact_id,
"md",
)))
}
(Adapter::Cursor, ArtifactKind::Command) => {
Some(runtime_root.join("commands").join(managed_file_name(
names,
package,
kind,
artifact_id,
"md",
)))
}
(Adapter::Cursor, ArtifactKind::Rule) => {
Some(runtime_root.join("rules").join(managed_file_name(
names,
package,
kind,
artifact_id,
"mdc",
)))
}
(Adapter::OpenCode, ArtifactKind::Agent) => {
Some(runtime_root.join("agents").join(managed_file_name(
names,
package,
kind,
artifact_id,
"md",
)))
}
(Adapter::OpenCode, ArtifactKind::Command) => {
Some(runtime_root.join("commands").join(managed_file_name(
names,
package,
kind,
artifact_id,
"md",
)))
}
(Adapter::OpenCode, ArtifactKind::Rule) => {
Some(runtime_root.join("rules").join(managed_file_name(
names,
package,
kind,
artifact_id,
"md",
)))
}
_ => None,
}
}
#[cfg(test)]
pub fn namespaced_artifact_id(package: &ResolvedPackage, artifact_id: &str) -> String {
format!("{artifact_id}_{}", package_short_id(package))
}
pub fn managed_file_name(
names: &ManagedArtifactNames,
package: &ResolvedPackage,
kind: ArtifactKind,
artifact_id: &str,
extension: &str,
) -> String {
names.managed_file_name(package, kind, artifact_id, extension)
}
#[cfg(test)]
pub fn namespaced_skill_id(package: &ResolvedPackage, skill_id: &str) -> String {
namespaced_artifact_id(package, skill_id)
}
#[cfg(test)]
pub fn namespaced_file_name(
package: &ResolvedPackage,
artifact_id: &str,
extension: &str,
) -> String {
format!(
"{}.{}",
namespaced_artifact_id(package, artifact_id),
extension.trim_start_matches('.')
)
}
pub fn package_short_id(package: &ResolvedPackage) -> String {
match &package.source {
PackageSource::Git { rev, .. } => short_source_id(rev),
PackageSource::Path { .. } | PackageSource::Root => {
short_source_id(strip_digest_prefix(&package.digest))
}
}
}
pub fn short_source_id(value: &str) -> String {
short_source_id_with_len(value, 6)
}
fn short_source_id_with_len(value: &str, len: usize) -> String {
let short = value
.chars()
.filter(|character| character.is_ascii_alphanumeric())
.take(len)
.collect::<String>()
.to_ascii_lowercase();
if short.is_empty() {
"local0".into()
} else {
short
}
}
fn locked_package_short_id(package: &LockedPackage) -> String {
match package.source.kind.as_str() {
"git" => short_source_id(
package
.source
.rev
.as_deref()
.unwrap_or(package.digest.as_str()),
),
_ => short_source_id(strip_digest_prefix(&package.digest)),
}
}
fn strip_digest_prefix(digest: &str) -> &str {
digest
.strip_prefix("sha256:")
.or_else(|| digest.strip_prefix("blake3:"))
.unwrap_or(digest)
}
#[cfg(test)]
mod identity_tests {
use super::*;
fn hash_part(
alias: &str,
plugin_name: &str,
short: &str,
long: &str,
source_collision_id: &str,
) -> PackageIdentityParts {
PackageIdentityParts {
alias: alias.to_string(),
plugin_name: plugin_name.to_string(),
suffix: PackageIdentitySuffix::Hash {
short: short.to_string(),
long: long.to_string(),
},
source_collision_id: source_collision_id.to_string(),
}
}
#[test]
fn managed_package_ids_extend_hash_collisions_before_source_hashing() {
let parts = vec![
hash_part("one", "playbook-ios", "ea51", "ea510001", "9a2f"),
hash_part("two", "playbook-ios", "ea51", "ea510002", "3b4c"),
];
let ids = managed_package_ids(&parts);
assert_eq!(ids["one"], "playbook-ios+ea510001");
assert_eq!(ids["two"], "playbook-ios+ea510002");
}
#[test]
fn managed_package_ids_append_source_hash_after_long_hash_collision() {
let parts = vec![
hash_part("one", "playbook-ios", "ea51", "ea510001", "9a2f"),
hash_part("two", "playbook-ios", "ea51", "ea510001", "3b4c"),
];
let ids = managed_package_ids(&parts);
assert_eq!(ids["one"], "playbook-ios+ea51+9a2f");
assert_eq!(ids["two"], "playbook-ios+ea51+3b4c");
}
#[test]
fn marketplace_plugin_names_keep_unique_names_friendly_and_suffix_collisions() {
let parts = vec![
hash_part("one", "playbook-ios", "ea51", "ea510001", "9a2f"),
hash_part("two", "playbook-ios", "fb72", "fb720001", "3b4c"),
hash_part("three", "docs-tools", "0011", "00112233", "abcd"),
];
let ids = managed_package_ids(&parts);
let names = marketplace_plugin_names(&parts, &ids);
assert_eq!(names["one"], "playbook-ios");
assert_eq!(names["two"], "playbook-ios+fb72");
assert_eq!(names["three"], "docs-tools");
}
}
#[cfg(test)]
mod nodus_home_path_tests {
use super::*;
#[test]
fn encodes_path_under_home_as_portable_token() {
let home = Path::new("/Users/wendell/.nodus");
let path = Path::new("/Users/wendell/.nodus/marketplaces/codex/plugins/core+3051");
assert_eq!(
encode_nodus_home_relative(home, path).as_deref(),
Some("${NODUS_HOME}/marketplaces/codex/plugins/core+3051"),
);
}
#[test]
fn encodes_home_root_as_bare_token() {
let home = Path::new("/Users/wendell/.nodus");
assert_eq!(
encode_nodus_home_relative(home, home).as_deref(),
Some(NODUS_HOME_TOKEN),
);
}
#[test]
fn returns_none_for_paths_outside_home() {
let home = Path::new("/Users/wendell/.nodus");
assert_eq!(
encode_nodus_home_relative(home, Path::new("/Users/wendell/project/.codex")),
None,
);
}
#[test]
fn expands_token_against_a_different_developers_home() {
let alice_home = Path::new("/Users/alice/.nodus");
assert_eq!(
expand_nodus_home_relative(
alice_home,
"${NODUS_HOME}/marketplaces/codex/plugins/core+3051",
),
Some(alice_home.join("marketplaces/codex/plugins/core+3051")),
);
}
#[test]
fn round_trips_through_a_relocated_home() {
let writer_home = Path::new("/Users/wendell/.nodus");
let reader_home = Path::new("/home/ci/.config/nodus");
let path = writer_home.join("marketplaces/codex/plugins/core+3051");
let encoded = encode_nodus_home_relative(writer_home, &path).expect("under home");
assert_eq!(
expand_nodus_home_relative(reader_home, &encoded),
Some(reader_home.join("marketplaces/codex/plugins/core+3051")),
);
}
#[test]
fn expand_returns_none_for_non_token_strings() {
let home = Path::new("/Users/wendell/.nodus");
assert_eq!(
expand_nodus_home_relative(home, ".nodus/packages/foo"),
None,
);
}
}