use dialoguer::{Confirm, MultiSelect, theme::ColorfulTheme};
use std::collections::BTreeSet;
use std::fs;
use std::io::{self, IsTerminal};
use std::path::{Path, PathBuf};
use crate::util::display_path;
const SKILL: &str = include_str!("../skills/tincan/SKILL.md");
const INSTALL_CONFIRM_DEFAULT: bool = true;
const INSTALL_ACTION: &str = "Install or update Tincan";
const UPDATE_NOTICE: &str = "Agent Skill update available. Run `tincan skill install` to update.";
const OPENAI_METADATA: &str = include_str!("../skills/tincan/agents/openai.yaml");
const PICKER_HELP: &str =
"[↑↓ move, Space select/unselect, A toggle all/none, Enter continue, Esc cancel]";
#[derive(Debug)]
pub enum InstallOutcome {
Installed(PathBuf),
AlreadyCurrent(PathBuf),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SkillRoot {
pub name: String,
pub path: PathBuf,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum InstallationState {
Current,
UpdateAvailable,
NotInstalled,
}
#[derive(Debug, PartialEq, Eq)]
pub struct InstallationStatus {
pub root: SkillRoot,
pub state: InstallationState,
}
pub fn detect_roots() -> Vec<SkillRoot> {
let home = nonempty_env("USERPROFILE")
.or_else(|| nonempty_env("HOME"))
.map(PathBuf::from);
let codex_home = nonempty_env("CODEX_HOME").map(PathBuf::from);
detect_roots_from(home.as_deref(), codex_home.as_deref())
}
pub fn notify_if_update_available() {
if !io::stderr().is_terminal() {
return;
}
let roots = detect_roots();
if outdated_roots(&roots).is_empty() {
return;
}
eprintln!("{UPDATE_NOTICE}");
}
pub fn installation_statuses() -> Vec<InstallationStatus> {
installation_statuses_for(detect_roots())
}
pub fn pending_roots(roots: &[SkillRoot]) -> Vec<SkillRoot> {
roots
.iter()
.filter(|root| !installed_skill_is_current(root))
.cloned()
.collect()
}
pub fn install_action(root: &SkillRoot) -> &'static str {
if root.path.join("tincan").exists() {
"Update"
} else {
"Install"
}
}
fn installation_statuses_for(roots: Vec<SkillRoot>) -> Vec<InstallationStatus> {
roots
.into_iter()
.map(|root| {
let destination = root.path.join("tincan");
let state = if !destination.is_dir() {
InstallationState::NotInstalled
} else if installed_skill_is_current(&root) {
InstallationState::Current
} else {
InstallationState::UpdateAvailable
};
InstallationStatus { root, state }
})
.collect()
}
fn outdated_roots(roots: &[SkillRoot]) -> Vec<&SkillRoot> {
roots
.iter()
.filter(|root| root.path.join("tincan").is_dir() && !installed_skill_is_current(root))
.collect()
}
fn installed_skill_is_current(root: &SkillRoot) -> bool {
let destination = root.path.join("tincan");
let skill = destination.join("SKILL.md");
let metadata = destination.join("agents").join("openai.yaml");
read(&skill).ok().as_deref() == Some(SKILL)
&& read(&metadata).ok().as_deref() == Some(OPENAI_METADATA)
}
pub fn choose_interactively(roots: &[SkillRoot]) -> Result<Option<Vec<PathBuf>>, String> {
let labels = picker_labels(roots);
let defaults = default_selections(roots);
let theme = ColorfulTheme::default();
loop {
let Some(indices) = MultiSelect::with_theme(&theme)
.with_prompt("Select Tincan skill installations")
.items(&labels)
.defaults(&defaults)
.report(false)
.interact_opt()
.map_err(|error| format!("cannot read skill destination selection: {error}"))?
else {
return Ok(None);
};
let selected = selected_paths(roots, &indices);
if selected.is_empty() {
eprintln!("No destinations selected. Select at least one or press Escape to cancel.");
continue;
}
println!("✔ Select Tincan skill installations");
println!("The Tincan skill will be installed or updated in:");
for index in &indices {
if let Some(root) = roots.get(*index) {
println!(
" - {} Tincan in {}: {}",
install_action(root),
root.name,
display_user_path(&root.path.join("tincan"))
);
}
}
let confirmed = Confirm::with_theme(&theme)
.with_prompt(format!(
"{INSTALL_ACTION} in {} selected destination{}?",
selected.len(),
if selected.len() == 1 { "" } else { "s" }
))
.default(INSTALL_CONFIRM_DEFAULT)
.wait_for_newline(true)
.interact()
.map_err(|error| format!("cannot read skill installation confirmation: {error}"))?;
return Ok(confirmed.then_some(selected));
}
}
pub fn install_many(roots: &[PathBuf], force: bool) -> Result<Vec<InstallOutcome>, String> {
let plans = roots
.iter()
.map(|root| install_plan(root, force))
.collect::<Result<Vec<_>, _>>()?;
plans.into_iter().map(apply_install_plan).collect()
}
fn install_plan(skills: &Path, force: bool) -> Result<InstallPlan, String> {
let destination = skills.join("tincan");
let skill_path = destination.join("SKILL.md");
let metadata_path = destination.join("agents").join("openai.yaml");
if skill_path.is_file()
&& metadata_path.is_file()
&& read(&skill_path)? == SKILL
&& read(&metadata_path)? == OPENAI_METADATA
{
return Ok(InstallPlan::Current(destination));
}
if destination.exists() && !force {
return Err(format!(
"{} already exists and differs from this Tincan version; rerun with --force to update Tincan-owned files",
destination.display()
));
}
Ok(InstallPlan::Write(destination))
}
fn apply_install_plan(plan: InstallPlan) -> Result<InstallOutcome, String> {
let destination = match plan {
InstallPlan::Current(destination) => {
return Ok(InstallOutcome::AlreadyCurrent(destination));
}
InstallPlan::Write(destination) => destination,
};
let skill_path = destination.join("SKILL.md");
let metadata_path = destination.join("agents").join("openai.yaml");
fs::create_dir_all(destination.join("agents")).map_err(|error| {
format!(
"cannot create skill directory {}: {error}",
destination.display()
)
})?;
fs::write(&skill_path, SKILL)
.map_err(|error| format!("cannot write {}: {error}", skill_path.display()))?;
fs::write(&metadata_path, OPENAI_METADATA)
.map_err(|error| format!("cannot write {}: {error}", metadata_path.display()))?;
Ok(InstallOutcome::Installed(destination))
}
enum InstallPlan {
Current(PathBuf),
Write(PathBuf),
}
fn detect_roots_from(home: Option<&Path>, codex_home: Option<&Path>) -> Vec<SkillRoot> {
let mut roots = Vec::new();
let mut seen = BTreeSet::new();
if let Some(codex_home) = codex_home {
add_detected_root(
&mut roots,
&mut seen,
"Codex",
codex_home,
&codex_home.join("skills"),
);
}
if let Some(home) = home {
for (name, directory) in [
("Agent Skills", ".agents"),
("Claude Code", ".claude"),
("Codex", ".codex"),
("Cursor", ".cursor"),
("Gemini CLI", ".gemini"),
] {
let marker = home.join(directory);
add_detected_root(&mut roots, &mut seen, name, &marker, &marker.join("skills"));
}
let opencode = home.join(".config").join("opencode");
add_detected_root(
&mut roots,
&mut seen,
"OpenCode",
&opencode,
&opencode.join("skills"),
);
}
roots
}
fn add_detected_root(
roots: &mut Vec<SkillRoot>,
seen: &mut BTreeSet<String>,
name: &str,
marker: &Path,
skills: &Path,
) {
if !marker.is_dir() {
return;
}
let mut key = skills.to_string_lossy().replace('\\', "/");
if cfg!(windows) {
key.make_ascii_lowercase();
}
if seen.insert(key) {
roots.push(SkillRoot {
name: name.to_string(),
path: skills.to_path_buf(),
});
}
}
fn selection_labels(roots: &[SkillRoot]) -> Vec<String> {
roots
.iter()
.map(|root| {
format!(
"{} Tincan in {}: {}",
install_action(root),
root.name,
root.path
.join("tincan")
.to_string_lossy()
.replace(['\n', '\r'], " ")
)
})
.collect()
}
fn picker_labels(roots: &[SkillRoot]) -> Vec<String> {
let mut labels = selection_labels(roots);
if let Some(last) = labels.last_mut() {
last.push_str("\n\n");
last.push_str(PICKER_HELP);
}
labels
}
pub fn display_user_path(path: &Path) -> String {
let home = nonempty_env("USERPROFILE")
.or_else(|| nonempty_env("HOME"))
.map(PathBuf::from);
if let Some(relative) = home
.as_deref()
.and_then(|home| path.strip_prefix(home).ok())
{
if relative.as_os_str().is_empty() {
return "~".to_string();
}
return format!("~/{}", display_path(relative));
}
display_path(path)
}
fn default_selections(roots: &[SkillRoot]) -> Vec<bool> {
vec![true; roots.len()]
}
fn selected_paths(roots: &[SkillRoot], indices: &[usize]) -> Vec<PathBuf> {
indices
.iter()
.filter_map(|index| roots.get(*index))
.map(|root| root.path.clone())
.collect()
}
fn nonempty_env(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|value| !value.is_empty())
}
fn read(path: &Path) -> Result<String, String> {
fs::read_to_string(path).map_err(|error| format!("cannot read {}: {error}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn installs_idempotently_and_protects_different_content() {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = std::env::temp_dir().join(format!("tincan-skill-install-{unique}"));
assert!(matches!(
install_many(std::slice::from_ref(&root), false).unwrap()[0],
InstallOutcome::Installed(ref path) if path == &root.join("tincan")
));
assert_eq!(
fs::read_to_string(root.join("tincan").join("SKILL.md")).unwrap(),
SKILL
);
assert!(matches!(
install_many(std::slice::from_ref(&root), false).unwrap()[0],
InstallOutcome::AlreadyCurrent(ref path) if path == &root.join("tincan")
));
fs::write(root.join("tincan").join("SKILL.md"), "local edit").unwrap();
assert!(install_many(std::slice::from_ref(&root), false).is_err());
assert!(install_many(std::slice::from_ref(&root), true).is_ok());
fs::remove_dir_all(root).unwrap();
}
#[test]
fn detects_only_harnesses_present_on_the_machine() {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = std::env::temp_dir().join(format!("tincan-skill-detect-{unique}"));
let home = root.join("home");
fs::create_dir_all(home.join(".claude")).unwrap();
fs::create_dir_all(home.join(".cursor")).unwrap();
let roots = detect_roots_from(Some(&home), None);
assert_eq!(roots.len(), 2);
assert!(roots.iter().any(|root| root.name == "Claude Code"));
assert!(roots.iter().any(|root| root.name == "Cursor"));
assert!(!roots.iter().any(|root| root.name.contains("Codex")));
fs::remove_dir_all(root).unwrap();
}
#[test]
fn renders_each_destination_on_one_line_and_maps_selections() {
let roots = vec![
SkillRoot {
name: "First".to_string(),
path: PathBuf::from("first/skills"),
},
SkillRoot {
name: "Second".to_string(),
path: PathBuf::from("second/skills"),
},
];
let labels = selection_labels(&roots);
assert_eq!(labels.len(), 2);
assert!(labels[0].starts_with("Install Tincan in First: "));
assert!(
labels[0]
.replace('\\', "/")
.ends_with("first/skills/tincan")
);
assert!(labels.iter().all(|label| !label.contains('\n')));
let selected = selected_paths(&roots, &[1]);
assert_eq!(selected, vec![PathBuf::from("second/skills")]);
}
#[test]
fn selects_every_detected_destination_by_default() {
let roots = vec![SkillRoot {
name: "Only harness".to_string(),
path: PathBuf::from("only/skills"),
}];
assert_eq!(default_selections(&roots), vec![true]);
}
#[test]
fn distinguishes_new_current_and_outdated_installations() {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = std::env::temp_dir().join(format!("tincan-skill-actions-{unique}"));
let missing = SkillRoot {
name: "Missing".to_string(),
path: root.join("missing"),
};
let current = SkillRoot {
name: "Current".to_string(),
path: root.join("current"),
};
let outdated = SkillRoot {
name: "Outdated".to_string(),
path: root.join("outdated"),
};
install_many(std::slice::from_ref(¤t.path), false).unwrap();
fs::create_dir_all(outdated.path.join("tincan")).unwrap();
fs::write(outdated.path.join("tincan/SKILL.md"), "older skill").unwrap();
assert_eq!(install_action(&missing), "Install");
assert_eq!(install_action(&outdated), "Update");
assert_eq!(install_action(¤t), "Update");
assert_eq!(
pending_roots(&[missing.clone(), current, outdated.clone()]),
vec![missing, outdated]
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn confirms_skill_installation_by_default() {
const { assert!(INSTALL_CONFIRM_DEFAULT) };
}
#[test]
fn interactive_confirmation_explicitly_covers_updates() {
assert_eq!(INSTALL_ACTION, "Install or update Tincan");
}
#[test]
fn skill_update_notice_is_concise_and_actionable() {
assert_eq!(
UPDATE_NOTICE,
"Agent Skill update available. Run `tincan skill install` to update."
);
}
#[test]
fn picker_help_explains_every_available_action() {
for instruction in [
"↑↓ move",
"Space select/unselect",
"A toggle all/none",
"Enter continue",
"Esc cancel",
] {
assert!(PICKER_HELP.contains(instruction));
}
let roots = vec![SkillRoot {
name: "Only harness".to_string(),
path: PathBuf::from("only/skills"),
}];
let labels = picker_labels(&roots);
assert!(labels[0].ends_with(PICKER_HELP));
assert_eq!(labels[0].matches(PICKER_HELP).count(), 1);
}
#[test]
fn bundled_skill_offers_initialization_without_assuming_consent() {
assert!(SKILL.contains("user-question tool"));
assert!(SKILL.contains("user-question"));
assert!(SKILL.contains("Initialize Tincan"));
assert!(SKILL.contains("Not now"));
assert!(SKILL.contains("Never run"));
assert!(SKILL.contains("without the user's explicit confirmation"));
}
#[test]
fn bundled_skill_contains_the_complete_memory_policy() {
for instruction in [
"transient status only",
"maintaining project memory",
"no durable implication",
"mistake, regression, or repeated",
"promote a journal status update into a learning",
] {
assert!(SKILL.contains(instruction));
}
assert!(!SKILL.contains("AGENT_GUIDE.md"));
}
#[test]
fn bundled_skill_maintains_a_sufficient_handoff_without_mirroring_git() {
for instruction in [
"Before handing control back after meaningful project work",
"already provide enough context, write nothing",
"The journal is a sufficient handoff, not a complete history",
"Do not mirror",
"does not itself require a journal entry",
"review the plan before handing control back",
"does not require a plan edit",
] {
assert!(
SKILL.contains(instruction),
"missing instruction: {instruction}"
);
}
}
#[test]
fn bundled_skill_scopes_historical_reviews_before_interpreting_them() {
for instruction in [
"run bare `tincan review` first",
"`review --year",
"use `review --all-time`",
"Use `tincan projects` to show registry state",
"use `tincan projects unregister PATH_OR_ID`",
"never deletes project `.tincan/`",
"run `tincan init PATH` on",
"deterministic source context, not an interpretation",
"do not store a derived review unless the user asks for a file",
] {
assert!(
SKILL.contains(instruction),
"missing instruction: {instruction}"
);
}
}
#[test]
fn bundled_skill_uses_one_startup_command_and_requires_lift_approval() {
assert!(!SKILL.contains("tincan --help"));
assert!(SKILL.contains("Run `tincan resume` once"));
assert!(SKILL.contains(
"I think this learning could be useful in other projects. Make it a global learning?"
));
assert!(SKILL.contains("run `tincan lift <learning-id> --from <markdown-file>`"));
assert!(SKILL.contains("run `tincan search <learning-id>` before changing memory"));
}
#[test]
fn validates_every_destination_before_writing_any() {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = std::env::temp_dir().join(format!("tincan-skill-atomic-{unique}"));
let first = root.join("first");
let second = root.join("second");
fs::create_dir_all(second.join("tincan")).unwrap();
fs::write(second.join("tincan").join("SKILL.md"), "owned elsewhere").unwrap();
assert!(install_many(&[first.clone(), second], false).is_err());
assert!(!first.join("tincan").exists());
fs::remove_dir_all(root).unwrap();
}
#[test]
fn detects_outdated_installed_skills_without_treating_missing_skills_as_updates() {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = std::env::temp_dir().join(format!("tincan-skill-update-{unique}"));
let installed = root.join("installed");
let missing = root.join("missing");
fs::create_dir_all(installed.join("tincan")).unwrap();
fs::write(installed.join("tincan").join("SKILL.md"), "older skill").unwrap();
let roots = vec![
SkillRoot {
name: "Installed".to_string(),
path: installed,
},
SkillRoot {
name: "Missing".to_string(),
path: missing,
},
];
let outdated = outdated_roots(&roots);
assert_eq!(outdated.len(), 1);
assert_eq!(outdated[0].name, "Installed");
fs::remove_dir_all(root).unwrap();
}
#[test]
fn reports_current_outdated_and_missing_skill_installations() {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = std::env::temp_dir().join(format!("tincan-skill-status-{unique}"));
let current = root.join("current");
let outdated = root.join("outdated");
let missing = root.join("missing");
install_many(std::slice::from_ref(¤t), false).unwrap();
fs::create_dir_all(outdated.join("tincan")).unwrap();
fs::write(outdated.join("tincan/SKILL.md"), "older skill").unwrap();
let statuses = installation_statuses_for(vec![
SkillRoot {
name: "Current".into(),
path: current,
},
SkillRoot {
name: "Outdated".into(),
path: outdated,
},
SkillRoot {
name: "Missing".into(),
path: missing,
},
]);
assert_eq!(statuses[0].state, InstallationState::Current);
assert_eq!(statuses[1].state, InstallationState::UpdateAvailable);
assert_eq!(statuses[2].state, InstallationState::NotInstalled);
fs::remove_dir_all(root).unwrap();
}
}