use std::env;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use anyhow::{Context, Result, bail};
use serde::Serialize;
use serde::de::DeserializeOwned;
static TEMPORARY_COUNTER: AtomicU64 = AtomicU64::new(0);
pub fn output_bounded(
command: &mut Command,
action: &str,
timeout: Duration,
) -> Result<(ExitStatus, Vec<u8>, Vec<u8>)> {
let mut child = command
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.with_context(|| format!("starting subprocess while {action}"))?;
let mut stdout = child.stdout.take().context("capturing subprocess stdout")?;
let mut stderr = child.stderr.take().context("capturing subprocess stderr")?;
let stdout_reader = std::thread::spawn(move || {
let mut bytes = Vec::new();
stdout.read_to_end(&mut bytes).map(|_| bytes)
});
let stderr_reader = std::thread::spawn(move || {
let mut bytes = Vec::new();
stderr.read_to_end(&mut bytes).map(|_| bytes)
});
let started = Instant::now();
let status = loop {
if let Some(status) = child.try_wait().context("checking subprocess status")? {
break status;
}
if started.elapsed() >= timeout {
let _ = child.kill();
let _ = child.wait();
drop(stdout_reader);
drop(stderr_reader);
bail!("subprocess exceeded {}s while {action}", timeout.as_secs());
}
std::thread::sleep(Duration::from_millis(25));
};
let stdout = stdout_reader
.join()
.map_err(|_| anyhow::anyhow!("subprocess stdout reader failed"))??;
let stderr = stderr_reader
.join()
.map_err(|_| anyhow::anyhow!("subprocess stderr reader failed"))??;
Ok((status, stdout, stderr))
}
pub fn config_root() -> Result<PathBuf> {
if let Some(path) = env::var_os("SKILLER_CONFIG_HOME") {
return Ok(PathBuf::from(path));
}
if let Some(path) = env::var_os("XDG_CONFIG_HOME") {
return Ok(PathBuf::from(path).join("skiller"));
}
Ok(home_dir()?.join(".config/skiller"))
}
pub fn cache_root() -> Result<PathBuf> {
if let Some(path) = env::var_os("SKILLER_CACHE_HOME") {
return Ok(PathBuf::from(path));
}
if let Some(path) = env::var_os("XDG_CACHE_HOME") {
return Ok(PathBuf::from(path).join("skiller"));
}
Ok(home_dir()?.join(".cache/skiller"))
}
pub fn state_root() -> Result<PathBuf> {
if let Some(path) = env::var_os("SKILLER_STATE_HOME") {
return Ok(PathBuf::from(path));
}
if let Some(path) = env::var_os("XDG_STATE_HOME") {
return Ok(PathBuf::from(path).join("skiller"));
}
Ok(home_dir()?.join(".local/state/skiller"))
}
pub fn global_config_path() -> Result<PathBuf> {
Ok(config_root()?.join("config.json"))
}
pub fn global_state_path() -> Result<PathBuf> {
Ok(state_root()?.join("installed.json"))
}
pub fn global_skills_root() -> Result<PathBuf> {
Ok(home_dir()?.join(".agents/skills"))
}
fn git_path(project_root: &Path, argument: &str, label: &str) -> Result<PathBuf> {
let output = Command::new("git")
.args(["rev-parse", argument])
.current_dir(project_root)
.output()
.with_context(|| format!("resolving {label}"))?;
if !output.status.success() {
bail!("project configuration requires a Git repository");
}
let value = String::from_utf8_lossy(&output.stdout).trim().to_owned();
if value.is_empty() {
bail!("Git returned an empty {label}");
}
let path = PathBuf::from(value);
let path = if path.is_absolute() {
path
} else {
project_root.join(path)
};
path.canonicalize()
.with_context(|| format!("resolving {label} at {}", path.display()))
}
pub fn project_root() -> Result<PathBuf> {
let cwd = env::current_dir().context("reading current directory")?;
git_path(&cwd, "--show-toplevel", "Git project root")
}
pub(crate) fn project_config_path(project_root: &Path) -> Result<PathBuf> {
Ok(
git_path(project_root, "--git-common-dir", "Git common directory")?
.join("skiller/config.json"),
)
}
pub(crate) fn project_state_root(project_root: &Path) -> Result<PathBuf> {
Ok(git_path(project_root, "--git-dir", "Git directory")?.join("skiller"))
}
pub fn read_json_or_default<T>(path: &Path) -> Result<T>
where
T: DeserializeOwned + Default,
{
match std::fs::read_to_string(path) {
Ok(raw) => {
serde_json::from_str(&raw).with_context(|| format!("parsing {}", path.display()))
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(T::default()),
Err(error) => Err(error).with_context(|| format!("reading {}", path.display())),
}
}
pub fn validate_managed_json_path(path: &Path) -> Result<()> {
let parent = path.parent().context("managed JSON path has no parent")?;
match std::fs::symlink_metadata(parent) {
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => {
bail!(
"managed state parent must be a real directory: {}",
parent.display()
)
}
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(error).with_context(|| format!("inspecting {}", parent.display()));
}
}
match std::fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
bail!("managed state must be a real file: {}", path.display())
}
Ok(_) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error).with_context(|| format!("inspecting {}", path.display())),
}
}
pub fn read_json<T: DeserializeOwned>(path: &Path) -> Result<T> {
let raw =
std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
serde_json::from_str(&raw).with_context(|| format!("parsing {}", path.display()))
}
pub fn write_json_atomic<T: Serialize>(path: &Path, value: &T) -> Result<()> {
write_json_atomic_bytes(path, serde_json::to_vec_pretty(value)?)
}
pub fn write_json_atomic_compact<T: Serialize>(path: &Path, value: &T) -> Result<()> {
write_json_atomic_bytes(path, serde_json::to_vec(value)?)
}
pub fn write_json_exclusive_compact<T: Serialize>(path: &Path, value: &T) -> Result<()> {
let parent = path.parent().context("JSON path has no parent")?;
ensure_real_dir(parent)?;
let bytes = serde_json::to_vec(value)?;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
.with_context(|| format!("acquiring exclusive transaction {}", path.display()))?;
if let Err(error) = file
.write_all(&bytes)
.and_then(|_| file.write_all(b"\n"))
.and_then(|_| file.sync_all())
{
drop(file);
let _ = std::fs::remove_file(path);
return Err(error).with_context(|| format!("writing {}", path.display()));
}
Ok(())
}
fn write_json_atomic_bytes(path: &Path, bytes: Vec<u8>) -> Result<()> {
let parent = path.parent().context("JSON path has no parent")?;
ensure_real_dir(parent)?;
if let Ok(metadata) = std::fs::symlink_metadata(path)
&& metadata.file_type().is_symlink()
{
bail!("refusing to replace symlinked file: {}", path.display());
}
let temporary = parent.join(format!(
".{}.{}.{}.tmp",
path.file_name().unwrap_or_default().to_string_lossy(),
std::process::id(),
TEMPORARY_COUNTER.fetch_add(1, Ordering::Relaxed)
));
let write_result = (|| -> Result<()> {
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&temporary)
.with_context(|| format!("creating {}", temporary.display()))?;
file.write_all(&bytes)
.and_then(|_| file.write_all(b"\n"))
.with_context(|| format!("writing {}", temporary.display()))?;
file.sync_all()
.with_context(|| format!("syncing {}", temporary.display()))?;
Ok(())
})();
if let Err(error) = write_result {
let _ = std::fs::remove_file(&temporary);
return Err(error);
}
if let Err(error) = std::fs::rename(&temporary, path) {
let _ = std::fs::remove_file(&temporary);
return Err(error).with_context(|| format!("committing {}", path.display()));
}
Ok(())
}
pub fn write_global_config(value: &crate::model::GlobalConfig) -> Result<()> {
let path = global_config_path()?;
let destination = match std::fs::symlink_metadata(&path) {
Ok(metadata) if metadata.file_type().is_symlink() => {
let target = std::fs::read_link(&path)
.with_context(|| format!("reading global config symlink {}", path.display()))?;
if target.is_absolute() {
target
} else {
path.parent()
.context("global config symlink has no parent")?
.join(target)
}
}
Ok(_) => path,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => path,
Err(error) => return Err(error).context("inspecting global config path"),
};
write_json_atomic(&destination, value)
}
pub fn ensure_real_dir(path: &Path) -> Result<()> {
if path.exists() {
let metadata = std::fs::symlink_metadata(path)?;
if metadata.file_type().is_symlink() || !metadata.is_dir() {
bail!("expected a real directory: {}", path.display());
}
return Ok(());
}
if let Some(parent) = path.parent()
&& parent != path
{
ensure_real_dir(parent)?;
}
std::fs::create_dir(path).with_context(|| format!("creating {}", path.display()))
}
pub fn safe_remove_owned_dir(path: &Path, allowed_parent: &Path) -> Result<()> {
let parent = path.parent().context("owned path has no parent")?;
if parent != allowed_parent || path.file_name().is_none() {
bail!(
"refusing to remove path outside managed root: {}",
path.display()
);
}
match std::fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() => {
bail!(
"refusing to remove symlinked managed path: {}",
path.display()
)
}
Ok(metadata) if metadata.is_dir() => {
std::fs::remove_dir_all(path).with_context(|| format!("removing {}", path.display()))
}
Ok(_) => bail!("managed path is not a directory: {}", path.display()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error).with_context(|| format!("inspecting {}", path.display())),
}
}
pub fn copy_tree(source: &Path, destination: &Path) -> Result<()> {
let metadata = std::fs::symlink_metadata(source)
.with_context(|| format!("inspecting {}", source.display()))?;
if metadata.file_type().is_symlink() || !metadata.is_dir() {
bail!(
"skill source must be a real directory: {}",
source.display()
);
}
ensure_real_dir(destination)?;
for entry in std::fs::read_dir(source)? {
let entry = entry?;
let source_path = entry.path();
let destination_path = destination.join(entry.file_name());
let file_type = entry.file_type()?;
if file_type.is_symlink() {
bail!(
"skill contains an unsupported symlink: {}",
source_path.display()
);
}
if file_type.is_dir() {
copy_tree(&source_path, &destination_path)?;
} else if file_type.is_file() {
std::fs::copy(&source_path, &destination_path)
.with_context(|| format!("copying {}", source_path.display()))?;
}
}
Ok(())
}
pub fn sanitize_child_output(bytes: &[u8]) -> String {
String::from_utf8_lossy(bytes)
.chars()
.map(|character| {
if character == '\n' || character == '\t' || !character.is_control() {
character
} else {
'�'
}
})
.collect()
}
fn home_dir() -> Result<PathBuf> {
env::var_os("HOME")
.map(PathBuf::from)
.context("HOME is not configured")
}
pub(crate) fn expand_home_path(value: &str) -> Result<PathBuf> {
if value == "~" {
return home_dir();
}
if let Some(relative) = value.strip_prefix("~/") {
return Ok(home_dir()?.join(relative));
}
Ok(PathBuf::from(value))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn home_relative_paths_expand_portably() {
assert_eq!(
expand_home_path("~/dotfiles").unwrap(),
home_dir().unwrap().join("dotfiles")
);
assert_eq!(
expand_home_path("relative/path").unwrap(),
PathBuf::from("relative/path")
);
}
#[cfg(unix)]
#[test]
fn managed_state_rejects_symlinked_file_and_parent() {
use std::os::unix::fs::symlink;
let base = std::env::current_dir()
.unwrap()
.join("target/test-work/managed-state-symlink");
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join("real")).unwrap();
std::fs::write(base.join("real/state.json"), "{}").unwrap();
symlink(base.join("real/state.json"), base.join("linked.json")).unwrap();
assert!(validate_managed_json_path(&base.join("linked.json")).is_err());
symlink(base.join("real"), base.join("linked-parent")).unwrap();
assert!(validate_managed_json_path(&base.join("linked-parent/state.json")).is_err());
std::fs::remove_dir_all(&base).unwrap();
}
#[test]
fn exclusive_json_write_acquires_once() {
let path = std::env::current_dir()
.unwrap()
.join("target/test-work/exclusive-json/transaction.json");
let _ = std::fs::remove_dir_all(path.parent().unwrap());
write_json_exclusive_compact(&path, &serde_json::json!({ "phase": "prepared" })).unwrap();
assert!(
write_json_exclusive_compact(&path, &serde_json::json!({ "phase": "other" })).is_err()
);
assert_eq!(
read_json::<serde_json::Value>(&path).unwrap()["phase"],
"prepared"
);
std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
}
#[cfg(unix)]
#[test]
fn bounded_output_stops_a_hung_subprocess() {
let mut command = Command::new("sleep");
command.arg("1");
let started = Instant::now();
assert!(
output_bounded(&mut command, "testing timeout", Duration::from_millis(20)).is_err()
);
assert!(started.elapsed() < Duration::from_secs(1));
}
#[test]
fn child_output_strips_terminal_controls() {
assert_eq!(
sanitize_child_output(b"ok\n\x1b]8;;bad\x07link"),
"ok\n�]8;;bad�link"
);
}
#[test]
fn linked_worktrees_share_policy_but_not_installation_state() {
let base = std::env::current_dir()
.unwrap()
.join("target/test-work/git-storage-scope");
let repository = base.join("repository");
let worktree = base.join("linked");
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&repository).unwrap();
assert!(
Command::new("git")
.arg("init")
.arg(&repository)
.status()
.unwrap()
.success()
);
assert!(
Command::new("git")
.args([
"-c",
"user.name=Skiller Test",
"-c",
"user.email=test@example.invalid"
])
.args(["commit", "--allow-empty", "-m", "initial"])
.current_dir(&repository)
.status()
.unwrap()
.success()
);
assert!(
Command::new("git")
.args(["worktree", "add", "-b", "linked-test"])
.arg(&worktree)
.current_dir(&repository)
.status()
.unwrap()
.success()
);
assert_eq!(
project_config_path(&repository).unwrap(),
project_config_path(&worktree).unwrap()
);
assert_ne!(
project_state_root(&repository).unwrap(),
project_state_root(&worktree).unwrap()
);
std::fs::remove_dir_all(&base).unwrap();
}
}