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_proactively_and_selectively_maintains_the_later_shelf() {
for instruction in [
"during ordinary implementation, debugging, and design discussion",
"do not wait for an explicit",
"tincan remember <text>",
"Worth putting on the later shelf?",
"tincan later",
"Tentative items need no audit",
"do not invent or enforce a taxonomy",
"inherit the project learning's topics",
"possibilities worth remembering, not commitments",
"Do not implement, plan, or present an item as current direction",
"connection and ask before folding it into the current work",
] {
assert!(
SKILL.contains(instruction),
"missing instruction: {instruction}"
);
}
}
#[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();
}
}