use std::{
env, fs,
path::{Path, PathBuf},
};
use crate::types::{
AgentTemplateCatalogEntry, AgentTemplateSourceKind, SkillInstallKind, SkillInstallMode,
};
use anyhow::{anyhow, bail, Context, Result};
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use chrono::{DateTime, Utc};
use reqwest::StatusCode;
use serde::{Deserialize, Serialize};
const TEMPLATE_AGENTS_FILENAME: &str = "AGENTS.md";
const TEMPLATE_SKILLS_FILENAME: &str = "skills.json";
const TEMPLATE_PROVENANCE_FILENAME: &str = "template-provenance.json";
const BUILTIN_TEMPLATE_STATE_FILENAME: &str = ".holon-builtin-template.json";
pub const DEFAULT_AGENT_TEMPLATE_ID: &str = "holon-default";
const MEMORY_SELF_INITIAL: &str = "# Self Memory\n\n";
const MEMORY_OPERATOR_INITIAL: &str = "# Operator Memory\n\n";
pub const REQUIRED_AGENT_HOME_GUIDANCE: &str = r#"## Holon Agent Home
- `agent_home` is this agent's default workspace. Use it for agent-local state, notes, memory, and non-project-local work.
- `AGENTS.md` is automatically loaded as concise agent guidance. Keep durable behavior here, not transient plans or copied project docs.
- `memory/self.md` and `memory/operator.md` are curated agent-scoped Markdown memory. They are searched or retrieved on demand and are not the same as always-loaded guidance.
- `notes/` is ordinary working notes.
- `work/` is for non-project-local work artifacts. Project-scoped files and memory belong in the active project workspace.
- `skills/` is for agent-local skills.
- `.holon/` is runtime-owned state, ledger, index, and cache storage. Do not edit it as ordinary agent-authored files.
"#;
const BUILTIN_TEMPLATES: &[BuiltinTemplate] = &[
BuiltinTemplate {
template_id: "holon-default",
version: 1,
agents_md: include_str!("../builtin_templates/holon-default/AGENTS.md"),
skills_json: None,
},
BuiltinTemplate {
template_id: "holon-developer",
version: 1,
agents_md: include_str!("../builtin_templates/holon-developer/AGENTS.md"),
skills_json: None,
},
BuiltinTemplate {
template_id: "holon-reviewer",
version: 1,
agents_md: include_str!("../builtin_templates/holon-reviewer/AGENTS.md"),
skills_json: None,
},
BuiltinTemplate {
template_id: "holon-release",
version: 1,
agents_md: include_str!("../builtin_templates/holon-release/AGENTS.md"),
skills_json: None,
},
BuiltinTemplate {
template_id: "holon-github-solve",
version: 1,
agents_md: include_str!("../builtin_templates/holon-github-solve/AGENTS.md"),
skills_json: Some(include_str!(
"../builtin_templates/holon-github-solve/skills.json"
)),
},
];
struct BuiltinTemplate {
template_id: &'static str,
version: u32,
agents_md: &'static str,
skills_json: Option<&'static str>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
struct BuiltinTemplateState {
template_id: String,
version: u32,
content_hash: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TemplateProvenanceSource {
TemplateId {
template_id: String,
path: PathBuf,
},
LocalPath {
path: PathBuf,
},
GitHubUrl {
url: String,
owner: String,
repo: String,
git_ref: String,
template_path: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TemplateProvenanceRecord {
pub selector: String,
pub source: TemplateProvenanceSource,
pub applied_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct TemplateSkillsManifest {
#[serde(default)]
skill_refs: Vec<TemplateSkillRef>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
enum TemplateSkillRef {
Local { path: PathBuf },
Github { package: String },
Builtin { name: String },
}
struct BuiltinSkill {
name: &'static str,
skill_md: &'static str,
files: &'static [BuiltinSkillFile],
}
struct BuiltinSkillFile {
path: &'static str,
content: &'static str,
}
const BUILTIN_SKILLS: &[BuiltinSkill] = &[
BuiltinSkill {
name: "ghx",
skill_md: include_str!("../skills/ghx/SKILL.md"),
files: &[],
},
BuiltinSkill {
name: "github-issue-solve",
skill_md: include_str!("../skills/github-issue-solve/SKILL.md"),
files: &[BuiltinSkillFile {
path: "references/issue-solve-workflow.md",
content: include_str!(
"../skills/github-issue-solve/references/issue-solve-workflow.md"
),
}],
},
BuiltinSkill {
name: "github-pr-fix",
skill_md: include_str!("../skills/github-pr-fix/SKILL.md"),
files: &[
BuiltinSkillFile {
path: "references/diagnostics.md",
content: include_str!("../skills/github-pr-fix/references/diagnostics.md"),
},
BuiltinSkillFile {
path: "references/pr-fix-workflow.md",
content: include_str!("../skills/github-pr-fix/references/pr-fix-workflow.md"),
},
],
},
BuiltinSkill {
name: "github-review",
skill_md: include_str!("../skills/github-review/SKILL.md"),
files: &[],
},
];
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct GitHubContentsFileResponse {
#[serde(rename = "type")]
kind: String,
content: Option<String>,
encoding: Option<String>,
}
#[derive(Debug, Clone)]
struct ResolvedTemplate {
provenance: TemplateProvenanceSource,
agents_md: String,
skill_refs: Vec<TemplateSkillRef>,
}
pub fn template_provenance_path(agent_home: &Path) -> PathBuf {
agent_home
.join(".holon")
.join("state")
.join(TEMPLATE_PROVENANCE_FILENAME)
}
pub fn agent_memory_self_path(agent_home: &Path) -> PathBuf {
agent_home.join("memory").join("self.md")
}
pub fn agent_memory_operator_path(agent_home: &Path) -> PathBuf {
agent_home.join("memory").join("operator.md")
}
fn builtin_template_state_path(template_dir: &Path) -> PathBuf {
template_dir.join(BUILTIN_TEMPLATE_STATE_FILENAME)
}
pub fn seed_builtin_templates() -> Result<()> {
let home_dir = user_home_dir()?;
seed_builtin_templates_for_home(&home_dir)
}
pub fn seed_builtin_templates_for_home(home_dir: &Path) -> Result<()> {
let templates_root = templates_root_for_home(home_dir);
fs::create_dir_all(&templates_root)
.with_context(|| format!("failed to create {}", templates_root.display()))?;
for builtin in BUILTIN_TEMPLATES {
let template_dir = templates_root.join(builtin.template_id);
let existed_before = template_dir.exists();
let was_empty_before = if existed_before {
fs::read_dir(&template_dir)?.next().is_none()
} else {
false
};
let existing_state = read_builtin_template_state(&template_dir)?;
let should_write = if !existed_before || was_empty_before {
true
} else if let Some(state) = existing_state.as_ref() {
builtin_template_is_managed(&template_dir, state)?
&& (state.version != builtin.version
|| state.content_hash != builtin_template_content_hash(builtin))
} else {
false
};
if should_write {
write_builtin_template(&template_dir, builtin)?;
}
}
Ok(())
}
pub async fn initialize_agent_home_from_template(
agent_home: &Path,
template: &str,
) -> Result<TemplateProvenanceRecord> {
let home_dir = user_home_dir()?;
initialize_agent_home_from_template_with_home(agent_home, &home_dir, template).await
}
pub async fn initialize_agent_home_from_template_with_catalog(
agent_home: &Path,
home_dir: &Path,
catalog_agent_home: &Path,
template: &str,
) -> Result<TemplateProvenanceRecord> {
initialize_agent_home_from_template_with_home_and_catalog(
agent_home,
home_dir,
Some(catalog_agent_home),
template,
)
.await
}
pub fn initialize_agent_home_without_template(agent_home: &Path) -> Result<()> {
ensure_agent_home_layout(agent_home)?;
let agents_md_path = agent_home.join(TEMPLATE_AGENTS_FILENAME);
create_file_if_missing(
&agents_md_path,
render_agent_home_agents_md("", None).as_bytes(),
)
}
pub async fn initialize_agent_home_from_template_with_home(
agent_home: &Path,
home_dir: &Path,
template: &str,
) -> Result<TemplateProvenanceRecord> {
initialize_agent_home_from_template_with_home_and_catalog(agent_home, home_dir, None, template)
.await
}
async fn initialize_agent_home_from_template_with_home_and_catalog(
agent_home: &Path,
home_dir: &Path,
catalog_agent_home: Option<&Path>,
template: &str,
) -> Result<TemplateProvenanceRecord> {
let agent_home = agent_home.to_path_buf();
let existed_before = agent_home.exists();
let was_empty_before = if existed_before {
fs::read_dir(&agent_home)?.next().is_none()
} else {
false
};
if existed_before && !was_empty_before {
bail!(
"agent home {} already exists and is not empty; template initialization refuses to overwrite existing agent state",
agent_home.display()
);
}
if !existed_before {
fs::create_dir_all(&agent_home)
.with_context(|| format!("failed to create {}", agent_home.display()))?;
}
let result = async {
ensure_agent_home_layout(&agent_home)?;
let resolved = resolve_template(
template,
home_dir,
catalog_agent_home.unwrap_or(&agent_home),
)
.await?;
materialize_template(&agent_home, &resolved).await?;
let record = TemplateProvenanceRecord {
selector: template.to_string(),
source: resolved.provenance,
applied_at: Utc::now(),
};
let content = serde_json::to_vec_pretty(&record)?;
fs::write(template_provenance_path(&agent_home), content).with_context(|| {
format!(
"failed to write {}",
template_provenance_path(&agent_home).display()
)
})?;
Ok(record)
}
.await;
if result.is_err() && agent_home.exists() {
if !existed_before {
let _ = fs::remove_dir_all(&agent_home);
} else if was_empty_before {
let _ = fs::remove_dir_all(&agent_home);
let _ = fs::create_dir_all(&agent_home);
}
}
result
}
pub async fn ensure_agent_home_agents_md_from_template(
agent_home: &Path,
template: &str,
) -> Result<Option<TemplateProvenanceRecord>> {
let home_dir = user_home_dir()?;
ensure_agent_home_agents_md_from_template_with_home(agent_home, &home_dir, template).await
}
pub async fn ensure_agent_home_agents_md_from_template_with_catalog(
agent_home: &Path,
home_dir: &Path,
catalog_agent_home: &Path,
template: &str,
) -> Result<Option<TemplateProvenanceRecord>> {
ensure_agent_home_agents_md_from_template_with_home_and_catalog(
agent_home,
home_dir,
Some(catalog_agent_home),
template,
)
.await
}
pub async fn ensure_agent_home_agents_md_from_template_with_home(
agent_home: &Path,
home_dir: &Path,
template: &str,
) -> Result<Option<TemplateProvenanceRecord>> {
ensure_agent_home_agents_md_from_template_with_home_and_catalog(
agent_home, home_dir, None, template,
)
.await
}
async fn ensure_agent_home_agents_md_from_template_with_home_and_catalog(
agent_home: &Path,
home_dir: &Path,
catalog_agent_home: Option<&Path>,
template: &str,
) -> Result<Option<TemplateProvenanceRecord>> {
let agent_home = agent_home.to_path_buf();
fs::create_dir_all(&agent_home)
.with_context(|| format!("failed to create {}", agent_home.display()))?;
ensure_agent_home_layout(&agent_home)?;
let agents_md_path = agent_home.join(TEMPLATE_AGENTS_FILENAME);
if agents_md_path.exists() {
return Ok(None);
}
let resolved = resolve_template(
template,
home_dir,
catalog_agent_home.unwrap_or(&agent_home),
)
.await?;
let skills_root = agent_home.join("skills");
let mut created_skill_destinations = Vec::new();
let result: Result<TemplateProvenanceRecord> = async {
let agents_md = render_agent_home_agents_md(&resolved.agents_md, None);
write_file_atomically(&agents_md_path, agents_md.as_bytes())?;
for skill_ref in &resolved.skill_refs {
let destination = materialize_skill_ref(&agent_home, &skills_root, skill_ref).await?;
created_skill_destinations.push(destination);
}
let record = TemplateProvenanceRecord {
selector: template.to_string(),
source: resolved.provenance,
applied_at: Utc::now(),
};
let content = serde_json::to_vec_pretty(&record)?;
write_file_atomically(&template_provenance_path(&agent_home), &content)?;
Ok(record)
}
.await;
match result {
Ok(record) => Ok(Some(record)),
Err(err) => {
let _ = fs::remove_file(&agents_md_path);
let _ = fs::remove_file(template_provenance_path(&agent_home));
for destination in created_skill_destinations.into_iter().rev() {
let _ = remove_materialized_skill_destination(&destination);
}
Err(err)
}
}
}
#[cfg(test)]
fn templates_root() -> Result<PathBuf> {
Ok(templates_root_for_home(&user_home_dir()?))
}
fn templates_root_for_home(home_dir: &Path) -> PathBuf {
home_dir.join(".agents").join("templates")
}
pub(crate) fn discover_agent_templates_catalog(
user_home: Option<&Path>,
agent_home: &Path,
) -> Vec<AgentTemplateCatalogEntry> {
let user_templates_root = user_home.map(templates_root_for_home);
let user_entries = if let Some(root) = user_templates_root.as_deref() {
discover_local_templates(root, AgentTemplateSourceKind::UserGlobal, false)
} else {
Vec::new()
};
let agent_home_entries = discover_local_templates(
&agent_home.join("templates"),
AgentTemplateSourceKind::AgentHome,
true,
);
let agent_home_template_ids = agent_home_entries
.iter()
.map(|entry| entry.template_id.clone())
.collect::<std::collections::BTreeSet<_>>();
let mut entries = user_entries
.into_iter()
.filter(|entry| !agent_home_template_ids.contains(&entry.template_id))
.collect::<Vec<_>>();
let user_template_ids = entries
.iter()
.map(|entry| entry.template_id.clone())
.collect::<std::collections::BTreeSet<_>>();
for builtin in BUILTIN_TEMPLATES {
if user_template_ids.contains(builtin.template_id)
|| agent_home_template_ids.contains(builtin.template_id)
{
continue;
}
entries.push(builtin_template_catalog_entry(builtin));
}
entries.extend(agent_home_entries);
entries.sort_by(|left, right| {
(
left.source,
left.template_id.as_str(),
left.path.as_ref().map(|path| path.display().to_string()),
)
.cmp(&(
right.source,
right.template_id.as_str(),
right.path.as_ref().map(|path| path.display().to_string()),
))
});
entries
}
fn builtin_template_catalog_entry(builtin: &BuiltinTemplate) -> AgentTemplateCatalogEntry {
AgentTemplateCatalogEntry {
catalog_id: format!("builtin:{}", builtin.template_id),
template: builtin.template_id.to_string(),
template_id: builtin.template_id.to_string(),
source: AgentTemplateSourceKind::Builtin,
path: None,
description: template_description(builtin.agents_md),
included_skills: builtin_template_skills(builtin),
}
}
fn discover_local_templates(
root: &Path,
source: AgentTemplateSourceKind,
use_absolute_selector: bool,
) -> Vec<AgentTemplateCatalogEntry> {
let Ok(read_dir) = fs::read_dir(root) else {
return Vec::new();
};
let mut entries = Vec::new();
for entry in read_dir.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let Some(template_id) = path.file_name().and_then(|name| name.to_str()) else {
continue;
};
if validate_template_id(template_id).is_err() {
continue;
}
if source == AgentTemplateSourceKind::UserGlobal
&& is_managed_seeded_builtin_template(&path, template_id)
{
continue;
}
let agents_md_path = path.join(TEMPLATE_AGENTS_FILENAME);
let Ok(agents_md) = fs::read_to_string(&agents_md_path) else {
continue;
};
if agents_md.trim().is_empty() {
continue;
}
let template = if use_absolute_selector {
path.to_string_lossy().into_owned()
} else {
template_id.to_string()
};
entries.push(AgentTemplateCatalogEntry {
catalog_id: format!("{}:{}", source.label(), template_id),
template,
template_id: template_id.to_string(),
source,
path: Some(path.clone()),
description: template_description(&agents_md),
included_skills: local_template_skills(&path),
});
}
entries
}
fn is_managed_seeded_builtin_template(path: &Path, template_id: &str) -> bool {
let Some(builtin) = BUILTIN_TEMPLATES
.iter()
.find(|builtin| builtin.template_id == template_id)
else {
return false;
};
let state = match read_builtin_template_state(path) {
Ok(Some(state)) => state,
Ok(None) => return false,
Err(error) => {
tracing::debug!(
template_path = %path.display(),
%error,
"failed to read seeded builtin template state while building catalog"
);
return false;
}
};
if state.template_id != builtin.template_id
|| state.version != builtin.version
|| state.content_hash != builtin_template_content_hash(builtin)
{
return false;
}
builtin_template_is_managed(path, &state).unwrap_or(false)
}
fn template_description(agents_md: &str) -> String {
let mut in_html_comment = false;
for line in agents_md.lines() {
let trimmed = line.trim();
let Some(trimmed) = trim_leading_html_comments(trimmed, &mut in_html_comment) else {
continue;
};
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
return trimmed.to_string();
}
agents_md
.lines()
.find_map(|line| {
let heading = line.trim().trim_start_matches('#').trim();
(!heading.is_empty()).then(|| heading.to_string())
})
.unwrap_or_default()
}
fn trim_leading_html_comments<'a>(mut trimmed: &'a str, in_comment: &mut bool) -> Option<&'a str> {
loop {
if *in_comment {
let end = trimmed.find("-->")?;
*in_comment = false;
trimmed = trimmed[end + 3..].trim_start();
continue;
}
let Some(after_start) = trimmed.strip_prefix("<!--") else {
return Some(trimmed);
};
let Some(end) = after_start.find("-->") else {
*in_comment = true;
return None;
};
trimmed = after_start[end + 3..].trim_start();
}
}
fn local_template_skills(path: &Path) -> Vec<String> {
match parse_skill_refs(path.join(TEMPLATE_SKILLS_FILENAME)) {
Ok(skill_refs) => skill_ref_names(skill_refs),
Err(error) => {
tracing::warn!(
template_path = %path.display(),
%error,
"failed to load local agent template skills"
);
Vec::new()
}
}
}
fn builtin_template_skills(builtin: &BuiltinTemplate) -> Vec<String> {
let Some(skills_json) = builtin.skills_json else {
return Vec::new();
};
serde_json::from_str::<TemplateSkillsManifest>(skills_json)
.map(|manifest| skill_ref_names(manifest.skill_refs))
.unwrap_or_default()
}
fn skill_ref_names(skill_refs: Vec<TemplateSkillRef>) -> Vec<String> {
let mut names = skill_refs
.into_iter()
.map(|skill_ref| match skill_ref {
TemplateSkillRef::Local { path } => path.display().to_string(),
TemplateSkillRef::Github { package } => package,
TemplateSkillRef::Builtin { name } => name,
})
.collect::<Vec<_>>();
names.sort();
names.dedup();
names
}
pub(crate) fn user_home_dir() -> Result<PathBuf> {
env::var_os("HOME")
.map(PathBuf::from)
.filter(|path| !path.as_os_str().is_empty())
.or_else(|| {
env::var_os("USERPROFILE")
.map(PathBuf::from)
.filter(|path| !path.as_os_str().is_empty())
})
.or_else(|| {
let drive = env::var_os("HOMEDRIVE")?;
let path = env::var_os("HOMEPATH")?;
let mut combined = PathBuf::from(drive);
combined.push(path);
if combined.as_os_str().is_empty() {
None
} else {
Some(combined)
}
})
.ok_or_else(|| anyhow!("HOME is not set; cannot resolve ~/.agents/templates"))
}
async fn resolve_template(
template: &str,
home_dir: &Path,
catalog_agent_home: &Path,
) -> Result<ResolvedTemplate> {
let template = template.trim();
if template.is_empty() {
bail!("template selector must not be empty");
}
if let Ok(path) = resolve_absolute_template_path(template) {
return resolve_local_template(
path,
TemplateProvenanceSource::LocalPath {
path: PathBuf::from(template),
},
);
}
if is_github_tree_url(template)? {
return resolve_github_template(template).await;
}
let entry = resolve_template_catalog_entry(template, home_dir, catalog_agent_home)?;
resolve_catalog_template(entry, home_dir).await
}
fn resolve_absolute_template_path(template: &str) -> Result<PathBuf> {
let path = PathBuf::from(template);
if !path.is_absolute() {
bail!("template selector is not an absolute path");
}
Ok(path)
}
fn validate_template_id(template_id: &str) -> Result<()> {
if template_id.contains('/') || template_id.contains('\\') {
bail!("template_id must not be path-like");
}
if template_id == "." || template_id == ".." || template_id.contains("..") {
bail!("template_id must be a simple stable name");
}
if !template_id
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_'))
{
bail!("template_id contains unsupported characters");
}
Ok(())
}
fn resolve_template_catalog_entry(
template: &str,
home_dir: &Path,
catalog_agent_home: &Path,
) -> Result<AgentTemplateCatalogEntry> {
if let Some((source_label, template_id)) = template.split_once(':') {
let Some(source) = AgentTemplateSourceKind::from_label(source_label) else {
return Err(unknown_template_error(
template,
home_dir,
catalog_agent_home,
));
};
validate_template_id(template_id)?;
return resolve_prefixed_template_catalog_entry(
source,
template_id,
home_dir,
catalog_agent_home,
)
.ok_or_else(|| unknown_template_error(template, home_dir, catalog_agent_home));
}
validate_template_id(template)?;
let catalog = discover_agent_templates_catalog(Some(home_dir), catalog_agent_home);
catalog
.into_iter()
.find(|entry| entry.template_id == template)
.ok_or_else(|| unknown_template_error(template, home_dir, catalog_agent_home))
}
fn resolve_prefixed_template_catalog_entry(
source: AgentTemplateSourceKind,
template_id: &str,
home_dir: &Path,
catalog_agent_home: &Path,
) -> Option<AgentTemplateCatalogEntry> {
match source {
AgentTemplateSourceKind::Builtin => BUILTIN_TEMPLATES
.iter()
.find(|template| template.template_id == template_id)
.map(builtin_template_catalog_entry),
AgentTemplateSourceKind::UserGlobal => discover_local_templates(
&templates_root_for_home(home_dir),
AgentTemplateSourceKind::UserGlobal,
false,
)
.into_iter()
.find(|entry| entry.template_id == template_id),
AgentTemplateSourceKind::AgentHome => discover_local_templates(
&catalog_agent_home.join("templates"),
AgentTemplateSourceKind::AgentHome,
true,
)
.into_iter()
.find(|entry| entry.template_id == template_id),
}
}
async fn resolve_catalog_template(
entry: AgentTemplateCatalogEntry,
home_dir: &Path,
) -> Result<ResolvedTemplate> {
match entry.source {
AgentTemplateSourceKind::Builtin => resolve_builtin_template(&entry.template_id, home_dir),
AgentTemplateSourceKind::UserGlobal | AgentTemplateSourceKind::AgentHome => {
let path = entry.path.clone().ok_or_else(|| {
anyhow!(
"template catalog entry {} has no local source path",
entry.catalog_id
)
})?;
resolve_local_template(
path.clone(),
TemplateProvenanceSource::TemplateId {
template_id: entry.template_id,
path,
},
)
}
}
}
fn resolve_builtin_template(template_id: &str, home_dir: &Path) -> Result<ResolvedTemplate> {
let builtin = BUILTIN_TEMPLATES
.iter()
.find(|builtin| builtin.template_id == template_id)
.ok_or_else(|| anyhow!("unknown builtin template id {template_id}"))?;
let skill_refs = builtin
.skills_json
.map(serde_json::from_str::<TemplateSkillsManifest>)
.transpose()
.with_context(|| format!("failed to parse builtin template {template_id} skills.json"))?
.map(|manifest| manifest.skill_refs)
.unwrap_or_default();
Ok(ResolvedTemplate {
provenance: TemplateProvenanceSource::TemplateId {
template_id: template_id.to_string(),
path: templates_root_for_home(home_dir).join(template_id),
},
agents_md: builtin.agents_md.to_string(),
skill_refs,
})
}
fn unknown_template_error(
template: &str,
home_dir: &Path,
catalog_agent_home: &Path,
) -> anyhow::Error {
let catalog = discover_agent_templates_catalog(Some(home_dir), catalog_agent_home);
let known = if catalog.is_empty() {
"none".to_string()
} else {
catalog
.iter()
.map(|entry| {
let source = entry
.path
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_else(|| entry.source.label().to_string());
format!(
"{} (id={}, source={source})",
entry.catalog_id, entry.template_id
)
})
.collect::<Vec<_>>()
.join(", ")
};
anyhow!("unknown template selector: {template}; known template ids/catalog ids: {known}")
}
fn resolve_local_template(
path: PathBuf,
provenance: TemplateProvenanceSource,
) -> Result<ResolvedTemplate> {
if !path.is_dir() {
bail!("template directory {} does not exist", path.display());
}
let agents_md_path = path.join(TEMPLATE_AGENTS_FILENAME);
let agents_md = fs::read_to_string(&agents_md_path)
.with_context(|| format!("failed to read {}", agents_md_path.display()))?;
if agents_md.trim().is_empty() {
bail!("template {} has an empty AGENTS.md", path.display());
}
let skill_refs = parse_skill_refs(path.join(TEMPLATE_SKILLS_FILENAME))?;
Ok(ResolvedTemplate {
provenance,
agents_md,
skill_refs,
})
}
fn parse_skill_refs(path: PathBuf) -> Result<Vec<TemplateSkillRef>> {
if !path.exists() {
return Ok(Vec::new());
}
let content =
fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
let manifest: TemplateSkillsManifest = serde_json::from_str(&content)
.with_context(|| format!("failed to parse {}", path.display()))?;
for skill_ref in &manifest.skill_refs {
match skill_ref {
TemplateSkillRef::Github { package } => {
template_github_skill_install_kind(package)?;
}
TemplateSkillRef::Builtin { name } => {
if builtin_skill(name).is_none() {
bail!("unknown builtin skill ref: {name}");
}
}
TemplateSkillRef::Local { .. } => {}
}
}
Ok(manifest.skill_refs)
}
fn template_github_skill_install_kind(package: &str) -> Result<SkillInstallKind> {
validate_template_github_skill_package(package)?;
let (remote_package, skill) = split_template_github_skill_package(package)?;
Ok(SkillInstallKind::Remote {
package: remote_package.to_string(),
skill: skill.map(str::to_string),
mode: SkillInstallMode::Linked,
})
}
fn split_template_github_skill_package(package: &str) -> Result<(&str, Option<&str>)> {
let Some(at_index) = package.rfind('@') else {
return Ok((package, None));
};
let remote_package = &package[..at_index];
if at_index == 0 || !remote_package.contains('/') {
return Ok((package, None));
}
let skill = &package[at_index + 1..];
validate_template_github_skill_package(remote_package)?;
validate_template_github_skill_name(skill)?;
Ok((remote_package, Some(skill)))
}
fn validate_template_github_skill_package(package: &str) -> Result<()> {
if package.trim().is_empty() {
bail!("github skill ref package must not be empty");
}
if package.trim() != package {
bail!("github skill ref package must not contain leading or trailing whitespace");
}
if package.starts_with('-') {
bail!("github skill ref package must not start with '-'");
}
if package
.chars()
.any(|ch| ch.is_control() || ch.is_ascii_whitespace())
{
bail!("github skill ref package must not contain whitespace or control characters");
}
Ok(())
}
fn validate_template_github_skill_name(skill: &str) -> Result<()> {
if skill.is_empty()
|| skill == "."
|| skill == ".."
|| skill.contains('/')
|| skill.contains('\\')
{
bail!("github skill ref skill name must be a plain skill directory name");
}
validate_template_github_skill_package(skill)?;
Ok(())
}
fn read_builtin_template_state(template_dir: &Path) -> Result<Option<BuiltinTemplateState>> {
let path = builtin_template_state_path(template_dir);
if !path.is_file() {
return Ok(None);
}
let content =
fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
if content.trim().is_empty() {
return Ok(None);
}
let state = serde_json::from_str(&content).ok();
Ok(state)
}
fn builtin_template_content_hash(template: &BuiltinTemplate) -> String {
use sha2::{Digest as _, Sha256};
let mut hasher = Sha256::new();
hasher.update(TEMPLATE_AGENTS_FILENAME.as_bytes());
hasher.update(b"\n");
hasher.update(template.agents_md.as_bytes());
if let Some(skills_json) = template.skills_json {
hasher.update(b"\n");
hasher.update(TEMPLATE_SKILLS_FILENAME.as_bytes());
hasher.update(b"\n");
hasher.update(skills_json.as_bytes());
}
format!("{:x}", hasher.finalize())
}
fn current_builtin_template_content_hash(
template_dir: &Path,
expect_skills_json: bool,
) -> Result<String> {
use sha2::{Digest as _, Sha256};
let agents_md_path = template_dir.join(TEMPLATE_AGENTS_FILENAME);
let agents_md = fs::read_to_string(&agents_md_path)
.with_context(|| format!("failed to read {}", agents_md_path.display()))?;
let mut hasher = Sha256::new();
hasher.update(TEMPLATE_AGENTS_FILENAME.as_bytes());
hasher.update(b"\n");
hasher.update(agents_md.as_bytes());
let skills_path = template_dir.join(TEMPLATE_SKILLS_FILENAME);
if expect_skills_json {
let skills_json = fs::read_to_string(&skills_path)
.with_context(|| format!("failed to read {}", skills_path.display()))?;
hasher.update(b"\n");
hasher.update(TEMPLATE_SKILLS_FILENAME.as_bytes());
hasher.update(b"\n");
hasher.update(skills_json.as_bytes());
}
Ok(format!("{:x}", hasher.finalize()))
}
fn builtin_template_is_managed(template_dir: &Path, state: &BuiltinTemplateState) -> Result<bool> {
let expect_skills_json = template_dir.join(TEMPLATE_SKILLS_FILENAME).exists();
let current_hash = current_builtin_template_content_hash(template_dir, expect_skills_json)?;
Ok(current_hash == state.content_hash)
}
fn write_builtin_template(template_dir: &Path, builtin: &BuiltinTemplate) -> Result<()> {
fs::create_dir_all(template_dir)
.with_context(|| format!("failed to create {}", template_dir.display()))?;
let agents_md_path = template_dir.join(TEMPLATE_AGENTS_FILENAME);
write_file_atomically(&agents_md_path, builtin.agents_md.as_bytes())?;
let skills_path = template_dir.join(TEMPLATE_SKILLS_FILENAME);
match builtin.skills_json {
Some(content) => write_file_atomically(&skills_path, content.as_bytes())?,
None if skills_path.exists() => fs::remove_file(&skills_path)
.with_context(|| format!("failed to remove {}", skills_path.display()))?,
None => {}
}
let state = BuiltinTemplateState {
template_id: builtin.template_id.to_string(),
version: builtin.version,
content_hash: builtin_template_content_hash(builtin),
};
let content = serde_json::to_vec_pretty(&state)?;
write_file_atomically(&builtin_template_state_path(template_dir), &content)?;
Ok(())
}
fn write_file_atomically(path: &Path, content: &[u8]) -> Result<()> {
let parent = path
.parent()
.ok_or_else(|| anyhow!("path {} has no parent directory", path.display()))?;
fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?;
let temp_path = parent.join(format!(
".{}.tmp-{}",
path.file_name()
.and_then(|name| name.to_str())
.unwrap_or("write"),
uuid::Uuid::new_v4().simple()
));
fs::write(&temp_path, content)
.with_context(|| format!("failed to write {}", temp_path.display()))?;
if path.exists() {
fs::remove_file(path).with_context(|| format!("failed to remove {}", path.display()))?;
}
fs::rename(&temp_path, path)
.with_context(|| format!("failed to replace {}", path.display()))?;
Ok(())
}
fn is_github_tree_url(template: &str) -> Result<bool> {
let Ok(url) = reqwest::Url::parse(template) else {
return Ok(false);
};
Ok(url.scheme() == "https"
&& url.host_str() == Some("github.com")
&& url
.path_segments()
.map(|segments| segments.collect::<Vec<_>>())
.is_some_and(|segments| segments.len() >= 5 && segments[2] == "tree"))
}
async fn resolve_github_template(template: &str) -> Result<ResolvedTemplate> {
let url = reqwest::Url::parse(template)?;
if url.query().is_some() || url.fragment().is_some() {
bail!("GitHub template URL must not include a query string or fragment");
}
let segments = url
.path_segments()
.ok_or_else(|| anyhow!("GitHub template URL is missing path segments"))?
.collect::<Vec<_>>();
if segments.len() < 5 || segments[2] != "tree" {
bail!("GitHub template URL must have the form https://github.com/<owner>/<repo>/tree/<ref>/<path-to-template-dir>");
}
let owner = segments[0].to_string();
let repo = segments[1].to_string();
let ref_and_path = &segments[3..];
for split in 1..ref_and_path.len() {
let git_ref = ref_and_path[..split].join("/");
let template_path = ref_and_path[split..].join("/");
let agents_md_path = format!("{template_path}/{TEMPLATE_AGENTS_FILENAME}");
let maybe_agents_md = fetch_github_file(&owner, &repo, &git_ref, &agents_md_path).await?;
let Some(agents_md) = maybe_agents_md else {
continue;
};
if agents_md.trim().is_empty() {
bail!("GitHub template {template} resolved to an empty AGENTS.md");
}
let skills_path = format!("{template_path}/{TEMPLATE_SKILLS_FILENAME}");
let skills_json = fetch_github_file(&owner, &repo, &git_ref, &skills_path).await?;
let skill_refs = match skills_json {
Some(content) => {
let manifest: TemplateSkillsManifest = serde_json::from_str(&content)
.with_context(|| {
format!("failed to parse {template}::{TEMPLATE_SKILLS_FILENAME}")
})?;
manifest.skill_refs
}
None => Vec::new(),
};
return Ok(ResolvedTemplate {
provenance: TemplateProvenanceSource::GitHubUrl {
url: template.to_string(),
owner,
repo,
git_ref,
template_path,
},
agents_md,
skill_refs,
});
}
bail!("GitHub template URL did not resolve to a readable template directory: {template}")
}
async fn fetch_github_file(
owner: &str,
repo: &str,
git_ref: &str,
path: &str,
) -> Result<Option<String>> {
let base = env::var("HOLON_TEMPLATE_GITHUB_API_BASE")
.unwrap_or_else(|_| "https://api.github.com".to_string());
let mut url = reqwest::Url::parse(&format!("{base}/repos/{owner}/{repo}/contents/{path}"))
.with_context(|| {
format!("failed to build GitHub contents URL for {owner}/{repo}:{path}")
})?;
url.query_pairs_mut().append_pair("ref", git_ref);
let client = reqwest::Client::builder()
.build()
.context("failed to build GitHub template client")?;
let response = client
.get(url)
.header(reqwest::header::USER_AGENT, "holon-template-resolver")
.send()
.await
.with_context(|| {
format!("failed to fetch GitHub template file {owner}/{repo}:{path}@{git_ref}")
})?;
if response.status() == StatusCode::NOT_FOUND {
return Ok(None);
}
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
bail!(
"GitHub template fetch failed for {owner}/{repo}:{path}@{git_ref} with status {status}: {body}"
);
}
let payload: GitHubContentsFileResponse = response.json().await.with_context(|| {
format!("failed to decode GitHub contents response for {owner}/{repo}:{path}@{git_ref}")
})?;
if payload.kind != "file" {
bail!("GitHub template target {owner}/{repo}:{path}@{git_ref} is not a file");
}
let content = payload
.content
.ok_or_else(|| anyhow!("GitHub contents response omitted file content"))?;
let encoding = payload.encoding.unwrap_or_default();
if encoding != "base64" {
bail!("unsupported GitHub contents encoding: {encoding}");
}
let decoded = BASE64_STANDARD
.decode(content.replace('\n', ""))
.context("failed to decode GitHub contents payload")?;
String::from_utf8(decoded)
.context("GitHub template file is not valid UTF-8")
.map(Some)
}
async fn materialize_template(agent_home: &Path, template: &ResolvedTemplate) -> Result<()> {
ensure_agent_home_layout(agent_home)?;
let agents_md_path = agent_home.join(TEMPLATE_AGENTS_FILENAME);
if agents_md_path.exists() {
bail!(
"{} already exists; template initialization refuses to overwrite live AGENTS.md",
agents_md_path.display()
);
}
let agents_md = render_agent_home_agents_md(&template.agents_md, None);
fs::write(&agents_md_path, &agents_md)
.with_context(|| format!("failed to write {}", agents_md_path.display()))?;
let skills_root = agent_home.join("skills");
for skill_ref in &template.skill_refs {
materialize_skill_ref(agent_home, &skills_root, skill_ref).await?;
}
Ok(())
}
pub fn ensure_agent_home_layout(agent_home: &Path) -> Result<()> {
fs::create_dir_all(agent_home)
.with_context(|| format!("failed to create {}", agent_home.display()))?;
for dir in [
agent_home.join("memory"),
agent_home.join("notes"),
agent_home.join("work"),
agent_home.join("work-items"),
agent_home.join("skills"),
agent_home.join(".holon/state"),
agent_home.join(".holon/ledger"),
agent_home.join(".holon/indexes"),
agent_home.join(".holon/cache"),
] {
fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
}
create_file_if_missing(
&agent_memory_self_path(agent_home),
MEMORY_SELF_INITIAL.as_bytes(),
)?;
create_file_if_missing(
&agent_memory_operator_path(agent_home),
MEMORY_OPERATOR_INITIAL.as_bytes(),
)?;
Ok(())
}
fn create_file_if_missing(path: &Path, content: &[u8]) -> Result<()> {
if path.exists() {
return Ok(());
}
write_file_atomically(path, content)
}
fn render_agent_home_agents_md(template_guidance: &str, profile_seed: Option<&str>) -> String {
let mut sections = Vec::new();
let template_guidance = template_guidance.trim();
if !template_guidance.is_empty() {
sections.push(template_guidance.to_string());
}
sections.push(REQUIRED_AGENT_HOME_GUIDANCE.trim().to_string());
if let Some(profile_seed) = profile_seed.map(str::trim).filter(|seed| !seed.is_empty()) {
sections.push(profile_seed.to_string());
}
format!("{}\n", sections.join("\n\n"))
}
async fn materialize_skill_ref(
agent_home: &Path,
skills_root: &Path,
skill_ref: &TemplateSkillRef,
) -> Result<PathBuf> {
match skill_ref {
TemplateSkillRef::Local { path } => materialize_local_skill_ref(skills_root, path),
TemplateSkillRef::Builtin { name } => materialize_builtin_skill_ref(skills_root, name),
TemplateSkillRef::Github { package } => materialize_github_skill_ref(agent_home, package),
}
}
fn materialize_github_skill_ref(agent_home: &Path, package: &str) -> Result<PathBuf> {
let install_kind = template_github_skill_install_kind(package)?;
let user_home = user_home_dir()?;
let skill_name = crate::skills::install_skill_with_user_home(
agent_home,
Some(user_home.as_path()),
&install_kind,
)?;
Ok(agent_home.join("skills").join(skill_name))
}
fn builtin_skill(name: &str) -> Option<&'static BuiltinSkill> {
BUILTIN_SKILLS.iter().find(|skill| skill.name == name)
}
pub fn builtin_skill_names() -> Vec<&'static str> {
BUILTIN_SKILLS.iter().map(|skill| skill.name).collect()
}
pub fn materialize_builtin_skill_ref(skills_root: &Path, name: &str) -> Result<PathBuf> {
let skill = builtin_skill(name).ok_or_else(|| anyhow!("unknown builtin skill ref: {name}"))?;
fs::create_dir_all(skills_root)
.with_context(|| format!("failed to create {}", skills_root.display()))?;
let destination = skills_root.join(skill.name);
if destination.exists() {
bail!(
"template skill destination {} already exists",
destination.display()
);
}
fs::create_dir_all(&destination)
.with_context(|| format!("failed to create {}", destination.display()))?;
write_file_atomically(&destination.join("SKILL.md"), skill.skill_md.as_bytes())?;
for file in skill.files {
write_file_atomically(&destination.join(file.path), file.content.as_bytes())?;
}
Ok(destination)
}
pub fn materialize_local_skill_ref(skills_root: &Path, path: &Path) -> Result<PathBuf> {
if !path.is_absolute() {
bail!("local skill ref path must be absolute: {}", path.display());
}
if !path.is_dir() {
bail!(
"local skill ref directory does not exist: {}",
path.display()
);
}
let skill_entrypoint = path.join("SKILL.md");
if !skill_entrypoint.is_file() {
bail!(
"local skill ref {} does not contain SKILL.md",
path.display()
);
}
let skill_name = path
.file_name()
.and_then(|name| name.to_str())
.filter(|name| !name.is_empty())
.ok_or_else(|| {
anyhow!(
"local skill ref {} has no usable directory name",
path.display()
)
})?;
fs::create_dir_all(skills_root)
.with_context(|| format!("failed to create {}", skills_root.display()))?;
let destination = skills_root.join(skill_name);
if destination.exists() {
bail!(
"template skill destination {} already exists",
destination.display()
);
}
create_directory_symlink(path, &destination).with_context(|| {
format!(
"failed to materialize local skill ref {} -> {}",
path.display(),
destination.display()
)
})?;
Ok(destination)
}
pub fn remove_materialized_skill_destination(destination: &Path) -> std::io::Result<()> {
let metadata = fs::symlink_metadata(destination)?;
if metadata.file_type().is_symlink() {
fs::remove_file(destination)
} else {
fs::remove_dir_all(destination)
}
}
#[cfg(unix)]
fn create_directory_symlink(src: &Path, dst: &Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(src, dst)
}
#[cfg(windows)]
fn create_directory_symlink(src: &Path, dst: &Path) -> std::io::Result<()> {
std::os::windows::fs::symlink_dir(src, dst)
}
#[cfg(test)]
mod tests {
use std::{
io::{Read, Write},
net::TcpListener,
sync::{Arc, LazyLock, Mutex},
thread,
};
use tempfile::tempdir;
use super::*;
static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
struct EnvGuard {
key: &'static str,
old: Option<String>,
}
impl EnvGuard {
fn set(key: &'static str, value: String) -> Self {
let old = env::var(key).ok();
env::set_var(key, value);
Self { key, old }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.old {
Some(value) => env::set_var(self.key, value),
None => env::remove_var(self.key),
}
}
}
#[test]
fn seed_builtin_templates_is_idempotent() {
let home = tempdir().unwrap();
let templates = templates_root_for_home(home.path());
seed_builtin_templates_for_home(home.path()).unwrap();
let developer_agents = templates.join("holon-developer/AGENTS.md");
assert!(developer_agents.is_file());
fs::write(&developer_agents, "custom").unwrap();
seed_builtin_templates_for_home(home.path()).unwrap();
assert_eq!(fs::read_to_string(&developer_agents).unwrap(), "custom");
}
#[test]
fn seed_builtin_templates_writes_prefixed_default_template() {
let home = tempdir().unwrap();
let templates = templates_root_for_home(home.path());
seed_builtin_templates_for_home(home.path()).unwrap();
let agents_md = templates.join("holon-default/AGENTS.md");
assert!(agents_md.is_file());
assert!(fs::read_to_string(agents_md)
.unwrap()
.contains("Holon Default Agent"));
}
#[test]
fn seed_builtin_templates_upgrades_managed_builtin_content() {
let home = tempdir().unwrap();
let templates = templates_root_for_home(home.path());
seed_builtin_templates_for_home(home.path()).unwrap();
let template_dir = templates.join("holon-reviewer");
let agents_md = template_dir.join("AGENTS.md");
let state_path = builtin_template_state_path(&template_dir);
let original = fs::read_to_string(&agents_md).unwrap();
let mut state: BuiltinTemplateState =
serde_json::from_str(&fs::read_to_string(&state_path).unwrap()).unwrap();
state.version = 0;
fs::write(&state_path, serde_json::to_vec_pretty(&state).unwrap()).unwrap();
seed_builtin_templates_for_home(home.path()).unwrap();
assert_eq!(fs::read_to_string(&agents_md).unwrap(), original);
let upgraded: BuiltinTemplateState =
serde_json::from_str(&fs::read_to_string(&state_path).unwrap()).unwrap();
assert_eq!(upgraded.version, 1);
}
#[test]
fn seed_builtin_templates_tolerates_empty_builtin_state_file() {
let home = tempdir().unwrap();
let templates = templates_root_for_home(home.path());
seed_builtin_templates_for_home(home.path()).unwrap();
let template_dir = templates.join("holon-default");
fs::write(builtin_template_state_path(&template_dir), "").unwrap();
seed_builtin_templates_for_home(home.path()).unwrap();
assert!(template_dir.join("AGENTS.md").is_file());
}
#[test]
fn discover_agent_templates_catalog_lists_stable_selectors_and_shadowing() {
let user_home = tempdir().unwrap();
let agent_home = tempdir().unwrap();
let user_templates = templates_root_for_home(user_home.path());
seed_builtin_templates_for_home(user_home.path()).unwrap();
let worker = user_templates.join("worker");
fs::create_dir_all(&worker).unwrap();
fs::write(
worker.join(TEMPLATE_AGENTS_FILENAME),
"# Worker\n\nDoes worker things\n",
)
.unwrap();
fs::write(
worker.join(TEMPLATE_SKILLS_FILENAME),
r#"{"skill_refs":[{"kind":"builtin","name":"ghx"}]}"#,
)
.unwrap();
let shadowed_default = user_templates.join("holon-default");
fs::create_dir_all(&shadowed_default).unwrap();
fs::write(
shadowed_default.join(TEMPLATE_AGENTS_FILENAME),
"# Custom default\n",
)
.unwrap();
let local = agent_home.path().join("templates").join("local-agent");
fs::create_dir_all(&local).unwrap();
fs::write(
local.join(TEMPLATE_AGENTS_FILENAME),
"# Local agent\n\nLocal template\n",
)
.unwrap();
let catalog = discover_agent_templates_catalog(Some(user_home.path()), agent_home.path());
assert!(catalog
.iter()
.any(|entry| entry.catalog_id == "user_global:holon-default"));
assert!(!catalog
.iter()
.any(|entry| entry.catalog_id == "builtin:holon-default"));
assert!(catalog
.iter()
.any(|entry| entry.catalog_id == "builtin:holon-developer"));
assert!(!catalog
.iter()
.any(|entry| entry.catalog_id == "user_global:holon-developer"));
let worker_entry = catalog
.iter()
.find(|entry| entry.catalog_id == "user_global:worker")
.unwrap();
assert_eq!(worker_entry.template, "worker");
assert_eq!(worker_entry.source, AgentTemplateSourceKind::UserGlobal);
assert_eq!(worker_entry.path.as_deref(), Some(worker.as_path()));
assert_eq!(worker_entry.description, "Does worker things");
assert_eq!(worker_entry.included_skills, vec!["ghx"]);
let local_entry = catalog
.iter()
.find(|entry| entry.catalog_id == "agent_home:local-agent")
.unwrap();
assert_eq!(local_entry.template, local.display().to_string());
assert_eq!(local_entry.path.as_deref(), Some(local.as_path()));
}
#[test]
fn discover_agent_templates_catalog_prefers_agent_home_over_other_sources() {
let user_home = tempdir().unwrap();
let agent_home = tempdir().unwrap();
let user_templates = templates_root_for_home(user_home.path());
seed_builtin_templates_for_home(user_home.path()).unwrap();
let user_worker = user_templates.join("worker");
fs::create_dir_all(&user_worker).unwrap();
fs::write(
user_worker.join(TEMPLATE_AGENTS_FILENAME),
"# User worker\n\nUser-global worker\n",
)
.unwrap();
let agent_templates = agent_home.path().join("templates");
let agent_worker = agent_templates.join("worker");
fs::create_dir_all(&agent_worker).unwrap();
fs::write(
agent_worker.join(TEMPLATE_AGENTS_FILENAME),
"# Agent worker\n\nAgent-home worker\n",
)
.unwrap();
let agent_default = agent_templates.join("holon-default");
fs::create_dir_all(&agent_default).unwrap();
fs::write(
agent_default.join(TEMPLATE_AGENTS_FILENAME),
"# Agent default\n\nAgent-home default\n",
)
.unwrap();
let catalog = discover_agent_templates_catalog(Some(user_home.path()), agent_home.path());
assert!(catalog
.iter()
.any(|entry| entry.catalog_id == "agent_home:worker"));
assert!(!catalog
.iter()
.any(|entry| entry.catalog_id == "user_global:worker"));
assert!(catalog
.iter()
.any(|entry| entry.catalog_id == "agent_home:holon-default"));
assert!(!catalog
.iter()
.any(|entry| entry.catalog_id == "builtin:holon-default"));
assert!(!catalog
.iter()
.any(|entry| entry.catalog_id == "user_global:holon-default"));
}
#[tokio::test]
async fn prefixed_template_selector_resolves_requested_source_when_shadowed() {
let user_home = tempdir().unwrap();
let agent_home = tempdir().unwrap();
let user_templates = templates_root_for_home(user_home.path());
seed_builtin_templates_for_home(user_home.path()).unwrap();
let agent_default = agent_home.path().join("templates").join("holon-default");
fs::create_dir_all(&agent_default).unwrap();
fs::write(
agent_default.join(TEMPLATE_AGENTS_FILENAME),
"# Agent default\n\nAgent-home default\n",
)
.unwrap();
let user_worker = user_templates.join("worker");
fs::create_dir_all(&user_worker).unwrap();
fs::write(
user_worker.join(TEMPLATE_AGENTS_FILENAME),
"# User worker\n\nUser-global worker\n",
)
.unwrap();
let agent_worker = agent_home.path().join("templates").join("worker");
fs::create_dir_all(&agent_worker).unwrap();
fs::write(
agent_worker.join(TEMPLATE_AGENTS_FILENAME),
"# Agent worker\n\nAgent-home worker\n",
)
.unwrap();
let builtin =
resolve_template("builtin:holon-default", user_home.path(), agent_home.path())
.await
.unwrap();
assert!(builtin.agents_md.contains("## Role"));
let user = resolve_template("user_global:worker", user_home.path(), agent_home.path())
.await
.unwrap();
assert_eq!(user.agents_md, "# User worker\n\nUser-global worker\n");
let agent = resolve_template("agent_home:worker", user_home.path(), agent_home.path())
.await
.unwrap();
assert_eq!(agent.agents_md, "# Agent worker\n\nAgent-home worker\n");
}
#[test]
fn template_description_skips_multiline_html_comments() {
assert_eq!(
template_description("<!--\nmetadata\n-->\n# Heading\n\nVisible description\n"),
"Visible description"
);
assert_eq!(template_description("<!-- hidden --> Visible\n"), "Visible");
}
#[test]
fn builtin_template_is_managed_accounts_for_skills_json() {
let home = tempdir().unwrap();
let template_dir = home.path().join("template");
fs::create_dir_all(&template_dir).unwrap();
fs::write(template_dir.join(TEMPLATE_AGENTS_FILENAME), "agents").unwrap();
fs::write(
template_dir.join(TEMPLATE_SKILLS_FILENAME),
r#"{"skill_refs":[]}"#,
)
.unwrap();
let builtin = BuiltinTemplate {
template_id: "holon-test",
version: 1,
agents_md: "agents",
skills_json: Some(r#"{"skill_refs":[]}"#),
};
let state = BuiltinTemplateState {
template_id: builtin.template_id.to_string(),
version: builtin.version,
content_hash: builtin_template_content_hash(&builtin),
};
assert!(builtin_template_is_managed(&template_dir, &state).unwrap());
}
#[tokio::test]
async fn github_solve_template_materializes_builtin_skills() {
let _lock = ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let home = tempdir().unwrap();
let _guard = EnvGuard::set("HOME", home.path().display().to_string());
seed_builtin_templates().unwrap();
let agent_home = home.path().join("agent");
initialize_agent_home_from_template(&agent_home, "holon-github-solve")
.await
.unwrap();
let agents_md = fs::read_to_string(agent_home.join("AGENTS.md")).unwrap();
assert!(agents_md.contains("Holon GitHub Solve Agent"));
assert!(agent_home
.join("skills/github-issue-solve/SKILL.md")
.is_file());
assert!(agent_home
.join("skills/github-issue-solve/references/issue-solve-workflow.md")
.is_file());
assert!(agent_home.join("skills/github-pr-fix/SKILL.md").is_file());
assert!(agent_home
.join("skills/github-pr-fix/references/diagnostics.md")
.is_file());
assert!(agent_home
.join("skills/github-pr-fix/references/pr-fix-workflow.md")
.is_file());
assert!(agent_home.join("skills/github-review/SKILL.md").is_file());
assert!(agent_home.join("skills/ghx/SKILL.md").is_file());
}
#[tokio::test]
async fn initialize_agent_home_from_template_id_materializes_agents_md_and_local_skill() {
let _lock = ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let home = tempdir().unwrap();
let _guard = EnvGuard::set("HOME", home.path().display().to_string());
let templates = templates_root().unwrap();
fs::create_dir_all(&templates).unwrap();
let source_skill = home.path().join("source-skill");
fs::create_dir_all(&source_skill).unwrap();
fs::write(
source_skill.join("SKILL.md"),
"---\nname: source\n---\nbody",
)
.unwrap();
let template_dir = templates.join("worker");
fs::create_dir_all(&template_dir).unwrap();
fs::write(template_dir.join("AGENTS.md"), "worker instructions").unwrap();
fs::write(
template_dir.join("skills.json"),
serde_json::json!({
"skill_refs": [
{ "kind": "local", "path": source_skill }
]
})
.to_string(),
)
.unwrap();
let agent_home = home.path().join("agent");
let provenance = initialize_agent_home_from_template(&agent_home, "worker")
.await
.unwrap();
assert!(matches!(
provenance.source,
TemplateProvenanceSource::TemplateId { .. }
));
let agents_md = fs::read_to_string(agent_home.join("AGENTS.md")).unwrap();
assert!(agents_md.contains("worker instructions"));
assert!(agents_md.contains("## Holon Agent Home"));
assert!(agents_md.contains("memory/self.md"));
assert!(agent_home.join("skills/source-skill/SKILL.md").exists());
assert!(agent_memory_self_path(&agent_home).is_file());
assert!(agent_memory_operator_path(&agent_home).is_file());
assert!(agent_home.join("notes").is_dir());
assert!(agent_home.join("work").is_dir());
assert!(agent_home.join(".holon/state").is_dir());
assert!(agent_home.join(".holon/ledger").is_dir());
assert!(agent_home.join(".holon/indexes").is_dir());
assert!(agent_home.join(".holon/cache").is_dir());
assert!(template_provenance_path(&agent_home).is_file());
}
#[tokio::test]
async fn catalog_id_initializes_from_agent_home_catalog() {
let home = tempdir().unwrap();
let catalog_agent_home = tempdir().unwrap();
let template_dir = catalog_agent_home.path().join("templates").join("worker");
fs::create_dir_all(&template_dir).unwrap();
fs::write(
template_dir.join("AGENTS.md"),
"catalog worker instructions",
)
.unwrap();
let agent_home = home.path().join("agent-catalog-id");
let provenance = initialize_agent_home_from_template_with_catalog(
&agent_home,
home.path(),
catalog_agent_home.path(),
"agent_home:worker",
)
.await
.unwrap();
assert!(fs::read_to_string(agent_home.join("AGENTS.md"))
.unwrap()
.contains("catalog worker instructions"));
assert!(matches!(
provenance.source,
TemplateProvenanceSource::TemplateId {
ref template_id,
ref path
} if template_id == "worker" && path == &template_dir
));
let bare_agent_home = home.path().join("agent-bare-id");
initialize_agent_home_from_template_with_catalog(
&bare_agent_home,
home.path(),
catalog_agent_home.path(),
"worker",
)
.await
.unwrap();
assert!(fs::read_to_string(bare_agent_home.join("AGENTS.md"))
.unwrap()
.contains("catalog worker instructions"));
}
#[tokio::test]
async fn template_catalog_selector_error_lists_known_ids() {
let home = tempdir().unwrap();
let catalog_agent_home = tempdir().unwrap();
let template_dir = catalog_agent_home.path().join("templates").join("worker");
fs::create_dir_all(&template_dir).unwrap();
fs::write(
template_dir.join("AGENTS.md"),
"catalog worker instructions",
)
.unwrap();
let agent_home = home.path().join("agent");
let err = initialize_agent_home_from_template_with_catalog(
&agent_home,
home.path(),
catalog_agent_home.path(),
"missing",
)
.await
.unwrap_err();
let message = err.to_string();
assert!(message.contains("unknown template selector: missing"));
assert!(message.contains("known template ids/catalog ids"));
assert!(message.contains("agent_home:worker"));
assert!(message.contains("worker"));
assert!(message.contains(&template_dir.display().to_string()));
assert!(message.contains("agent_home"));
}
#[tokio::test]
async fn template_without_directory_guidance_still_gets_required_agent_home_guidance() {
let home = tempdir().unwrap();
let template_dir = home.path().join("template");
fs::create_dir_all(&template_dir).unwrap();
fs::write(template_dir.join("AGENTS.md"), "role only").unwrap();
let agent_home = home.path().join("agent");
initialize_agent_home_from_template_with_home(
&agent_home,
home.path(),
template_dir.to_str().unwrap(),
)
.await
.unwrap();
let agents_md = fs::read_to_string(agent_home.join("AGENTS.md")).unwrap();
assert!(agents_md.starts_with("role only"));
assert!(agents_md.contains("## Holon Agent Home"));
assert!(agents_md.contains("`.holon/` is runtime-owned"));
assert!(agents_md.contains("active project workspace"));
}
#[tokio::test]
async fn initialize_agent_home_from_absolute_template_path_works() {
let home = tempdir().unwrap();
let template_dir = home.path().join("template");
fs::create_dir_all(&template_dir).unwrap();
fs::write(template_dir.join("AGENTS.md"), "absolute template").unwrap();
let agent_home = home.path().join("agent");
let provenance =
initialize_agent_home_from_template(&agent_home, template_dir.to_str().unwrap())
.await
.unwrap();
assert!(matches!(
provenance.source,
TemplateProvenanceSource::LocalPath { .. }
));
let agents_md = fs::read_to_string(agent_home.join("AGENTS.md")).unwrap();
assert!(agents_md.contains("absolute template"));
assert!(agents_md.contains("## Holon Agent Home"));
}
#[tokio::test]
async fn ensure_agent_home_agents_md_from_template_fills_missing_agents_md() {
let _lock = ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let home = tempdir().unwrap();
let _guard = EnvGuard::set("HOME", home.path().display().to_string());
seed_builtin_templates().unwrap();
let agent_home = home.path().join("agent");
fs::create_dir_all(&agent_home).unwrap();
fs::create_dir_all(agent_home.join(".holon/state")).unwrap();
fs::write(agent_home.join(".holon/state/agent.json"), "{}").unwrap();
let record =
ensure_agent_home_agents_md_from_template(&agent_home, DEFAULT_AGENT_TEMPLATE_ID)
.await
.unwrap()
.expect("missing AGENTS.md should be materialized");
assert!(matches!(
record.source,
TemplateProvenanceSource::TemplateId { .. }
));
assert!(fs::read_to_string(agent_home.join("AGENTS.md"))
.unwrap()
.contains("Holon Default Agent"));
assert!(fs::read_to_string(agent_home.join("AGENTS.md"))
.unwrap()
.contains("## Holon Agent Home"));
}
#[tokio::test]
async fn ensure_agent_home_agents_md_rolls_back_on_skill_failure() {
let _lock = ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let home = tempdir().unwrap();
let _guard = EnvGuard::set("HOME", home.path().display().to_string());
let templates = templates_root().unwrap();
fs::create_dir_all(&templates).unwrap();
let valid_skill = home.path().join("valid-skill");
fs::create_dir_all(&valid_skill).unwrap();
fs::write(valid_skill.join("SKILL.md"), "# Valid Skill\n").unwrap();
let template_dir = templates.join("broken");
fs::create_dir_all(&template_dir).unwrap();
fs::write(template_dir.join("AGENTS.md"), "broken").unwrap();
fs::write(
template_dir.join("skills.json"),
serde_json::json!({
"skill_refs": [
{"kind": "local", "path": valid_skill},
{"kind": "local", "path": "relative/path"}
]
})
.to_string(),
)
.unwrap();
let agent_home = home.path().join("agent");
fs::create_dir_all(&agent_home).unwrap();
fs::create_dir_all(agent_home.join(".holon/state")).unwrap();
fs::write(agent_home.join(".holon/state/agent.json"), "{}").unwrap();
let err = ensure_agent_home_agents_md_from_template(&agent_home, "broken")
.await
.unwrap_err();
assert!(err.to_string().contains("absolute"));
assert!(!agent_home.join("AGENTS.md").exists());
assert!(!template_provenance_path(&agent_home).exists());
assert!(!agent_home.join("skills/valid-skill").exists());
assert!(agent_home.join("skills").is_dir());
}
#[tokio::test]
async fn initialize_agent_home_fails_closed_on_invalid_skill_ref() {
let _lock = ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let home = tempdir().unwrap();
let _guard = EnvGuard::set("HOME", home.path().display().to_string());
let templates = templates_root().unwrap();
fs::create_dir_all(&templates).unwrap();
let template_dir = templates.join("broken");
fs::create_dir_all(&template_dir).unwrap();
fs::write(template_dir.join("AGENTS.md"), "broken").unwrap();
fs::write(
template_dir.join("skills.json"),
r#"{"skill_refs":[{"kind":"local","path":"relative/path"}]}"#,
)
.unwrap();
let agent_home = home.path().join("agent");
let err = initialize_agent_home_from_template(&agent_home, "broken")
.await
.unwrap_err();
assert!(err.to_string().contains("absolute"));
assert!(!agent_home.exists());
}
#[tokio::test]
async fn initialize_agent_home_restores_preexisting_empty_agent_home_on_failure() {
let _lock = ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let home = tempdir().unwrap();
let _guard = EnvGuard::set("HOME", home.path().display().to_string());
let templates = templates_root().unwrap();
fs::create_dir_all(&templates).unwrap();
let template_dir = templates.join("broken");
fs::create_dir_all(&template_dir).unwrap();
fs::write(template_dir.join("AGENTS.md"), "broken").unwrap();
fs::write(
template_dir.join("skills.json"),
r#"{"skill_refs":[{"kind":"local","path":"relative/path"}]}"#,
)
.unwrap();
let agent_home = home.path().join("agent");
fs::create_dir_all(&agent_home).unwrap();
let err = initialize_agent_home_from_template(&agent_home, "broken")
.await
.unwrap_err();
assert!(err.to_string().contains("absolute"));
assert!(agent_home.exists());
assert!(fs::read_dir(&agent_home).unwrap().next().is_none());
}
#[test]
fn parse_skill_refs_accepts_github_skill_refs() {
let home = tempdir().unwrap();
let manifest_path = home.path().join("skills.json");
fs::write(
&manifest_path,
r#"{"skill_refs":[{"kind":"github","package":"owner/repo@skill"},{"kind":"github","package":"@scope/package"}]}"#,
)
.unwrap();
let refs = parse_skill_refs(manifest_path).unwrap();
assert_eq!(refs.len(), 2);
assert!(
matches!(&refs[0], TemplateSkillRef::Github { package } if package == "owner/repo@skill")
);
assert!(
matches!(&refs[1], TemplateSkillRef::Github { package } if package == "@scope/package")
);
}
#[test]
fn template_github_skill_install_kind_splits_skill_selector() {
let kind = template_github_skill_install_kind("owner/repo@skill").unwrap();
assert!(matches!(
kind,
SkillInstallKind::Remote {
package,
skill: Some(skill),
mode: SkillInstallMode::Linked,
} if package == "owner/repo" && skill == "skill"
));
let scoped = template_github_skill_install_kind("@scope/package").unwrap();
assert!(matches!(
scoped,
SkillInstallKind::Remote {
package,
skill: None,
mode: SkillInstallMode::Linked,
} if package == "@scope/package"
));
}
#[test]
fn parse_skill_refs_rejects_invalid_github_skill_refs() {
let home = tempdir().unwrap();
let manifest_path = home.path().join("skills.json");
fs::write(
&manifest_path,
r#"{"skill_refs":[{"kind":"github","package":"owner/repo@../bad"}]}"#,
)
.unwrap();
let err = parse_skill_refs(manifest_path).unwrap_err();
assert!(err.to_string().contains("plain skill directory name"));
}
#[test]
fn user_home_dir_falls_back_to_userprofile() {
let _lock = ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let profile = tempdir().unwrap();
let _home = EnvGuard::set("HOME", String::new());
let _userprofile = EnvGuard::set("USERPROFILE", profile.path().display().to_string());
assert_eq!(user_home_dir().unwrap(), profile.path());
}
#[tokio::test]
async fn initialize_agent_home_from_github_url_works() {
let _lock = ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let home = tempdir().unwrap();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let seen_paths = Arc::new(Mutex::new(Vec::new()));
let seen_paths_clone = seen_paths.clone();
thread::spawn(move || {
for stream in listener.incoming().take(2) {
let mut stream = stream.unwrap();
let mut buffer = [0_u8; 2048];
let count = stream.read(&mut buffer).unwrap();
let request = String::from_utf8_lossy(&buffer[..count]);
let request_line = request.lines().next().unwrap().to_string();
let path = request_line.split_whitespace().nth(1).unwrap().to_string();
seen_paths_clone.lock().unwrap().push(path.clone());
let body =
if path == "/repos/owner/repo/contents/templates/reviewer/AGENTS.md?ref=main" {
serde_json::json!({
"type": "file",
"encoding": "base64",
"content": BASE64_STANDARD.encode("reviewer rules")
})
.to_string()
} else {
"{\"message\":\"not found\"}".to_string()
};
let status = if body.contains("not found") {
"404 Not Found"
} else {
"200 OK"
};
write!(
stream,
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
)
.unwrap();
}
});
let _api_guard =
EnvGuard::set("HOLON_TEMPLATE_GITHUB_API_BASE", format!("http://{}", addr));
let agent_home = home.path().join("agent");
let url = "https://github.com/owner/repo/tree/main/templates/reviewer";
let provenance = initialize_agent_home_from_template(&agent_home, url)
.await
.unwrap();
assert!(matches!(
provenance.source,
TemplateProvenanceSource::GitHubUrl { .. }
));
let agents_md = fs::read_to_string(agent_home.join("AGENTS.md")).unwrap();
assert!(agents_md.contains("reviewer rules"));
assert!(agents_md.contains("## Holon Agent Home"));
assert_eq!(
seen_paths.lock().unwrap().as_slice(),
&[
"/repos/owner/repo/contents/templates/reviewer/AGENTS.md?ref=main",
"/repos/owner/repo/contents/templates/reviewer/skills.json?ref=main",
]
);
}
}