use std::collections::BTreeMap;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use walkdir::WalkDir;
use crate::config::{Config, DomainName, SkillName};
use crate::context::{DirectoryConfig, read_directory_config};
use crate::launch::{LaunchError, expand_path};
use crate::prompt::PromptError;
const BEGIN_MARKER: &str = "<!-- clanker:bake:begin -->";
const END_MARKER: &str = "<!-- clanker:bake:end -->";
const LOCK_PATH: &str = ".clanker/bake.lock";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BakeMode {
DryRun,
Check,
Write,
Force,
}
#[derive(Debug, Clone, Serialize)]
pub struct BakeReport {
pub domain: String,
pub profiles: Vec<String>,
pub skills: Vec<String>,
pub changed_files: Vec<String>,
pub unchanged_files: Vec<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum BakeError {
#[error(transparent)]
Context(#[from] crate::context::ContextError),
#[error("unknown domain `{0}` in clanker config")]
UnknownDomain(String),
#[error(transparent)]
Launch(#[from] LaunchError),
#[error(transparent)]
Prompt(#[from] PromptError),
#[error("rendered prompt is not valid UTF-8: {0}")]
NonUtf8(#[from] std::string::FromUtf8Error),
#[error("invalid bake family `{family}`: {source}")]
Family {
family: &'static str,
source: prompter::InvalidFamilyName,
},
#[error("clanker bake requires directory-form .clanker/config.toml; {path} is a flat file")]
FlatRepoConfig {
path: PathBuf,
},
#[error("configured skill `{skill}` is missing {path}")]
MissingSkill {
skill: SkillName,
path: PathBuf,
},
#[error("failed to {operation} {path}: {source}")]
Io {
operation: &'static str,
path: PathBuf,
source: std::io::Error,
},
#[error("bake lock TOML error in {path}: {source}")]
LockToml {
path: PathBuf,
source: Box<dyn std::error::Error + Send + Sync>,
},
#[error("refusing to overwrite hand-edited baked file {path}; rerun with --force")]
HandEdited {
path: String,
},
#[error("baked repo is stale: {}", issues.join("; "))]
Drift {
issues: Vec<String>,
},
#[error("skill `{skill}` contains symlink {path}; vendored skills must be real files")]
SkillSymlink {
skill: SkillName,
path: PathBuf,
},
}
pub fn bake_repo(
repo: &Path,
config: &Config,
home: &Path,
mode: BakeMode,
) -> Result<BakeReport, BakeError> {
let directory_config = read_directory_config(repo)?;
ensure_lock_directory_available(repo, mode)?;
if directory_config.is_none() && matches!(mode, BakeMode::Write | BakeMode::Force) {
write_default_repo_config(repo, &config.defaults.domain)?;
}
let plan = build_bake_plan(repo, config, home, directory_config.as_ref())?;
let previous = read_lock(repo)?;
let report = plan.report(previous.as_ref());
match mode {
BakeMode::DryRun => Ok(report),
BakeMode::Check => {
let issues = plan.drift_issues(previous.as_ref());
if issues.is_empty() {
Ok(report)
} else {
Err(BakeError::Drift { issues })
}
}
BakeMode::Write | BakeMode::Force => {
plan.ensure_not_hand_edited(previous.as_ref(), mode)?;
plan.write(mode)?;
Ok(report)
}
}
}
fn ensure_lock_directory_available(repo: &Path, mode: BakeMode) -> Result<(), BakeError> {
let clanker = repo.join(".clanker");
if clanker.is_file() {
return Err(BakeError::FlatRepoConfig { path: clanker });
}
if matches!(mode, BakeMode::Write | BakeMode::Force) {
fs::create_dir_all(&clanker).map_err(|source| BakeError::Io {
operation: "create directory",
path: clanker,
source,
})?;
}
Ok(())
}
fn write_default_repo_config(repo: &Path, domain: &DomainName) -> Result<(), BakeError> {
let path = repo.join(".clanker/config.toml");
if path.exists() {
return Ok(());
}
let Some(parent) = path.parent() else {
return Ok(());
};
fs::create_dir_all(parent).map_err(|source| BakeError::Io {
operation: "create directory",
path: parent.to_path_buf(),
source,
})?;
fs::write(&path, format!("domain = \"{domain}\"\n")).map_err(|source| BakeError::Io {
operation: "write",
path,
source,
})
}
#[derive(Debug)]
struct BakePlan {
repo: PathBuf,
lock: BakeLock,
files: BTreeMap<String, Vec<u8>>,
skill_dirs: Vec<String>,
}
impl BakePlan {
fn report(&self, previous: Option<&BakeLock>) -> BakeReport {
let mut changed_files = Vec::new();
let mut unchanged_files = Vec::new();
for (path, contents) in &self.files {
let current_matches = self.repo.join(path).is_file()
&& read_hash(&self.repo.join(path)).ok().as_ref() == Some(&file_hash(contents));
let previous_matches = previous
.and_then(|lock| lock.files.get(path))
.is_some_and(|hash| hash == &file_hash(contents))
&& self.repo.join(path).is_file();
if current_matches || previous_matches {
unchanged_files.push(path.clone());
} else {
changed_files.push(path.clone());
}
}
if previous == Some(&self.lock) {
unchanged_files.push(LOCK_PATH.to_string());
} else {
changed_files.push(LOCK_PATH.to_string());
}
BakeReport {
domain: self.lock.domain.clone(),
profiles: self.lock.profiles.clone(),
skills: self.lock.skills.keys().cloned().collect(),
changed_files,
unchanged_files,
}
}
fn drift_issues(&self, previous: Option<&BakeLock>) -> Vec<String> {
let Some(previous) = previous else {
return vec![format!("{LOCK_PATH} is missing")];
};
let mut issues = Vec::new();
if previous.domain != self.lock.domain || previous.profiles != self.lock.profiles {
issues.push("selected domain or profiles changed".to_string());
}
if previous.skills != self.lock.skills {
issues.push("skill source content changed".to_string());
}
for (path, desired) in &self.lock.files {
if previous.files.get(path) != Some(desired) {
issues.push(format!("source changed for {path}"));
}
}
for (path, previous_hash) in &previous.files {
match read_hash(&self.repo.join(path)) {
Ok(current) if ¤t == previous_hash => {}
Ok(_) => issues.push(format!("baked output changed: {path}")),
Err(_) => issues.push(format!("baked output missing: {path}")),
}
}
issues
}
fn ensure_not_hand_edited(
&self,
previous: Option<&BakeLock>,
mode: BakeMode,
) -> Result<(), BakeError> {
if mode == BakeMode::Force {
return Ok(());
}
let Some(previous) = previous else {
return Ok(());
};
for path in self.files.keys() {
let destination = self.repo.join(path);
if !destination.exists() {
continue;
}
let Some(previous_hash) = previous.files.get(path) else {
continue;
};
let current_hash = read_hash(&destination).map_err(|source| BakeError::Io {
operation: "read",
path: destination,
source,
})?;
if ¤t_hash != previous_hash {
return Err(BakeError::HandEdited { path: path.clone() });
}
}
Ok(())
}
fn write(&self, mode: BakeMode) -> Result<(), BakeError> {
for skill_dir in &self.skill_dirs {
let path = self.repo.join(skill_dir);
if path.exists() {
if mode == BakeMode::Force {
fs::remove_dir_all(&path).map_err(|source| BakeError::Io {
operation: "remove directory",
path: path.clone(),
source,
})?;
} else if !path.is_dir() {
return Err(BakeError::HandEdited {
path: skill_dir.clone(),
});
}
}
}
for (path, contents) in &self.files {
let destination = self.repo.join(path);
if destination.is_file()
&& read_hash(&destination).ok().as_ref() == Some(&file_hash(contents))
{
continue;
}
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent).map_err(|source| BakeError::Io {
operation: "create directory",
path: parent.to_path_buf(),
source,
})?;
}
let mut file = fs::File::create(&destination).map_err(|source| BakeError::Io {
operation: "write",
path: destination.clone(),
source,
})?;
file.write_all(contents).map_err(|source| BakeError::Io {
operation: "write",
path: destination,
source,
})?;
}
write_lock(&self.repo, &self.lock)
}
}
fn build_bake_plan(
repo: &Path,
config: &Config,
home: &Path,
directory_config: Option<&DirectoryConfig>,
) -> Result<BakePlan, BakeError> {
let domain_name = directory_config
.and_then(|directory| directory.domain.clone())
.unwrap_or_else(|| config.defaults.domain.clone());
let domain = config
.domain
.get(&domain_name)
.ok_or_else(|| BakeError::UnknownDomain(domain_name.to_string()))?;
let skills = directory_config
.and_then(|directory| directory.bake.as_ref())
.and_then(|bake| bake.skills.clone())
.unwrap_or_else(|| domain.skills.clone());
let profiles = domain.profiles.clone();
let prompt_gpt = render_prompt(config, home, &profiles, "gpt")?;
let prompt_claude = render_prompt(config, home, &profiles, "claude")?;
let bundle = expand_path(&config.defaults.skills_bundle, home)?;
let mut files = BTreeMap::new();
files.insert(
"AGENTS.md".to_string(),
managed_document(&repo.join("AGENTS.md"), &prompt_gpt)?.into_bytes(),
);
files.insert(
"CLAUDE.md".to_string(),
managed_document(&repo.join("CLAUDE.md"), &prompt_claude)?.into_bytes(),
);
let mut skill_hashes = BTreeMap::new();
let mut skill_dirs = Vec::new();
for skill in &skills {
let source = bundle.join(skill.as_str());
let skill_md = source.join("SKILL.md");
if !skill_md.is_file() {
return Err(BakeError::MissingSkill {
skill: skill.clone(),
path: skill_md,
});
}
let source_files = collect_skill_files(skill, &source)?;
let digest = digest_skill_files(&source, &source_files)?;
skill_hashes.insert(skill.to_string(), digest);
for target_root in [".agents/skills", ".claude/skills"] {
let target_dir = format!("{target_root}/{}", skill.as_str());
skill_dirs.push(target_dir.clone());
for relative in &source_files {
let source_path = source.join(relative);
let target = format!("{target_dir}/{}", relative.display());
files.insert(
target,
fs::read(&source_path).map_err(|source| BakeError::Io {
operation: "read",
path: source_path,
source,
})?,
);
}
}
}
let file_hashes = files
.iter()
.map(|(path, contents)| (path.clone(), file_hash(contents)))
.collect();
let lock = BakeLock {
version: env!("CARGO_PKG_VERSION").to_string(),
domain: domain_name.to_string(),
profiles,
skills: skill_hashes,
files: file_hashes,
skills_bundle: bundle.display().to_string(),
};
Ok(BakePlan {
repo: repo.to_path_buf(),
lock,
files,
skill_dirs,
})
}
fn render_prompt(
config: &Config,
home: &Path,
profiles: &[String],
family: &'static str,
) -> Result<String, BakeError> {
let family =
prompter::FamilyName::new(family).map_err(|source| BakeError::Family { family, source })?;
let bundle = expand_path(&config.defaults.prompter_bundle, home)?;
let bundle_config = bundle.join("config.toml");
let bytes = prompter::render_to_vec(profiles, Some(&family), Some(&bundle_config))
.map_err(|source| PromptError::Render(source.to_string()))?;
Ok(String::from_utf8(bytes)?)
}
fn managed_document(path: &Path, rendered: &str) -> Result<String, BakeError> {
let managed = format!("{BEGIN_MARKER}\n{rendered}{END_MARKER}\n");
if !path.exists() {
return Ok(managed);
}
let existing = fs::read_to_string(path).map_err(|source| BakeError::Io {
operation: "read",
path: path.to_path_buf(),
source,
})?;
let Some(begin) = existing.find(BEGIN_MARKER) else {
return Ok(join_existing_and_managed(&existing, &managed));
};
let Some(end_relative) = existing.get(begin..).and_then(|tail| tail.find(END_MARKER)) else {
return Ok(join_existing_and_managed(&existing, &managed));
};
let end = begin + end_relative + END_MARKER.len();
let Some(prefix) = existing.get(..begin) else {
return Ok(join_existing_and_managed(&existing, &managed));
};
let Some(suffix) = existing.get(end..) else {
return Ok(join_existing_and_managed(&existing, &managed));
};
let suffix = suffix.strip_prefix('\n').unwrap_or(suffix);
Ok(format!("{prefix}{managed}{suffix}"))
}
fn join_existing_and_managed(existing: &str, managed: &str) -> String {
let separator = if existing.ends_with('\n') {
"\n"
} else {
"\n\n"
};
format!("{existing}{separator}{managed}")
}
fn collect_skill_files(skill: &SkillName, source: &Path) -> Result<Vec<PathBuf>, BakeError> {
let source = fs::canonicalize(source).map_err(|source_error| BakeError::Io {
operation: "canonicalize",
path: source.to_path_buf(),
source: source_error,
})?;
let mut files = Vec::new();
for entry in WalkDir::new(&source).follow_links(false) {
let entry = entry.map_err(|source| BakeError::Io {
operation: "walk",
path: source.to_string().into(),
source: std::io::Error::other("walkdir traversal failed"),
})?;
let file_type = entry.file_type();
if file_type.is_symlink() {
return Err(BakeError::SkillSymlink {
skill: skill.clone(),
path: entry.path().to_path_buf(),
});
}
if file_type.is_file() {
let relative = entry
.path()
.strip_prefix(&source)
.map_err(|_| BakeError::Io {
operation: "relativize",
path: entry.path().to_path_buf(),
source: std::io::Error::other("walked path escaped skill source"),
})?
.to_path_buf();
files.push(relative);
}
}
files.sort();
Ok(files)
}
fn digest_skill_files(source: &Path, files: &[PathBuf]) -> Result<String, BakeError> {
let mut hasher = Sha256::new();
for relative in files {
hasher.update(relative.to_string_lossy().as_bytes());
hasher.update([0]);
let path = source.join(relative);
hasher.update(fs::read(&path).map_err(|source| BakeError::Io {
operation: "read",
path,
source,
})?);
hasher.update([0]);
}
Ok(format!("{:x}", hasher.finalize()))
}
fn file_hash(contents: &[u8]) -> String {
format!("{:x}", Sha256::digest(contents))
}
fn read_hash(path: &Path) -> Result<String, std::io::Error> {
fs::read(path).map(|contents| file_hash(&contents))
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct BakeLock {
version: String,
domain: String,
profiles: Vec<String>,
skills_bundle: String,
skills: BTreeMap<String, String>,
files: BTreeMap<String, String>,
}
fn read_lock(repo: &Path) -> Result<Option<BakeLock>, BakeError> {
let path = repo.join(LOCK_PATH);
let text = match fs::read_to_string(&path) {
Ok(text) => text,
Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(source) => {
return Err(BakeError::Io {
operation: "read",
path,
source,
});
}
};
toml::from_str(&text)
.map(Some)
.map_err(|source| BakeError::LockToml {
path,
source: Box::new(source),
})
}
fn write_lock(repo: &Path, lock: &BakeLock) -> Result<(), BakeError> {
let path = repo.join(LOCK_PATH);
let text = toml::to_string_pretty(lock).map_err(|source| BakeError::LockToml {
path: path.clone(),
source: Box::new(source),
})?;
if fs::read_to_string(&path).ok().as_deref() == Some(text.as_str()) {
return Ok(());
}
fs::write(&path, text).map_err(|source| BakeError::Io {
operation: "write",
path,
source,
})
}