From 68929e535faca49dbfc8da166d227e0a4e586476 Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Sat, 25 Jul 2026 21:00:48 -0600 Subject: [PATCH] feat(sol): port sol skills to native Rust sol skills install, uninstall, and list now run through a direct match arm in solstone_core_sol::run, placed before the compatibility dispatcher. It is not a native authority entry: the inventory builder globs solstone/**/native/**/authority.toml, every entry lands in a partition with a frozen count, and sol-call paths must exist in the frozen oracle grammar. skills is a top-level non-call verb with no oracle path. Remove skills from TOP_LEVEL_COMPAT_MODULES and TOP_LEVEL_COMPAT_COMMANDS, one element each. The compatibility dispatcher, the SOLSTONE_NATIVE_COMPAT_ACTIVE sentinel, and every other compat entry are untouched. A new order-insensitive, count-free element-identity assertion ties the two collections together so they cannot drift. One Python/Rust divergence is knowingly accepted: install_user does not reproduce the os.access(target, W_OK) pre-check. access(2) has no std equivalent and adding libc to reproduce one error string is not warranted. The filesystem outcome is identical: a mode-0500 directory fails remove_dir_all immediately with no partial deletion. Only the reason string differs, and it is Rust-native. AC 12 is covered structurally by a hazard test rather than byte-pinned. The project root is canonicalized inside the skills path only, matching Python's get_project_root() realpath so lexical relpath targets agree. resolve_project_root(), sol root output, and check-native-sol-root-contract are unchanged. skills.rs is added to CORE_REQUIRED_SDIST_MEMBERS in scripts/check_wheel_contents.py because a new solstone-core-sol source file must ship in the core sdist. Co-Authored-By: OpenAI Codex --- core/crates/solstone-core-sol/src/lib.rs | 4 +- core/crates/solstone-core-sol/src/skills.rs | 1585 +++++++++++++++++++ core/crates/solstone-core/tests/skills.rs | 142 ++ scripts/check_wheel_contents.py | 1 + solstone/think/sol_compat_inventory.py | 1 - tests/test_sol_compat_cli.py | 19 + 6 files changed, 1750 insertions(+), 2 deletions(-) create mode 100644 core/crates/solstone-core-sol/src/skills.rs create mode 100644 core/crates/solstone-core/tests/skills.rs diff --git a/core/crates/solstone-core-sol/src/lib.rs b/core/crates/solstone-core-sol/src/lib.rs index 96ec6bb78..0e49e3f0e 100644 --- a/core/crates/solstone-core-sol/src/lib.rs +++ b/core/crates/solstone-core-sol/src/lib.rs @@ -33,6 +33,7 @@ use solstone_core_sol_client_cli::{ }; mod generated; +mod skills; use generated::journal_host_commands::{JOURNAL_HOST_COMMAND_COUNT, JOURNAL_HOST_COMMANDS}; @@ -49,7 +50,7 @@ const COMPAT_SENTINEL_ARMED: &str = "armed"; const COMPAT_ARGV0_MARKER_PREFIX: &str = "__solstone_native_argv0="; const COMPAT_RECURSION_ERROR: &str = "sol: compatibility dispatch recursion detected. Reinstall solstone and solstone-core."; -const TOP_LEVEL_COMPAT_COMMANDS: &[&str] = &["doctor", "check", "skills", "link"]; +const TOP_LEVEL_COMPAT_COMMANDS: &[&str] = &["doctor", "check", "link"]; pub fn run(public_argv0: &str, args: Vec) -> ExitCode { run_with_stdin_provider(public_argv0, args, &RealStdinProvider) @@ -80,6 +81,7 @@ fn run_with_stdin_provider( [command] if command == OsStr::new("--path") => run_plain_path(), [command] if command == OsStr::new("path") => run_path(), [command] if command == OsStr::new("status") => run_status(), + [command, rest @ ..] if command == OsStr::new("skills") => render_output(skills::run(rest)), [command, rest @ ..] if command == OsStr::new("call") => { run_call(public_argv0, &args, rest, stdin_provider) } diff --git a/core/crates/solstone-core-sol/src/skills.rs b/core/crates/solstone-core-sol/src/skills.rs new file mode 100644 index 000000000..c7a718853 --- /dev/null +++ b/core/crates/solstone-core-sol/src/skills.rs @@ -0,0 +1,1585 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 sol pbc + +use std::ffi::{OsStr, OsString}; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read, Write}; +use std::path::{Component, Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use solstone_core_journal::HomeError; +use solstone_core_sol_client::command::CommandOutput; + +use super::{EXIT_CONFIG, discover_binary_home, resolve_project_root}; + +const EXIT_ARGPARSE_USAGE: i32 = 2; +const USAGE: &str = "Usage: sol skills [args...]\n"; +const ALL_AGENTS: &str = "all"; +const PROJECT_MULTI_AGENT: &str = "agents"; +const PROJECT_CLAUDE_SKILLS_REL: &str = ".claude/skills"; +const PROJECT_AGENTS_SKILLS_REL: &str = ".agents/skills"; +const ROUTER_SKILL_NAMES: [&str; 2] = ["sol", "journal"]; +const USER_SKILL_NAME: &str = "sol"; +const GLOBAL_SKIP_MESSAGE: &str = + "no AI coding agent config directories found — skipping skill registration"; + +static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Agent { + Claude, + Codex, + Gemini, +} + +#[derive(Debug, Clone, Copy)] +struct AgentSpec { + name: &'static str, + parent_dir: &'static str, + skills_dir: &'static str, + silent_when_default_all: bool, +} + +impl Agent { + fn spec(self) -> AgentSpec { + match self { + Agent::Claude => AgentSpec { + name: "claude", + parent_dir: ".claude", + skills_dir: PROJECT_CLAUDE_SKILLS_REL, + silent_when_default_all: false, + }, + Agent::Codex => AgentSpec { + name: "codex", + parent_dir: ".codex", + skills_dir: ".codex/skills", + silent_when_default_all: false, + }, + Agent::Gemini => AgentSpec { + name: "gemini", + parent_dir: ".gemini", + skills_dir: ".gemini/skills", + silent_when_default_all: true, + }, + } + } +} + +const USER_AGENT_ORDER: [Agent; 3] = [Agent::Claude, Agent::Codex, Agent::Gemini]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AgentSelection { + All, + One(Agent), +} + +impl AgentSelection { + fn parse(value: &OsStr) -> Result { + if value == OsStr::new(ALL_AGENTS) { + return Ok(Self::All); + } + if value == OsStr::new("claude") { + return Ok(Self::One(Agent::Claude)); + } + if value == OsStr::new("codex") { + return Ok(Self::One(Agent::Codex)); + } + if value == OsStr::new("gemini") { + return Ok(Self::One(Agent::Gemini)); + } + Err(format!("unsupported --agent {}", value.to_string_lossy())) + } + + fn user_agents(self) -> (Vec, bool) { + match self { + Self::All => ( + USER_AGENT_ORDER.into_iter().map(Agent::spec).collect(), + true, + ), + Self::One(agent) => (vec![agent.spec()], false), + } + } +} + +#[derive(Debug, Clone)] +enum SkillsCommand { + Install(InstallArgs), + Uninstall(InstallArgs), + List(ListArgs), + Help, +} + +#[derive(Debug, Clone)] +struct InstallArgs { + agent: AgentSelection, + project: Option, +} + +#[derive(Debug, Clone)] +struct ListArgs { + project: Option, +} + +#[derive(Debug, Clone)] +struct RuntimeContext { + home: PathBuf, + cwd: PathBuf, + project_root: PathBuf, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Action { + Error, + Installed, + Noop, + Removed, + Replaced, + Skipped, + Warning, +} + +#[derive(Debug, Clone)] +struct ActionRow { + agent: String, + skill: String, + action: Action, + path: PathBuf, + reason: Option, +} + +#[derive(Debug, Default)] +struct InstallReport { + rows: Vec, +} + +impl InstallReport { + fn error_count(&self) -> usize { + self.rows + .iter() + .filter(|row| row.action == Action::Error) + .count() + } + + fn all_skipped(&self) -> bool { + !self.rows.is_empty() + && self.rows.iter().all(|row| { + row.action == Action::Skipped + && row + .reason + .as_deref() + .is_some_and(|reason| reason.starts_with("config dir absent at ")) + }) + } +} + +#[derive(Debug)] +struct StatusRow { + agent: String, + skill: String, + state: &'static str, +} + +pub(crate) fn run(args: &[OsString]) -> CommandOutput { + let command = match parse_command(args) { + Ok(command) => command, + Err(message) => return usage_error(message), + }; + if matches!(command, SkillsCommand::Help) { + return CommandOutput::success(USAGE); + } + let context = match real_context() { + Ok(context) => context, + Err(message) => { + return CommandOutput { + stdout: String::new(), + stderr: message, + exit: i32::from(EXIT_CONFIG), + }; + } + }; + run_with_context(command, &context) +} + +fn run_with_context(command: SkillsCommand, context: &RuntimeContext) -> CommandOutput { + match execute(command, context) { + Ok(output) => output, + Err(message) => CommandOutput::failure(format!("error: {message}\n"), 1), + } +} + +fn execute(command: SkillsCommand, context: &RuntimeContext) -> Result { + match command { + SkillsCommand::Install(args) => { + if let Some(project) = args.project { + let target = resolve_project_target(&project, &context.cwd, &context.home); + let report = install_project(&context.project_root, &target, args.agent)?; + Ok(report_output(report, "install")) + } else { + let skill_dir = resolve_user_skill(&context.project_root)?; + let report = install_user(&skill_dir, &context.home, args.agent); + Ok(report_output(report, "install")) + } + } + SkillsCommand::Uninstall(args) => { + if let Some(project) = args.project { + let target = resolve_project_target(&project, &context.cwd, &context.home); + let report = uninstall_project(&context.project_root, &target, args.agent)?; + Ok(report_output(report, "uninstall")) + } else { + let skill_dir = resolve_user_skill(&context.project_root)?; + let report = uninstall_user(&skill_dir, &context.home, args.agent); + Ok(report_output(report, "uninstall")) + } + } + SkillsCommand::List(args) => { + let rows = if let Some(project) = args.project { + let target = resolve_project_target(&project, &context.cwd, &context.home); + list_project_status(&context.project_root, &target)? + } else { + let skill_dir = resolve_user_skill(&context.project_root)?; + list_user_status(&skill_dir, &context.home) + }; + Ok(CommandOutput::success(status_output(&rows))) + } + SkillsCommand::Help => Ok(CommandOutput::success(USAGE)), + } +} + +fn real_context() -> Result { + let home = discover_binary_home().map_err(|HomeError::Unavailable| { + "sol: native skills home is unavailable. Reinstall solstone and solstone-core.\n" + .to_string() + })?; + let cwd = std::env::current_dir().map_err(|error| { + format!("sol: native skills cwd is unavailable: {error}. Reinstall solstone and solstone-core.\n") + })?; + let root = resolve_project_root().map_err(|error| { + format!( + "sol: native skills payload root is unavailable: {error}. Reinstall solstone and solstone-core.\n" + ) + })?; + let project_root = canonicalize_project_root_for_skills(root); + Ok(RuntimeContext { + home, + cwd, + project_root, + }) +} + +fn canonicalize_project_root_for_skills(root: PathBuf) -> PathBuf { + fs::canonicalize(&root).unwrap_or(root) +} + +fn parse_command(args: &[OsString]) -> Result { + let Some((command, rest)) = args.split_first() else { + return Err("missing subcommand".to_string()); + }; + if command == OsStr::new("-h") || command == OsStr::new("--help") { + return Ok(SkillsCommand::Help); + } + if command == OsStr::new("install") { + return parse_install_args(rest).map(SkillsCommand::Install); + } + if command == OsStr::new("uninstall") { + return parse_install_args(rest).map(SkillsCommand::Uninstall); + } + if command == OsStr::new("list") { + return parse_list_args(rest).map(SkillsCommand::List); + } + Err(format!("unknown subcommand {}", command.to_string_lossy())) +} + +fn parse_install_args(args: &[OsString]) -> Result { + let mut agent = AgentSelection::All; + let mut project = None; + let mut index = 0; + while index < args.len() { + let arg = &args[index]; + if arg == OsStr::new("--agent") { + index += 1; + let Some(value) = args.get(index) else { + return Err("--agent requires a value".to_string()); + }; + agent = AgentSelection::parse(value)?; + } else if let Some(value) = flag_value(arg, "--agent=") { + agent = AgentSelection::parse(OsStr::new(value))?; + } else if arg == OsStr::new("--project") { + if let Some(next) = args.get(index + 1) + && !next.to_string_lossy().starts_with('-') + { + project = Some(next.clone()); + index += 1; + } else { + project = Some(OsString::new()); + } + } else if let Some(value) = flag_value(arg, "--project=") { + project = Some(OsString::from(value)); + } else { + return Err(format!("unrecognized argument {}", arg.to_string_lossy())); + } + index += 1; + } + Ok(InstallArgs { agent, project }) +} + +fn parse_list_args(args: &[OsString]) -> Result { + let mut project = None; + let mut index = 0; + while index < args.len() { + let arg = &args[index]; + if arg == OsStr::new("--project") { + if let Some(next) = args.get(index + 1) + && !next.to_string_lossy().starts_with('-') + { + project = Some(next.clone()); + index += 1; + } else { + project = Some(OsString::new()); + } + } else if let Some(value) = flag_value(arg, "--project=") { + project = Some(OsString::from(value)); + } else { + return Err(format!("unrecognized argument {}", arg.to_string_lossy())); + } + index += 1; + } + Ok(ListArgs { project }) +} + +fn flag_value<'a>(arg: &'a OsStr, prefix: &str) -> Option<&'a str> { + arg.to_str()?.strip_prefix(prefix) +} + +fn usage_error(message: String) -> CommandOutput { + CommandOutput { + stdout: String::new(), + stderr: format!("{USAGE}sol skills: error: {message}\n"), + exit: EXIT_ARGPARSE_USAGE, + } +} + +fn resolve_project_target(value: &OsStr, cwd: &Path, home: &Path) -> PathBuf { + if value.is_empty() { + return resolve_non_strict(cwd, cwd, home); + } + resolve_non_strict(&expand_user(value, home), cwd, home) +} + +fn expand_user(value: &OsStr, home: &Path) -> PathBuf { + let text = value.to_string_lossy(); + if text == "~" { + return home.to_path_buf(); + } + if let Some(rest) = text.strip_prefix("~/") { + return home.join(rest); + } + PathBuf::from(value) +} + +fn resolve_non_strict(path: &Path, cwd: &Path, _home: &Path) -> PathBuf { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + cwd.join(path) + }; + let mut cursor = absolute.clone(); + let mut missing = Vec::new(); + while !cursor.exists() { + let Some(name) = cursor.file_name().map(OsString::from) else { + break; + }; + missing.push(name); + let Some(parent) = cursor.parent() else { + break; + }; + cursor = parent.to_path_buf(); + } + let mut resolved = fs::canonicalize(&cursor).unwrap_or(cursor); + for name in missing.iter().rev() { + resolved.push(name); + } + resolved +} + +fn resolve_user_skill(project_root: &Path) -> Result { + let skill_dir = project_root + .join("solstone") + .join("talent") + .join(USER_SKILL_NAME); + let skill_file = skill_dir.join("SKILL.md"); + if !skill_file.is_file() { + return Err(format!( + "expected bundled umbrella skill at solstone/talent/sol/SKILL.md ({})", + skill_file.display() + )); + } + Ok(skill_dir) +} + +fn discover_project_sources(project_root: &Path) -> Result, String> { + let mut sources = Vec::new(); + for name in ROUTER_SKILL_NAMES { + let source = project_root.join("solstone").join("talent").join(name); + let skill_file = source.join("SKILL.md"); + if !skill_file.is_file() { + return Err(format!( + "expected project skill at {}", + skill_file.display() + )); + } + sources.push(source); + } + sources.sort(); + Ok(sources) +} + +fn install_user(skill_dir: &Path, home: &Path, selection: AgentSelection) -> InstallReport { + let (selected, _default_all) = selection.user_agents(); + let mut report = InstallReport::default(); + for spec in selected { + let skills_root = home.join(spec.skills_dir); + if let Err(error) = fs::create_dir_all(&skills_root) { + append_error(&mut report.rows, spec.name, "", &skills_root, error); + continue; + } + let target = skills_root.join(USER_SKILL_NAME); + let mut action = Action::Installed; + match fs::symlink_metadata(&target) { + Ok(metadata) if metadata.file_type().is_symlink() => { + if let Err(error) = fs::remove_file(&target) { + append_error(&mut report.rows, spec.name, USER_SKILL_NAME, &target, error); + continue; + } + action = Action::Replaced; + } + Ok(metadata) if !metadata.file_type().is_dir() => { + if let Err(error) = fs::remove_file(&target) { + append_error(&mut report.rows, spec.name, USER_SKILL_NAME, &target, error); + continue; + } + action = Action::Replaced; + } + Ok(_) => { + match tree_matches(skill_dir, &target) { + Ok(true) => { + report.rows.push(ActionRow { + agent: spec.name.to_string(), + skill: USER_SKILL_NAME.to_string(), + action: Action::Noop, + path: target, + reason: None, + }); + continue; + } + Ok(false) => {} + Err(error) => { + append_error(&mut report.rows, spec.name, USER_SKILL_NAME, &target, error); + continue; + } + } + // Python checked os.access(target, W_OK) before rmtree. Native Rust + // intentionally omits that pre-check and lets remove_dir_all/write fail; + // the filesystem outcome is the same, but the reason string is Rust-native. + if let Err(error) = fs::remove_dir_all(&target) { + append_error(&mut report.rows, spec.name, USER_SKILL_NAME, &target, error); + continue; + } + action = Action::Replaced; + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => { + append_error(&mut report.rows, spec.name, USER_SKILL_NAME, &target, error); + continue; + } + } + if let Err(error) = copy_tree_files(skill_dir, &target) { + append_error(&mut report.rows, spec.name, USER_SKILL_NAME, &target, error); + continue; + } + report.rows.push(ActionRow { + agent: spec.name.to_string(), + skill: USER_SKILL_NAME.to_string(), + action, + path: target, + reason: None, + }); + } + report +} + +fn uninstall_user(skill_dir: &Path, home: &Path, selection: AgentSelection) -> InstallReport { + let (selected, default_all) = selection.user_agents(); + let mut report = InstallReport::default(); + for spec in selected { + let parent = home.join(spec.parent_dir); + if !parent.exists() { + if default_all && spec.silent_when_default_all { + continue; + } + report.rows.push(ActionRow { + agent: spec.name.to_string(), + skill: String::new(), + action: Action::Skipped, + path: parent.clone(), + reason: Some(format!("config dir absent at {}", parent.display())), + }); + continue; + } + let target = home.join(spec.skills_dir).join( + skill_dir + .file_name() + .unwrap_or_else(|| OsStr::new(USER_SKILL_NAME)), + ); + match fs::symlink_metadata(&target) { + Err(error) if error.kind() == io::ErrorKind::NotFound => { + report.rows.push(ActionRow { + agent: spec.name.to_string(), + skill: USER_SKILL_NAME.to_string(), + action: Action::Skipped, + path: target, + reason: Some("nothing to remove".to_string()), + }); + } + Err(error) => { + append_error(&mut report.rows, spec.name, USER_SKILL_NAME, &target, error) + } + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.file_type().is_dir() => { + report.rows.push(ActionRow { + agent: spec.name.to_string(), + skill: USER_SKILL_NAME.to_string(), + action: Action::Error, + path: target, + reason: Some("refusing to remove non-directory".to_string()), + }); + } + Ok(_) => { + if let Err(error) = fs::remove_dir_all(&target) { + append_error(&mut report.rows, spec.name, USER_SKILL_NAME, &target, error); + continue; + } + report.rows.push(ActionRow { + agent: spec.name.to_string(), + skill: USER_SKILL_NAME.to_string(), + action: Action::Removed, + path: target, + reason: None, + }); + } + } + } + report +} + +fn project_targets( + target: &Path, + selection: AgentSelection, +) -> Result, String> { + match selection { + AgentSelection::All => Ok(vec![ + ("claude", target.join(PROJECT_CLAUDE_SKILLS_REL)), + (PROJECT_MULTI_AGENT, target.join(PROJECT_AGENTS_SKILLS_REL)), + ]), + AgentSelection::One(Agent::Claude) => { + Ok(vec![("claude", target.join(PROJECT_CLAUDE_SKILLS_REL))]) + } + AgentSelection::One(agent) => Err(format!( + "--agent {} is not supported with --project; use --agent all or --agent claude", + agent.spec().name + )), + } +} + +fn install_project( + project_root: &Path, + target: &Path, + selection: AgentSelection, +) -> Result { + let sources = discover_project_sources(project_root)?; + let source_names = sources + .iter() + .filter_map(|source| source.file_name().map(OsString::from)) + .collect::>(); + let mut report = InstallReport::default(); + for (agent, link_parent) in project_targets(target, selection)? { + if let Err(error) = fs::create_dir_all(&link_parent) { + append_error(&mut report.rows, agent, "", &link_parent, error); + continue; + } + for source in &sources { + install_project_source(agent, source, &link_parent, &mut report); + } + remove_stale_project_links(agent, &link_parent, &source_names, &mut report); + } + Ok(report) +} + +fn install_project_source( + agent: &str, + source: &Path, + link_parent: &Path, + report: &mut InstallReport, +) { + let name = source + .file_name() + .and_then(OsStr::to_str) + .unwrap_or_default(); + let link = link_parent.join(name); + let target = lexical_relpath(source, link_parent); + match fs::symlink_metadata(&link) { + Ok(metadata) if metadata.file_type().is_symlink() => match fs::read_link(&link) { + Ok(existing) if existing == Path::new(&target) => { + report.rows.push(ActionRow { + agent: agent.to_string(), + skill: name.to_string(), + action: Action::Noop, + path: link, + reason: None, + }); + } + Ok(_) => { + if let Err(error) = + fs::remove_file(&link).and_then(|()| create_symlink(Path::new(&target), &link)) + { + append_error(&mut report.rows, agent, name, &link, error); + return; + } + report.rows.push(ActionRow { + agent: agent.to_string(), + skill: name.to_string(), + action: Action::Replaced, + path: link, + reason: None, + }); + } + Err(error) => append_error(&mut report.rows, agent, name, &link, error), + }, + Ok(_) => report.rows.push(ActionRow { + agent: agent.to_string(), + skill: name.to_string(), + action: Action::Warning, + path: link, + reason: Some("user content at target preserved".to_string()), + }), + Err(error) if error.kind() == io::ErrorKind::NotFound => { + if let Err(error) = create_symlink(Path::new(&target), &link) { + append_error(&mut report.rows, agent, name, &link, error); + return; + } + report.rows.push(ActionRow { + agent: agent.to_string(), + skill: name.to_string(), + action: Action::Installed, + path: link, + reason: None, + }); + } + Err(error) => append_error(&mut report.rows, agent, name, &link, error), + } +} + +fn remove_stale_project_links( + agent: &str, + link_parent: &Path, + source_names: &[OsString], + report: &mut InstallReport, +) { + if !link_parent.is_dir() { + return; + } + let mut links = match sorted_entries(link_parent) { + Ok(entries) => entries, + Err(error) => { + append_error(&mut report.rows, agent, "", link_parent, error); + return; + } + }; + for link in links.drain(..) { + let name_os = link.file_name().map(OsString::from).unwrap_or_default(); + if source_names.contains(&name_os) { + continue; + } + let name = name_os.to_string_lossy().to_string(); + match fs::symlink_metadata(&link) { + Ok(metadata) if metadata.file_type().is_symlink() => { + if let Err(error) = fs::remove_file(&link) { + append_error(&mut report.rows, agent, &name, &link, error); + continue; + } + report.rows.push(ActionRow { + agent: agent.to_string(), + skill: name, + action: Action::Removed, + path: link, + reason: Some("stale".to_string()), + }); + } + Ok(_) => report.rows.push(ActionRow { + agent: agent.to_string(), + skill: name, + action: Action::Warning, + path: link, + reason: Some("user content at stale target preserved".to_string()), + }), + Err(error) => append_error(&mut report.rows, agent, &name, &link, error), + } + } +} + +fn uninstall_project( + project_root: &Path, + target: &Path, + selection: AgentSelection, +) -> Result { + let sources = discover_project_sources(project_root)?; + let mut report = InstallReport::default(); + for (agent, link_parent) in project_targets(target, selection)? { + for source in &sources { + let name = source + .file_name() + .and_then(OsStr::to_str) + .unwrap_or_default(); + let link = link_parent.join(name); + match fs::symlink_metadata(&link) { + Err(error) if error.kind() == io::ErrorKind::NotFound => { + report.rows.push(ActionRow { + agent: agent.to_string(), + skill: name.to_string(), + action: Action::Skipped, + path: link, + reason: Some("nothing to remove".to_string()), + }); + } + Err(error) => append_error(&mut report.rows, agent, name, &link, error), + Ok(metadata) if !metadata.file_type().is_symlink() => { + report.rows.push(ActionRow { + agent: agent.to_string(), + skill: name.to_string(), + action: Action::Error, + path: link, + reason: Some("refusing to remove non-symlink".to_string()), + }); + } + Ok(_) => { + if let Err(error) = fs::remove_file(&link) { + append_error(&mut report.rows, agent, name, &link, error); + continue; + } + report.rows.push(ActionRow { + agent: agent.to_string(), + skill: name.to_string(), + action: Action::Removed, + path: link, + reason: None, + }); + } + } + } + } + Ok(report) +} + +fn list_user_status(skill_dir: &Path, home: &Path) -> Vec { + USER_AGENT_ORDER + .into_iter() + .map(|agent| { + let spec = agent.spec(); + let target = home.join(spec.skills_dir).join( + skill_dir + .file_name() + .unwrap_or_else(|| OsStr::new(USER_SKILL_NAME)), + ); + let state = if target.join("SKILL.md").is_file() { + "installed" + } else { + "not installed" + }; + StatusRow { + agent: spec.name.to_string(), + skill: USER_SKILL_NAME.to_string(), + state, + } + }) + .collect() +} + +fn list_project_status(project_root: &Path, target: &Path) -> Result, String> { + let sources = discover_project_sources(project_root)?; + let mut rows = Vec::new(); + for (agent, link_parent) in project_targets(target, AgentSelection::All)? { + for source in &sources { + let name = source + .file_name() + .and_then(OsStr::to_str) + .unwrap_or_default(); + let link = link_parent.join(name); + let expected = lexical_relpath(source, &link_parent); + let state = if fs::symlink_metadata(&link) + .is_ok_and(|metadata| metadata.file_type().is_symlink()) + && fs::read_link(&link).is_ok_and(|value| value == Path::new(&expected)) + { + "installed" + } else { + "not installed" + }; + rows.push(StatusRow { + agent: agent.to_string(), + skill: name.to_string(), + state, + }); + } + } + Ok(rows) +} + +fn report_output(report: InstallReport, operation: &str) -> CommandOutput { + let mut stdout = String::new(); + let mut stderr = String::new(); + let mut warnings = Vec::new(); + for row in &report.rows { + match row.action { + Action::Noop => {} + Action::Warning => warnings.push(row), + Action::Error => { + stderr.push_str(&format!( + "error: {operation} {}: {}\n", + row.path.display(), + row.reason.as_deref().unwrap_or_default() + )); + } + Action::Skipped => { + let skill = if row.skill.is_empty() { + String::new() + } else { + format!(" {}", row.skill) + }; + stdout.push_str(&format!( + "skipped {}{} ({})\n", + row.agent, + skill, + row.reason.as_deref().unwrap_or_default() + )); + } + Action::Removed if row.reason.is_some() => { + stdout.push_str(&format!( + "removed {} {} ({}) -> {}\n", + row.agent, + row.skill, + row.reason.as_deref().unwrap_or_default(), + row.path.display() + )); + } + Action::Removed => { + stdout.push_str(&format!( + "removed {} {} -> {}\n", + row.agent, + row.skill, + row.path.display() + )); + } + Action::Installed => { + stdout.push_str(&format!( + "installed {} {} -> {}\n", + row.agent, + row.skill, + row.path.display() + )); + } + Action::Replaced => { + stdout.push_str(&format!( + "replaced {} {} -> {}\n", + row.agent, + row.skill, + row.path.display() + )); + } + } + } + if !warnings.is_empty() { + stdout.push_str("Warnings:\n"); + for row in warnings { + stdout.push_str(&format!( + "warning {} {} -> {} ({})\n", + row.agent, + row.skill, + row.path.display(), + row.reason.as_deref().unwrap_or_default() + )); + } + } + if report.all_skipped() { + stdout.push_str(GLOBAL_SKIP_MESSAGE); + stdout.push('\n'); + } + CommandOutput { + stdout, + stderr, + exit: if report.error_count() > 0 { 1 } else { 0 }, + } +} + +fn status_output(rows: &[StatusRow]) -> String { + let mut output = format!("{:<10} {:<20} state\n", "agent", "skill"); + for row in rows { + output.push_str(&format!( + "{:<10} {:<20} {}\n", + row.agent, row.skill, row.state + )); + } + output +} + +fn append_error( + rows: &mut Vec, + agent: &str, + skill: &str, + path: &Path, + error: io::Error, +) { + rows.push(ActionRow { + agent: agent.to_string(), + skill: skill.to_string(), + action: Action::Error, + path: path.to_path_buf(), + reason: Some(error.to_string()), + }); +} + +fn tree_matches(src_dir: &Path, dst_dir: &Path) -> io::Result { + let src_files = collect_file_rel_paths(src_dir)?; + let dst_files = collect_file_rel_paths(dst_dir)?; + if src_files != dst_files { + return Ok(false); + } + for rel in src_files { + if fs::read(src_dir.join(&rel))? != fs::read(dst_dir.join(&rel))? { + return Ok(false); + } + } + Ok(true) +} + +fn copy_tree_files(src_dir: &Path, dst_dir: &Path) -> io::Result<()> { + fs::create_dir_all(dst_dir)?; + for rel in collect_file_rel_paths(src_dir)? { + copy_file_0600(&src_dir.join(&rel), &dst_dir.join(rel))?; + } + Ok(()) +} + +fn collect_file_rel_paths(root: &Path) -> io::Result> { + let mut output = Vec::new(); + collect_file_rel_paths_inner(root, root, &mut output)?; + output.sort(); + Ok(output) +} + +fn collect_file_rel_paths_inner( + root: &Path, + dir: &Path, + output: &mut Vec, +) -> io::Result<()> { + for entry in sorted_entries(dir)? { + let metadata = fs::symlink_metadata(&entry)?; + if metadata.file_type().is_dir() && !metadata.file_type().is_symlink() { + collect_file_rel_paths_inner(root, &entry, output)?; + continue; + } + if fs::metadata(&entry).is_ok_and(|metadata| metadata.is_file()) { + output.push(entry.strip_prefix(root).unwrap_or(&entry).to_path_buf()); + } + } + Ok(()) +} + +fn sorted_entries(dir: &Path) -> io::Result> { + let mut entries = fs::read_dir(dir)? + .map(|entry| entry.map(|entry| entry.path())) + .collect::>>()?; + entries.sort(); + Ok(entries) +} + +fn copy_file_0600(src: &Path, dst: &Path) -> io::Result<()> { + if let Some(parent) = dst.parent() { + fs::create_dir_all(parent)?; + } + let temp = temp_path(dst); + let result = (|| { + let mut input = File::open(src)?; + let mut output = create_temp_file_0600(&temp)?; + let mut buffer = Vec::new(); + input.read_to_end(&mut buffer)?; + output.write_all(&buffer)?; + output.sync_all()?; + drop(output); + set_mode_0600(&temp)?; + fs::rename(&temp, dst) + })(); + if result.is_err() { + let _ = fs::remove_file(&temp); + } + result +} + +fn temp_path(dst: &Path) -> PathBuf { + let count = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); + let name = format!(".tmp_{}_{}.tmp", std::process::id(), count); + dst.parent().unwrap_or_else(|| Path::new(".")).join(name) +} + +#[cfg(unix)] +fn create_temp_file_0600(path: &Path) -> io::Result { + use std::os::unix::fs::OpenOptionsExt; + OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path) +} + +#[cfg(not(unix))] +fn create_temp_file_0600(path: &Path) -> io::Result { + OpenOptions::new().write(true).create_new(true).open(path) +} + +#[cfg(unix)] +fn set_mode_0600(path: &Path) -> io::Result<()> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) +} + +#[cfg(not(unix))] +fn set_mode_0600(_path: &Path) -> io::Result<()> { + Ok(()) +} + +#[cfg(unix)] +fn create_symlink(target: &Path, link: &Path) -> io::Result<()> { + std::os::unix::fs::symlink(target, link) +} + +#[cfg(not(unix))] +fn create_symlink(_target: &Path, _link: &Path) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "symlink creation unsupported", + )) +} + +fn lexical_relpath(target: &Path, base: &Path) -> String { + let (target_root, target_parts) = lexical_parts(target); + let (base_root, base_parts) = lexical_parts(base); + if target_root != base_root { + return target.to_string_lossy().to_string(); + } + let mut common = 0; + while common < target_parts.len() + && common < base_parts.len() + && target_parts[common] == base_parts[common] + { + common += 1; + } + let mut out = PathBuf::new(); + for _ in common..base_parts.len() { + out.push(".."); + } + for part in &target_parts[common..] { + out.push(part); + } + if out.as_os_str().is_empty() { + ".".to_string() + } else { + out.to_string_lossy().to_string() + } +} + +fn lexical_parts(path: &Path) -> (bool, Vec) { + let mut rooted = false; + let mut parts = Vec::new(); + for component in path.components() { + match component { + Component::Prefix(prefix) => parts.push(prefix.as_os_str().to_os_string()), + Component::RootDir => rooted = true, + Component::CurDir => {} + Component::ParentDir => { + if parts.last().is_some_and(|part| part != "..") { + parts.pop(); + } else { + parts.push(OsString::from("..")); + } + } + Component::Normal(part) => parts.push(part.to_os_string()), + } + } + (rooted, parts) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + use std::fs; + use std::path::{Path, PathBuf}; + use std::process::Command; + use std::time::{SystemTime, UNIX_EPOCH}; + + use serde_json::Value; + + use super::*; + + fn unique_temp(name: &str) -> PathBuf { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time should be available") + .as_nanos(); + std::env::temp_dir().join(format!("solstone-core-sol-skills-{name}-{stamp}")) + } + + fn fixture_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../core/fixtures/native-sol/skills-parity-v1/vectors.json") + } + + fn source_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../..") + .canonicalize() + .expect("source checkout root should canonicalize") + } + + fn subst(text: &str, values: &BTreeMap) -> String { + let mut output = text.to_string(); + let mut replacements = values.iter().collect::>(); + replacements.sort_by_key(|(token, _value)| std::cmp::Reverse(token.len())); + for (token, value) in replacements { + output = output.replace(token, value); + } + output + } + + fn setup_vector(ops: &[Value], values: &BTreeMap) { + for op in ops { + let kind = op["op"].as_str().expect("setup op should have kind"); + let path = PathBuf::from(subst( + op["path"].as_str().expect("setup op should have path"), + values, + )); + match kind { + "mkdir" => fs::create_dir_all(&path).expect("mkdir setup"), + "write_file" | "mutate_file" => { + let content = op["content_b64"] + .as_str() + .expect("write setup should have content"); + let bytes = base64_decode(content); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("write setup parent"); + } + fs::write(&path, bytes).expect("write setup file"); + } + "symlink" => { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("symlink setup parent"); + } + create_symlink( + Path::new(&subst( + op["target"].as_str().expect("symlink setup target"), + values, + )), + &path, + ) + .expect("symlink setup"); + } + "remove" => { + if path.is_symlink() || path.is_file() { + fs::remove_file(&path).expect("remove file setup"); + } else if path.is_dir() { + fs::remove_dir_all(&path).expect("remove dir setup"); + } + } + "copy_user_skill" => { + copy_tree_files(&source_root().join("solstone/talent/sol"), &path) + .expect("copy user skill setup"); + } + "project_links" => { + let agent = op.get("agent").and_then(Value::as_str).unwrap_or("all"); + let project = path; + let link_parents = if agent == "claude" { + vec![project.join(PROJECT_CLAUDE_SKILLS_REL)] + } else { + vec![ + project.join(PROJECT_CLAUDE_SKILLS_REL), + project.join(PROJECT_AGENTS_SKILLS_REL), + ] + }; + for link_parent in link_parents { + fs::create_dir_all(&link_parent).expect("project link parent setup"); + for name in ["journal", "sol"] { + let source = source_root().join("solstone/talent").join(name); + let target = lexical_relpath(&source, &link_parent); + create_symlink(Path::new(&target), &link_parent.join(name)) + .expect("project symlink setup"); + } + } + } + other => panic!("unknown setup op {other}"), + } + } + } + + fn base64_decode(input: &str) -> Vec { + const TABLE: &[u8; 64] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut output = Vec::new(); + let mut buffer = 0_u32; + let mut bits = 0_u8; + for byte in input.bytes() { + if byte == b'=' { + break; + } + let value = TABLE + .iter() + .position(|candidate| *candidate == byte) + .expect("fixture base64 should be valid") as u32; + buffer = (buffer << 6) | value; + bits += 6; + while bits >= 8 { + bits -= 8; + output.push(((buffer >> bits) & 0xff) as u8); + } + } + output + } + + #[test] + fn skills_parity_vectors_match_native() { + let text = fs::read_to_string(fixture_root()).expect("read skills parity vectors"); + let fixture: Value = serde_json::from_str(&text).expect("parse skills parity vectors"); + assert_eq!(fixture["schema"], "native-sol-skills-parity-v1"); + let vectors = fixture["vectors"] + .as_array() + .expect("vectors should be array"); + assert_eq!(vectors.len(), 30); + + for vector in vectors { + let id = vector["id"].as_str().expect("vector id"); + let temp = unique_temp(id); + let root = source_root(); + let fake_root = temp.join("fake-root"); + fs::create_dir_all(fake_root.join("solstone/talent")).expect("fake root setup"); + let mut values = BTreeMap::new(); + values.insert("${PROJECT_ROOT}".to_string(), root.display().to_string()); + values.insert("${TEMP_ROOT}".to_string(), temp.display().to_string()); + values.insert("${FAKE_ROOT}".to_string(), fake_root.display().to_string()); + let home = PathBuf::from(subst(vector["home"].as_str().expect("home"), &values)); + let cwd = PathBuf::from(subst(vector["cwd"].as_str().expect("cwd"), &values)); + let project_root = PathBuf::from(subst( + vector["project_root"].as_str().expect("project_root"), + &values, + )); + fs::create_dir_all(&home).expect("create home"); + fs::create_dir_all(&cwd).expect("create cwd"); + values.insert("${HOME}".to_string(), home.display().to_string()); + values.insert("${CWD}".to_string(), cwd.display().to_string()); + setup_vector( + vector["setup"].as_array().expect("setup should be array"), + &values, + ); + let args = vector["argv"] + .as_array() + .expect("argv should be array") + .iter() + .skip(1) + .map(|item| OsString::from(subst(item.as_str().expect("argv item"), &values))) + .collect::>(); + let output = match parse_command(&args) { + Ok(command) => run_with_context( + command, + &RuntimeContext { + home: home.clone(), + cwd: cwd.clone(), + project_root, + }, + ), + Err(message) => usage_error(message), + }; + let expected = &vector["expected"]; + assert_eq!( + output.exit, + expected["exit"].as_i64().unwrap() as i32, + "{id} exit" + ); + assert_eq!( + output.stdout, + subst(expected["stdout"].as_str().expect("stdout"), &values), + "{id} stdout" + ); + if expected + .get("compare_stderr") + .and_then(Value::as_bool) + .unwrap_or(true) + { + assert_eq!( + output.stderr, + subst(expected["stderr"].as_str().expect("stderr"), &values), + "{id} stderr" + ); + } + let _ = fs::remove_dir_all(&temp); + } + } + + #[cfg(unix)] + #[test] + fn installed_user_files_are_0600() { + use std::os::unix::fs::PermissionsExt; + let temp = unique_temp("mode"); + let home = temp.join("home"); + let context = RuntimeContext { + home: home.clone(), + cwd: temp.clone(), + project_root: source_root(), + }; + let command = parse_command(&[ + OsString::from("install"), + OsString::from("--agent"), + OsString::from("claude"), + ]) + .expect("parse install"); + + let output = run_with_context(command, &context); + + assert_eq!(output.exit, 0); + let mode = fs::metadata(home.join(".claude/skills/sol/SKILL.md")) + .expect("installed skill metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + let _ = fs::remove_dir_all(temp); + } + + #[cfg(unix)] + #[test] + fn user_reinstall_noop_preserves_inode_and_mtime() { + use std::os::unix::fs::MetadataExt; + let temp = unique_temp("user-idempotent"); + let home = temp.join("home"); + let context = RuntimeContext { + home: home.clone(), + cwd: temp.clone(), + project_root: source_root(), + }; + let command = parse_command(&[ + OsString::from("install"), + OsString::from("--agent"), + OsString::from("claude"), + ]) + .expect("parse install"); + assert_eq!(run_with_context(command.clone(), &context).exit, 0); + let file = home.join(".claude/skills/sol/SKILL.md"); + let before = fs::metadata(&file).expect("before metadata"); + + assert_eq!(run_with_context(command, &context).exit, 0); + + let after = fs::metadata(&file).expect("after metadata"); + assert_eq!(before.ino(), after.ino()); + assert_eq!(before.mtime_nsec(), after.mtime_nsec()); + assert_eq!(before.mtime(), after.mtime()); + let _ = fs::remove_dir_all(temp); + } + + #[cfg(unix)] + #[test] + fn project_reinstall_noop_preserves_readlink_and_lstat_mtime() { + use std::os::unix::fs::MetadataExt; + let temp = unique_temp("project-idempotent"); + let project = temp.join("project"); + let context = RuntimeContext { + home: temp.join("home"), + cwd: temp.clone(), + project_root: source_root(), + }; + let command = parse_command(&[ + OsString::from("install"), + OsString::from("--project"), + OsString::from(project.as_os_str()), + OsString::from("--agent"), + OsString::from("all"), + ]) + .expect("parse project install"); + assert_eq!(run_with_context(command.clone(), &context).exit, 0); + let link = project.join(".claude/skills/journal"); + let before_link = fs::read_link(&link).expect("before readlink"); + let before_meta = fs::symlink_metadata(&link).expect("before lstat"); + + assert_eq!(run_with_context(command, &context).exit, 0); + + let after_link = fs::read_link(&link).expect("after readlink"); + let after_meta = fs::symlink_metadata(&link).expect("after lstat"); + assert_eq!(before_link, after_link); + assert_eq!(before_meta.mtime(), after_meta.mtime()); + assert_eq!(before_meta.mtime_nsec(), after_meta.mtime_nsec()); + let _ = fs::remove_dir_all(temp); + } + + #[cfg(unix)] + #[test] + fn user_install_replaces_symlink_without_touching_pointed_to_directory() { + let temp = unique_temp("symlink-replace"); + let home = temp.join("home"); + let external = temp.join("external"); + fs::create_dir_all(&external).expect("external dir"); + fs::write(external.join("keep.txt"), "keep\n").expect("external content"); + let link = home.join(".claude/skills/sol"); + fs::create_dir_all(link.parent().unwrap()).expect("link parent"); + create_symlink(&external, &link).expect("setup symlink"); + let context = RuntimeContext { + home: home.clone(), + cwd: temp.clone(), + project_root: source_root(), + }; + let command = parse_command(&[ + OsString::from("install"), + OsString::from("--agent"), + OsString::from("claude"), + ]) + .expect("parse install"); + + let output = run_with_context(command, &context); + + assert_eq!(output.exit, 0); + assert_eq!( + fs::read_to_string(external.join("keep.txt")).unwrap(), + "keep\n" + ); + assert!( + !fs::symlink_metadata(&link) + .unwrap() + .file_type() + .is_symlink() + ); + assert!(link.join("SKILL.md").is_file()); + let _ = fs::remove_dir_all(temp); + } + + #[test] + fn lexical_relpath_emits_multi_parent_string() { + let root = PathBuf::from("/tmp/solstone-root"); + let source = root.join("solstone/talent/journal"); + let base = root.join("scratch/skills-oracle/casework/p/deep/.claude/skills"); + let target = lexical_relpath(&source, &base); + assert!(target.starts_with("../../.."), "{target}"); + assert!(target.ends_with("solstone/talent/journal"), "{target}"); + } + + #[cfg(unix)] + #[test] + fn skills_canonicalizes_symlinked_checkout_root_for_project_links() { + let temp = unique_temp("symlinked-root"); + let real = temp.join("real"); + let linked = temp.join("linked"); + fs::create_dir_all(real.join("solstone/talent")).expect("real talent"); + fs::create_dir_all(real.join("solstone/talent/sol")).expect("real sol"); + fs::create_dir_all(real.join("solstone/talent/journal")).expect("real journal"); + fs::write(real.join("solstone/talent/sol/SKILL.md"), "---\n").expect("sol skill"); + fs::write(real.join("solstone/talent/journal/SKILL.md"), "---\n").expect("journal skill"); + create_symlink(&real, &linked).expect("linked checkout"); + let project = temp.join("project"); + let context = RuntimeContext { + home: temp.join("home"), + cwd: temp.clone(), + project_root: canonicalize_project_root_for_skills(linked.clone()), + }; + let command = parse_command(&[ + OsString::from("install"), + OsString::from("--project"), + OsString::from(project.as_os_str()), + OsString::from("--agent"), + OsString::from("claude"), + ]) + .expect("parse install"); + + let output = run_with_context(command, &context); + + assert_eq!(output.exit, 0); + let link_target = + fs::read_link(project.join(".claude/skills/journal")).expect("project journal link"); + let link_target = link_target.to_string_lossy(); + assert!(link_target.contains("real/solstone/talent/journal")); + assert!(!link_target.contains("linked")); + let _ = fs::remove_dir_all(temp); + } + + #[test] + fn row_order_matches_python_contract() { + let temp = unique_temp("row-order"); + let home = temp.join("home"); + let context = RuntimeContext { + home: home.clone(), + cwd: temp.clone(), + project_root: source_root(), + }; + let user = run_with_context( + parse_command(&[OsString::from("install")]).expect("parse user install"), + &context, + ); + assert!(user.stdout.find("claude").unwrap() < user.stdout.find("codex").unwrap()); + assert!(user.stdout.find("codex").unwrap() < user.stdout.find("gemini").unwrap()); + let project = temp.join("project"); + let output = run_with_context( + parse_command(&[ + OsString::from("install"), + OsString::from("--project"), + OsString::from(project.as_os_str()), + OsString::from("--agent"), + OsString::from("claude"), + ]) + .expect("parse project install"), + &context, + ); + assert!(output.stdout.find("journal").unwrap() < output.stdout.find(" sol ").unwrap()); + let _ = fs::remove_dir_all(temp); + } + + #[cfg(unix)] + #[test] + fn mixed_writability_installs_writable_agent_and_reports_unwritable_agent() { + use std::os::unix::fs::PermissionsExt; + let is_root = Command::new("id") + .arg("-u") + .output() + .ok() + .and_then(|output| String::from_utf8(output.stdout).ok()) + .is_some_and(|uid| uid.trim() == "0"); + if is_root { + return; + } + let temp = unique_temp("mixed-writability"); + let home = temp.join("home"); + let claude_target = home.join(".claude/skills/sol"); + copy_tree_files(&source_root().join("solstone/talent/sol"), &claude_target) + .expect("setup claude target"); + fs::write(claude_target.join("extra.txt"), "extra").expect("make mismatch"); + fs::set_permissions(&claude_target, fs::Permissions::from_mode(0o500)) + .expect("make claude target unwritable"); + let context = RuntimeContext { + home: home.clone(), + cwd: temp.clone(), + project_root: source_root(), + }; + let output = run_with_context( + parse_command(&[OsString::from("install")]).expect("parse install all"), + &context, + ); + fs::set_permissions(&claude_target, fs::Permissions::from_mode(0o700)) + .expect("restore permissions"); + + assert_eq!(output.exit, 1); + assert!( + output + .stderr + .contains(&format!("error: install {}", claude_target.display())) + ); + assert!(home.join(".codex/skills/sol/SKILL.md").is_file()); + let _ = fs::remove_dir_all(temp); + } +} diff --git a/core/crates/solstone-core/tests/skills.rs b/core/crates/solstone-core/tests/skills.rs new file mode 100644 index 000000000..835783ed9 --- /dev/null +++ b/core/crates/solstone-core/tests/skills.rs @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 sol pbc + +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::thread::sleep; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::{env, fs}; + +fn bin() -> &'static str { + env!("CARGO_BIN_EXE_solstone-core") +} + +fn temp_path(name: &str) -> PathBuf { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time should be available") + .as_nanos(); + env::temp_dir().join(format!("solstone-core-skills-{name}-{stamp}")) +} + +fn source_checkout_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .and_then(Path::parent) + .expect("workspace checkout root") + .to_path_buf() +} + +fn identity_arg(public_argv0: &str) -> String { + format!("__solstone_identity={public_argv0}") +} + +fn run_sol(program: &Path, cwd: &Path, home: &Path, args: &[&str]) -> Output { + for _ in 0..100 { + match Command::new(program) + .arg(identity_arg("sol")) + .args(args) + .current_dir(cwd) + .env("HOME", home) + .output() + { + Ok(output) => return output, + Err(error) if error.kind() == ErrorKind::ExecutableFileBusy => { + sleep(Duration::from_millis(20)); + } + Err(error) => panic!("fake solstone-core should execute: {error:?}"), + } + } + panic!( + "fake solstone-core stayed busy after retries: {}", + program.display() + ) +} + +fn copy_tree(src: &Path, dst: &Path) { + fs::create_dir_all(dst).expect("create copy destination"); + for entry in fs::read_dir(src).expect("read copy source") { + let entry = entry.expect("read copy entry"); + let source = entry.path(); + let target = dst.join(entry.file_name()); + let metadata = fs::symlink_metadata(&source).expect("copy source metadata"); + if metadata.is_dir() { + copy_tree(&source, &target); + } else if metadata.is_file() { + fs::copy(&source, &target).expect("copy file"); + } + } +} + +#[test] +fn sol_skills_uses_relocated_installed_payload_root() { + let temp = temp_path("relocated-installed-layout"); + let staged_root = temp.join("staged"); + let moved_root = temp.join("moved"); + let bin_dir = staged_root.join("bin"); + let site_packages = staged_root + .join("lib") + .join("python3.13") + .join("site-packages"); + fs::create_dir_all(&bin_dir).expect("create staged bin dir"); + fs::create_dir_all(site_packages.join("solstone")).expect("create staged package dir"); + fs::write(site_packages.join("solstone").join("__init__.py"), "").expect("write init"); + copy_tree( + &source_checkout_root().join("solstone/talent"), + &site_packages.join("solstone/talent"), + ); + + let staged_binary = bin_dir.join("solstone-core"); + fs::copy(bin(), &staged_binary).expect("copy solstone-core binary into fake layout"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&staged_binary, fs::Permissions::from_mode(0o755)) + .expect("make fake solstone-core executable"); + } + + fs::rename(&staged_root, &moved_root).expect("move fake installed tree"); + let moved_binary = moved_root.join("bin").join("solstone-core"); + let moved_site_packages = moved_root + .join("lib") + .join("python3.13") + .join("site-packages"); + let unrelated_cwd = temp.join("outside-checkout"); + let home = temp.join("home"); + fs::create_dir_all(&unrelated_cwd).expect("create unrelated cwd"); + fs::create_dir_all(&home).expect("create isolated home"); + + let root = run_sol(&moved_binary, &unrelated_cwd, &home, &["root"]); + + assert_eq!(root.status.code(), Some(0)); + assert_eq!( + String::from_utf8(root.stdout).expect("root stdout should be utf-8"), + format!("{}\n", moved_site_packages.display()) + ); + assert_eq!( + String::from_utf8(root.stderr).expect("root stderr should be utf-8"), + "" + ); + + fs::remove_dir_all(moved_site_packages.join("solstone/talent/sol")) + .expect("remove moved sol payload"); + let missing_payload = run_sol(&moved_binary, &unrelated_cwd, &home, &["skills", "list"]); + + assert_ne!(missing_payload.status.code(), Some(0)); + assert_eq!( + String::from_utf8(missing_payload.stdout).expect("skills stdout should be utf-8"), + "" + ); + assert_eq!( + String::from_utf8(missing_payload.stderr).expect("skills stderr should be utf-8"), + format!( + "error: expected bundled umbrella skill at solstone/talent/sol/SKILL.md ({})\n", + moved_site_packages + .join("solstone/talent/sol/SKILL.md") + .display() + ) + ); + fs::remove_dir_all(temp).expect("cleanup relocated installed layout"); +} diff --git a/scripts/check_wheel_contents.py b/scripts/check_wheel_contents.py index 895019858..34eb74f7c 100644 --- a/scripts/check_wheel_contents.py +++ b/scripts/check_wheel_contents.py @@ -83,6 +83,7 @@ CORE_REQUIRED_SDIST_MEMBERS = { "core/crates/solstone-core-sol/src/generated/journal_host_commands.rs", "core/crates/solstone-core-sol/src/generated/mod.rs", "core/crates/solstone-core-sol/src/lib.rs", + "core/crates/solstone-core-sol/src/skills.rs", "core/crates/solstone-core-sol-client/Cargo.toml", "core/crates/solstone-core-sol-client/src/aggregate.rs", "core/crates/solstone-core-sol-client/src/command.rs", diff --git a/solstone/think/sol_compat_inventory.py b/solstone/think/sol_compat_inventory.py index c8299cf79..e87f7d11d 100644 --- a/solstone/think/sol_compat_inventory.py +++ b/solstone/think/sol_compat_inventory.py @@ -25,7 +25,6 @@ PUBLIC_BINARIES = frozenset({"sol", "solstone"}) TOP_LEVEL_COMPAT_MODULES = { "doctor": "solstone.think.doctor", "check": "solstone.think.check", - "skills": "solstone.think.skills_cli", "link": "solstone.think.link", } diff --git a/tests/test_sol_compat_cli.py b/tests/test_sol_compat_cli.py index 0fed55934..69f15a41e 100644 --- a/tests/test_sol_compat_cli.py +++ b/tests/test_sol_compat_cli.py @@ -5,6 +5,7 @@ from __future__ import annotations import io import os +import re import sys from pathlib import Path @@ -26,6 +27,8 @@ from solstone.think.sol_compat_inventory import ( marker_for_public_argv0, ) +REPO_ROOT = Path(__file__).resolve().parents[1] + def _capture_forwarding( monkeypatch: pytest.MonkeyPatch, @@ -99,6 +102,22 @@ def test_allowed_top_level_commands_forward_exact_process_state( ] +def test_rust_and_python_top_level_compat_collections_match() -> None: + source = ( + REPO_ROOT / "core/crates/solstone-core-sol/src/lib.rs" + ).read_text(encoding="utf-8") + match = re.search( + r"const\s+TOP_LEVEL_COMPAT_COMMANDS\s*:\s*&\[\s*&str\s*\]\s*=\s*&\[(?P.*?)\];", + source, + re.DOTALL, + ) + assert match is not None + rust_commands = re.findall(r'"([^"]+)"', match.group("body")) + + assert len(rust_commands) == len(set(rust_commands)) + assert set(rust_commands) == set(TOP_LEVEL_COMPAT_MODULES) + + JOURNAL_ERRORS, JOURNAL_PATHS = frozen_journal_remainder_paths() assert not JOURNAL_ERRORS assert JOURNAL_PATHS -- 2.51.2