use std::path::{Path, PathBuf};
use spawningpool::ai::{Api, Reasoning};
use spawningpool::{EntityKind, ModelDef, ProviderDef, Registry, ScriptError, Specialist};
use crate::cli::DefineEntity;
use crate::display::{available_names, defined_tools};
pub(crate) fn define(entity: DefineEntity) -> Result<(), String> {
if let DefineEntity::Tool { name, script } = &entity {
return define_tool(name, script);
}
let mut registry = spawningpool::store::load()?;
let what = match entity {
DefineEntity::Provider {
name,
api,
base_url,
api_key_env,
constrained_decoding,
} => {
let def = ProviderDef {
name: name.clone(),
api: api.parse::<Api>()?,
base_url,
api_key_env,
constrained_decoding,
};
registry.providers.insert(name.clone(), def);
format!("provider {name}")
}
DefineEntity::Model {
id,
provider,
name,
max_tokens,
context_window,
} => {
let def = ModelDef {
id: id.clone(),
name: name.unwrap_or_else(|| id.clone()),
provider,
max_tokens,
context_window,
};
check_model_refs(®istry, &def)?;
registry.models.insert(id.clone(), def);
format!("model {id}")
}
DefineEntity::Specialist {
name,
provider,
model,
system_prompt,
tools,
constraint,
reasoning,
stream,
} => {
let def = Specialist {
name: name.clone(),
provider,
model,
system_prompt,
tools: parse_list(tools),
constraint,
reasoning: parse_reasoning(&reasoning)?,
stream,
};
def.validate()?;
check_specialist_refs(®istry, &def, &spawningpool::store::tools_dir())?;
registry.specialists.insert(name.clone(), def);
format!("specialist {name}")
}
DefineEntity::Tool { .. } => unreachable!("handled by define_tool before load"),
};
spawningpool::store::save(®istry)?;
println!("defined {what}");
Ok(())
}
fn define_tool(name: &str, script: &Path) -> Result<(), String> {
if !spawningpool::tools::is_valid_tool_name(name) {
return Err(format!(
"'{name}' isn't a valid tool name; use letters, digits, '_' or '-' (max 64 chars)."
));
}
let script = resolve_script(script)?;
let summary = spawningpool::summarize(&script).map_err(|e| e.to_string())?;
if summary.desc.is_none() {
eprintln!(
"warning: tool '{name}' has no '# desc:' header, so the model will see an empty \
description.\n Add a line like '# desc: <what it does>' to {}.",
script.display()
);
}
let dir = spawningpool::store::tools_dir();
std::fs::create_dir_all(&dir)
.map_err(|e| format!("failed to create {}: {e}", dir.display()))?;
spawningpool::tools::remove(&dir, name)?;
let link = dir.join(name);
std::os::unix::fs::symlink(&script, &link).map_err(|e| {
format!(
"failed to link {} -> {}: {e}",
link.display(),
script.display()
)
})?;
println!("defined tool {name}");
Ok(())
}
pub(crate) fn parse_list(raw: Option<String>) -> Vec<String> {
raw.into_iter()
.flat_map(|s| {
s.split(',')
.map(str::trim)
.filter(|p| !p.is_empty())
.map(String::from)
.collect::<Vec<_>>()
})
.collect()
}
pub(crate) fn parse_reasoning(raw: &str) -> Result<Reasoning, String> {
raw.parse()
}
pub(crate) fn check_model_refs(registry: &Registry, model: &ModelDef) -> Result<(), String> {
if registry.missing_model_ref(model).is_some() {
return Err([
format!(
"model '{}' references provider '{}', which isn't defined.",
model.id, model.provider
),
String::new(),
format!(" Defined providers: {}", available_names(®istry.providers)),
String::new(),
" Define it first:".to_string(),
format!(
" spawningpool define provider {} --api <anthropic-messages|openai-completions> --base-url <url>",
model.provider
),
]
.join("\n"));
}
Ok(())
}
pub(crate) fn check_specialist_refs(
registry: &Registry,
specialist: &Specialist,
tools_dir: &Path,
) -> Result<(), String> {
let Some(missing) = registry.missing_specialist_ref(specialist, |name| {
spawningpool::tools::exists(tools_dir, name)
}) else {
return Ok(());
};
let message = match missing.kind {
EntityKind::Provider => [
format!(
"specialist '{}' references provider '{}', which isn't defined.",
specialist.name, missing.name
),
String::new(),
format!(" Defined providers: {}", available_names(®istry.providers)),
String::new(),
" Define it first:".to_string(),
format!(
" spawningpool define provider {} --api <anthropic-messages|openai-completions> --base-url <url>",
missing.name
),
String::new(),
" ...or point the specialist at one that exists with --provider.".to_string(),
]
.join("\n"),
EntityKind::Model => [
format!(
"specialist '{}' references model '{}', which isn't defined.",
specialist.name, missing.name
),
String::new(),
format!(" Defined models: {}", available_names(®istry.models)),
String::new(),
" Define it first:".to_string(),
format!(
" spawningpool define model {} --provider {} --max-tokens <n> --context-window <n>",
missing.name, specialist.provider
),
String::new(),
" ...or point the specialist at one that exists with --model.".to_string(),
]
.join("\n"),
EntityKind::Tool => [
format!(
"specialist '{}' references tool '{}', which isn't defined.",
specialist.name, missing.name
),
String::new(),
format!(" Defined tools: {}", defined_tools(tools_dir)),
String::new(),
" Back it with a script:".to_string(),
format!(" spawningpool define tool {} --script <path>", missing.name),
]
.join("\n"),
EntityKind::Specialist => {
unreachable!("a specialist does not reference other specialists")
}
};
Err(message)
}
pub(crate) fn resolve_script(script: &Path) -> Result<PathBuf, String> {
spawningpool::prepare_script(script).map_err(|e| match e {
ScriptError::Unreadable { path, source } => format!(
"tool script {} can't be read: {source}\n Check the path is right and the file exists.",
path.display()
),
ScriptError::NotExecutable { path } => [
format!("tool script {} isn't executable.", path.display()),
String::new(),
" Make it runnable:".to_string(),
format!(" chmod +x {}", path.display()),
String::new(),
" (It also needs a shebang line, e.g. #!/bin/sh.)".to_string(),
]
.join("\n"),
})
}