use std::path::{Path, PathBuf};
use anyhow::Result as AnyResult;
use serde::{Deserialize, Serialize};
use crate::prompt::BUNDLED_PROMPT_FILES;
use crate::runtime_config;
use crate::skills::{list_skills, read_skill};
const CWD_ADDENDUM_AGENT0: &str = "0.md";
const CWD_ADDENDUM_AGENTINFINITY: &str = "agentinfinity.md";
const CWD_ADDENDUM_CLONE_EXT: &str = ".md";
const BASE_TEMPLATE: &str = include_str!("../prompts/base.md");
const AGENT0_STANZA: &str = include_str!("../prompts/agent0.md");
const CLONE_STANZA: &str = include_str!("../prompts/clone.md");
const AGENTINFINITY_STANZA: &str = include_str!("../prompts/agentinfinity.md");
const SEPARATOR: &str = "\n\n---\n\n";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PromptAgentKind {
Agent0,
Clone,
Agentinfinity,
}
pub trait PromptAgent: Copy {
fn prompt_agent_kind(self) -> PromptAgentKind;
fn prompt_agent_name(self) -> String;
fn prompt_agent_env_n(self) -> String;
}
#[derive(Debug, Clone)]
pub struct PromptContext<A: PromptAgent> {
pub agent: A,
pub cwd: String,
}
impl<A: PromptAgent> PromptContext<A> {
pub fn new(agent: A, cwd: impl Into<String>) -> Self {
Self {
agent,
cwd: cwd.into(),
}
}
fn bindings(&self) -> Vec<(&'static str, String)> {
vec![
("agent_name", self.agent.prompt_agent_name()),
("n", self.agent.prompt_agent_env_n()),
("cwd", self.cwd.clone()),
]
}
}
#[derive(Debug)]
pub enum PromptError {
Io(std::io::Error),
Config(anyhow::Error),
Skill(anyhow::Error),
UnsubstitutedPlaceholders { count: usize, preview: String },
}
impl std::fmt::Display for PromptError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(e) => write!(f, "io error reading prompt layer: {e}"),
Self::Config(e) => write!(f, "runtime config error reading prompt layer: {e}"),
Self::Skill(e) => write!(f, "skill resolver error: {e}"),
Self::UnsubstitutedPlaceholders { count, preview } => write!(
f,
"template render left {count} unsubstituted placeholder(s): {preview}"
),
}
}
}
impl std::error::Error for PromptError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(e) => Some(e),
Self::Config(e) | Self::Skill(e) => Some(e.as_ref()),
Self::UnsubstitutedPlaceholders { .. } => None,
}
}
}
impl From<std::io::Error> for PromptError {
fn from(e: std::io::Error) -> Self {
Self::Io(e)
}
}
impl From<anyhow::Error> for PromptError {
fn from(e: anyhow::Error) -> Self {
Self::Config(e)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PromptLayerKind {
Base,
Identity,
CwdAddendum,
RuntimeAddendum,
Skill,
HarnessDoc,
InstallAsset,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PromptLayerOrigin {
LiveFile,
BundledFile,
RuntimeConfig,
Virtual,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PromptLayer {
pub id: String,
pub kind: PromptLayerKind,
pub origin: PromptLayerOrigin,
pub path: Option<PathBuf>,
pub git_path: Option<PathBuf>,
pub body: String,
pub active: bool,
pub note: Option<String>,
}
impl PromptLayer {
pub fn bytes(&self) -> usize {
self.body.len()
}
}
#[derive(Debug, Default, Deserialize)]
struct NetskyToml {
#[serde(default)]
addendum: Option<AddendumConfig>,
}
#[derive(Debug, Default, Deserialize)]
struct AddendumConfig {
agent0: Option<String>,
agentinfinity: Option<String>,
#[serde(rename = "clone_default")]
clone_default: Option<String>,
}
fn load_netsky_toml(path: &Path) -> AnyResult<Option<NetskyToml>> {
let raw = match std::fs::read_to_string(path) {
Ok(raw) => raw,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(err.into()),
};
let cfg = toml::from_str::<NetskyToml>(&raw)?;
Ok(Some(cfg))
}
fn configured_addendum<A: PromptAgent>(agent: A, cwd: &Path) -> Option<String> {
let cfg = load_netsky_toml(&cwd.join("netsky.toml")).ok().flatten()?;
let addendum = cfg.addendum?;
match agent.prompt_agent_kind() {
PromptAgentKind::Agent0 => addendum.agent0,
PromptAgentKind::Agentinfinity => addendum.agentinfinity,
PromptAgentKind::Clone => addendum.clone_default,
}
}
fn resolve_addendum_path<A: PromptAgent>(agent: A, cwd: &Path) -> PathBuf {
match configured_addendum(agent, cwd) {
Some(p) if p.starts_with('/') => PathBuf::from(p),
Some(p) if p.starts_with("~/") => dirs::home_dir()
.map(|home| home.join(p.trim_start_matches("~/")))
.unwrap_or_else(|| cwd.join(p)),
Some(p) => cwd.join(p),
None => cwd.join(cwd_addendum_filename(agent)),
}
}
fn cwd_addendum_filename<A: PromptAgent>(agent: A) -> String {
match agent.prompt_agent_kind() {
PromptAgentKind::Agent0 => CWD_ADDENDUM_AGENT0.to_string(),
PromptAgentKind::Agentinfinity => CWD_ADDENDUM_AGENTINFINITY.to_string(),
PromptAgentKind::Clone => format!("{}{CWD_ADDENDUM_CLONE_EXT}", agent.prompt_agent_env_n()),
}
}
fn stanza_for<A: PromptAgent>(agent: A) -> (&'static str, &'static str) {
match agent.prompt_agent_kind() {
PromptAgentKind::Agent0 => ("agent:root", AGENT0_STANZA),
PromptAgentKind::Clone => ("agent:clone", CLONE_STANZA),
PromptAgentKind::Agentinfinity => ("agent:watchdog", AGENTINFINITY_STANZA),
}
}
fn apply_bindings(body: &str, bindings: &[(&'static str, String)]) -> String {
let mut out = body.to_string();
for (name, value) in bindings {
for placeholder in [
format!("{{{{ {name} }}}}"),
format!("{{{{{name}}}}}"),
format!("{{{{ {name}}}}}"),
format!("{{{{{name} }}}}"),
] {
out = out.replace(&placeholder, value);
}
}
out
}
fn git_path_for(repo_root: &Path, path: &Path) -> Option<PathBuf> {
path.strip_prefix(repo_root).ok().map(PathBuf::from)
}
fn assert_fully_rendered(body: &str) -> Result<(), PromptError> {
let count = body.matches("{{").count();
if count == 0 {
return Ok(());
}
let preview = body
.match_indices("{{")
.take(3)
.map(|(i, _)| {
let end = body.len().min(i + 32);
body[i..end].to_string()
})
.collect::<Vec<_>>()
.join(" | ");
Err(PromptError::UnsubstitutedPlaceholders { count, preview })
}
#[allow(clippy::too_many_arguments)]
fn push_file_layer(
layers: &mut Vec<PromptLayer>,
id: impl Into<String>,
kind: PromptLayerKind,
origin: PromptLayerOrigin,
path: PathBuf,
git_path: Option<PathBuf>,
body: String,
active: bool,
note: impl Into<Option<String>>,
) {
layers.push(PromptLayer {
id: id.into(),
kind,
origin,
path: Some(path),
git_path,
body,
active,
note: note.into(),
});
}
pub fn resolve_layers<A: PromptAgent>(
ctx: PromptContext<A>,
cwd: &Path,
requested_skills: &[String],
) -> Result<Vec<PromptLayer>, PromptError> {
let agent = ctx.agent;
let bindings = ctx.bindings();
let mut layers = Vec::new();
let base_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("prompts/base.md");
push_file_layer(
&mut layers,
"base",
PromptLayerKind::Base,
PromptLayerOrigin::LiveFile,
base_path.clone(),
Some(PathBuf::from("src/crates/netsky-prompts/prompts/base.md")),
apply_bindings(BASE_TEMPLATE, &bindings),
true,
Some("live source".to_string()),
);
let (identity_id, identity_template) = stanza_for(agent);
let identity_git_path = match agent.prompt_agent_kind() {
PromptAgentKind::Agent0 => "src/crates/netsky-prompts/prompts/agent0.md",
PromptAgentKind::Clone => "src/crates/netsky-prompts/prompts/clone.md",
PromptAgentKind::Agentinfinity => "src/crates/netsky-prompts/prompts/agentinfinity.md",
};
let identity_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("prompts").join(
Path::new(identity_git_path)
.file_name()
.expect("identity file name"),
);
push_file_layer(
&mut layers,
identity_id,
PromptLayerKind::Identity,
PromptLayerOrigin::LiveFile,
identity_path,
Some(PathBuf::from(identity_git_path)),
apply_bindings(identity_template, &bindings),
true,
Some(format!("stanza for {}", agent.prompt_agent_name())),
);
let cwd_addendum_path = resolve_addendum_path(agent, cwd);
match std::fs::read_to_string(&cwd_addendum_path) {
Ok(raw) => {
let trimmed = raw.trim();
if !trimmed.is_empty() {
push_file_layer(
&mut layers,
"cwd:addendum",
PromptLayerKind::CwdAddendum,
PromptLayerOrigin::LiveFile,
cwd_addendum_path.clone(),
git_path_for(cwd, &cwd_addendum_path),
trimmed.to_string(),
true,
Some(format!("resolved from {}", cwd_addendum_path.display())),
);
}
}
Err(err)
if matches!(
err.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) => {}
Err(err) => return Err(PromptError::Io(err)),
}
let cfg = runtime_config::Config::load()?;
if let Some(base) = cfg.addendum.base.as_deref() {
let trimmed = base.trim();
if !trimmed.is_empty() {
layers.push(PromptLayer {
id: "runtime:addendum:base".to_string(),
kind: PromptLayerKind::RuntimeAddendum,
origin: PromptLayerOrigin::RuntimeConfig,
path: Some(cfg.addendum.base_path.clone()),
git_path: None,
body: trimmed.to_string(),
active: true,
note: Some(cfg.addendum.base_path.display().to_string()),
});
}
}
if let Some(host) = cfg.addendum.host.as_deref() {
let trimmed = host.trim();
if !trimmed.is_empty() {
layers.push(PromptLayer {
id: "runtime:addendum:host".to_string(),
kind: PromptLayerKind::RuntimeAddendum,
origin: PromptLayerOrigin::RuntimeConfig,
path: cfg.addendum.host_path.clone(),
git_path: None,
body: trimmed.to_string(),
active: true,
note: cfg
.addendum
.host_path
.as_ref()
.map(|p| p.display().to_string()),
});
}
}
for name in requested_skills {
let skill = read_skill(cwd, name).map_err(PromptError::Skill)?;
layers.push(PromptLayer {
id: format!("skill:{}", skill.summary.name),
kind: PromptLayerKind::Skill,
origin: PromptLayerOrigin::LiveFile,
path: Some(skill.summary.path.clone()),
git_path: git_path_for(cwd, &skill.summary.path),
body: skill.body,
active: true,
note: Some("invocation skill".to_string()),
});
}
Ok(layers)
}
pub fn compose(layers: &[PromptLayer]) -> Result<String, PromptError> {
let mut out = String::new();
for (index, layer) in layers.iter().filter(|layer| layer.active).enumerate() {
if index > 0 {
out.push_str(SEPARATOR);
}
out.push_str(layer.body.trim_end());
}
out.push('\n');
assert_fully_rendered(&out)?;
Ok(out)
}
pub fn resolve_catalog_layers<A: PromptAgent>(
ctx: PromptContext<A>,
cwd: &Path,
requested_skills: &[String],
) -> Result<Vec<PromptLayer>, PromptError> {
let mut extras = Vec::new();
let requested = requested_skills
.iter()
.cloned()
.collect::<std::collections::BTreeSet<_>>();
for skill in list_skills(cwd).map_err(PromptError::Skill)? {
if requested.contains(&skill.name) {
continue;
}
extras.push(PromptLayer {
id: format!("skill:{}", skill.name),
kind: PromptLayerKind::Skill,
origin: PromptLayerOrigin::LiveFile,
path: Some(skill.path.clone()),
git_path: git_path_for(cwd, &skill.path),
body: String::new(),
active: false,
note: Some(format!("available skill ({} bytes)", skill.bytes)),
});
}
let harness = [
("harness:agents", "AGENTS.md"),
("harness:claude", "CLAUDE.md"),
];
for (id, name) in harness {
let path = cwd.join(name);
if let Ok(body) = std::fs::read_to_string(&path) {
push_file_layer(
&mut extras,
id,
PromptLayerKind::HarnessDoc,
PromptLayerOrigin::LiveFile,
path.clone(),
git_path_for(cwd, &path),
body,
false,
Some("repo harness doc, not composed".to_string()),
);
}
}
let bundled_root = cwd.join("src/crates/netsky-cli/assets/root");
for name in ["AGENTS.md", "CLAUDE.md"] {
let path = bundled_root.join(name);
if let Ok(body) = std::fs::read_to_string(&path) {
push_file_layer(
&mut extras,
format!("bundled:{}", name.trim_end_matches(".md").to_lowercase()),
PromptLayerKind::InstallAsset,
PromptLayerOrigin::BundledFile,
path.clone(),
git_path_for(cwd, &path),
body,
false,
Some("bundled install asset".to_string()),
);
}
}
let prompt_assets = cwd.join("src/crates/netsky-cli/assets/root/prompts");
let mut saw_bundled_prompt = false;
if let Ok(read_dir) = std::fs::read_dir(&prompt_assets) {
let mut paths = read_dir
.flatten()
.map(|entry| entry.path())
.collect::<Vec<_>>();
paths.sort();
for path in paths {
if path.extension().and_then(|ext| ext.to_str()) != Some("md") || !path.is_file() {
continue;
}
let Ok(body) = std::fs::read_to_string(&path) else {
continue;
};
let name = path
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("unknown");
saw_bundled_prompt = true;
push_file_layer(
&mut extras,
format!("bundled:prompts:{name}"),
PromptLayerKind::InstallAsset,
PromptLayerOrigin::BundledFile,
path.clone(),
git_path_for(cwd, &path),
body,
false,
Some("bundled prompt asset".to_string()),
);
}
}
if !saw_bundled_prompt {
let prompt_root = cwd.join("src/crates/netsky-prompts/prompts");
for (name, body) in BUNDLED_PROMPT_FILES {
let layer_name = name.trim_end_matches(".md");
push_file_layer(
&mut extras,
format!("bundled:prompts:{layer_name}"),
PromptLayerKind::InstallAsset,
PromptLayerOrigin::BundledFile,
prompt_root.join(name),
Some(PathBuf::from(format!(
"src/crates/netsky-prompts/prompts/{name}"
))),
(*body).to_string(),
false,
Some("bundled prompt asset (embedded)".to_string()),
);
}
}
let mut ordered = resolve_layers(ctx, cwd, requested_skills)?;
let active_ids = ordered
.iter()
.map(|layer| layer.id.clone())
.collect::<std::collections::BTreeSet<_>>();
ordered.extend(
extras
.into_iter()
.filter(|layer| !active_ids.contains(&layer.id)),
);
Ok(ordered)
}