use serde::{Deserialize, Serialize};
use std::ffi::OsString;
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use thiserror::Error;
use super::super::{ENV_CONV_BRANCH, ENV_CONV_REPO};
use super::dispatch::EnvLookup;
use crate::harness_root;
use crate::workspace;
const SKILLS_DIR: &str = "skills";
const ENV_LERNIE_HOME: &str = "LERNIE_HOME";
const ENV_XDG_DATA: &str = "XDG_DATA_HOME";
const ENV_HOME: &str = "HOME";
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Input {
name: String,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
struct Output<'a> {
status: &'a str,
path: String,
}
const STATUS_LOADED: &str = "loaded";
const STATUS_ALREADY_LOADED: &str = "already_loaded";
#[derive(Debug, Error)]
pub enum Error {
#[error("invalid input JSON: {0}")]
InvalidJson(#[source] serde_json::Error),
#[error("read input from stdin: {0}")]
StdinRead(#[source] io::Error),
#[error("missing env var {0:?} (set by the harness per ARCH §3.3)")]
MissingEnv(&'static str),
#[error("resolve data root: {0}")]
Root(#[source] harness_root::Error),
#[error("skill name {0:?} is not a single path component (ARCH §3.3)")]
BadName(String),
#[error("unknown skill {name:?}; available: {available}")]
Unknown { name: String, available: String },
#[error("copy skill {name:?} into worktree: {source}")]
Copy {
name: String,
#[source]
source: io::Error,
},
#[error("write to stdout: {0}")]
Write(#[source] io::Error),
}
pub fn run<R: Read, W: Write>(
stdin: &mut R,
stdout: &mut W,
env: &dyn EnvLookup,
) -> Result<(), Error> {
let mut buf = Vec::new();
stdin.read_to_end(&mut buf).map_err(Error::StdinRead)?;
let input: Input = serde_json::from_slice(&buf).map_err(Error::InvalidJson)?;
let name = input.name;
if !crate::name::is_component(&name) {
return Err(Error::BadName(name));
}
let repo = require_env(env, ENV_CONV_REPO)?;
let branch = require_env(env, ENV_CONV_BRANCH)?
.into_string()
.map_err(|_| Error::MissingEnv(ENV_CONV_BRANCH))?;
let worktree = workspace::agent_worktree(Path::new(&repo), &branch);
let dest = worktree.join(SKILLS_DIR).join(&name);
let rel = format!("{SKILLS_DIR}/{name}");
if dest.is_dir() {
return emit(stdout, STATUS_ALREADY_LOADED, rel);
}
let src = skills_pool(env)?.join(&name);
if !src.is_dir() {
let available = available(&skills_pool(env)?);
return Err(Error::Unknown { name, available });
}
copy_dir(&src, &dest).map_err(|source| Error::Copy {
name: name.clone(),
source,
})?;
emit(stdout, STATUS_LOADED, rel)
}
fn skills_pool(env: &dyn EnvLookup) -> Result<PathBuf, Error> {
let override_v = env.get(ENV_LERNIE_HOME);
let xdg_data = env.get(ENV_XDG_DATA);
let home = env.get(ENV_HOME);
let roots = harness_root::resolve_from(
override_v.as_deref(),
None,
xdg_data.as_deref(),
home.as_deref().map(Path::new),
)
.map_err(Error::Root)?;
Ok(roots.data.join(SKILLS_DIR))
}
fn available(pool: &Path) -> String {
let mut names: Vec<String> = match std::fs::read_dir(pool) {
Ok(rd) => rd
.flatten()
.filter(|e| e.path().is_dir())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect(),
Err(_) => Vec::new(),
};
names.sort();
crate::name::pool(&names)
}
fn copy_dir(src: &Path, dest: &Path) -> io::Result<()> {
std::fs::create_dir_all(dest)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let from = entry.path();
let to = dest.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_dir(&from, &to)?;
} else {
std::fs::copy(&from, &to)?;
}
}
Ok(())
}
fn emit<W: Write>(stdout: &mut W, status: &str, path: String) -> Result<(), Error> {
let payload = Output { status, path };
let bytes = serde_json::to_vec(&payload).expect("Output is always serializable");
stdout.write_all(&bytes).map_err(Error::Write)
}
fn require_env(env: &dyn EnvLookup, key: &'static str) -> Result<OsString, Error> {
env.get(key).ok_or(Error::MissingEnv(key))
}
#[cfg(test)]
mod tests;