#![cfg_attr(
not(test),
deny(clippy::unwrap_used, clippy::panic, clippy::indexing_slicing)
)]
pub mod bake;
pub mod cli;
pub mod config;
pub mod context;
pub mod doctor;
pub mod error;
pub mod launch;
pub mod prompt;
mod session;
use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use bake::{BakeMode, bake_repo};
use cli::{Cli, Command, parse_external_launch, parse_launch};
use config::{LoadedConfig, load_config};
use doctor::Doctor;
use error::ClankerError;
use launch::{
LaunchInputs, LaunchPlan, LaunchPlanOutput, build_command_plan, build_symlink_plan,
execute_plan,
};
use serde::Serialize;
use session::CurrentSession;
use tftio_cli_common::{
AgentCapability, AgentSurfaceSpec, CommandSelector, FatalCliError, FlagSelector, JsonOutput,
LicenseType, ProcessEnv, StandardCommand, ToolSpec, map_standard_command, render_response,
run_cli_from, run_doctor_with_output, workspace_tool,
};
const LIST_COMMAND: CommandSelector = CommandSelector::new(&["list"]);
const CURRENT_COMMAND: CommandSelector = CommandSelector::new(&["current"]);
const CURRENT_JSON_FLAG: FlagSelector = FlagSelector::new(&[], "json");
const LIST_CAPABILITY: AgentCapability =
AgentCapability::minimal("list-configuration", &[LIST_COMMAND], &[])
.with_output("configured harness, model, and domain names")
.with_constraints("reads runtime configuration only")
.with_when_to_use("the user wants to inspect available clanker launch choices")
.with_when_not_to_use("the user wants to launch a harness or modify configuration");
const CURRENT_CAPABILITY: AgentCapability = AgentCapability::new(
"current-session",
"Determine how clanker launched the current harness session",
&[CURRENT_COMMAND],
&[CURRENT_JSON_FLAG],
)
.with_examples(&["clanker --json current"])
.with_output(
"launch-time active state, invocation, harness, context, context source, ordered domains, model, and prompt family",
)
.with_constraints(
"run clanker --json current and report its inherited launch markers; active=false means this process was not launched by marker-capable clanker; never recompute the answer from the current directory or configuration",
)
.with_when_to_use(
"the user asks how this agent session was launched, which clanker context or domains are active, or whether clanker launched it",
)
.with_when_not_to_use(
"the user wants to predict a future launch or inspect available clanker configuration",
);
const AGENT_SURFACE: AgentSurfaceSpec =
AgentSurfaceSpec::new(&[LIST_CAPABILITY, CURRENT_CAPABILITY]);
pub const TOOL_SPEC: ToolSpec = workspace_tool(
"clanker",
"clanker",
env!("CARGO_PKG_VERSION"),
LicenseType::MIT,
true,
true,
)
.with_agent_surface(&AGENT_SURFACE);
#[derive(Debug, Clone)]
pub struct RuntimeContext {
pub home: Option<PathBuf>,
pub current_dir: PathBuf,
pub hostname: Result<String, String>,
pub config_environment: Option<PathBuf>,
pub context_override: Option<String>,
pub context_environment: Option<String>,
pub domain_override: Option<String>,
pub domain_environment: Option<String>,
pub model_environment: Option<String>,
pub inherited_path: Option<OsString>,
pub inherited_environment: std::collections::BTreeMap<OsString, OsString>,
}
impl RuntimeContext {
pub fn config_path(&self, explicit: Option<&Path>) -> Result<PathBuf, ClankerError> {
if let Some(path) = explicit {
return Ok(self.resolve_relative(path));
}
if let Some(path) = &self.config_environment {
return Ok(self.resolve_relative(path));
}
self.home
.as_ref()
.map(|home| home.join(".config/clanker/config.toml"))
.ok_or(ClankerError::HomeNotSet)
}
fn resolve_relative(&self, path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
self.current_dir.join(path)
}
}
fn launch_inputs(&self) -> Result<LaunchInputs<'_>, ClankerError> {
let home = self.home.as_deref().ok_or(ClankerError::HomeNotSet)?;
let hostname = self
.hostname
.as_deref()
.map_err(|error| ClankerError::Hostname(error.clone()))?;
Ok(LaunchInputs {
home,
current_dir: &self.current_dir,
hostname,
context_override: self.context_override.as_deref(),
context_environment: self.context_environment.as_deref(),
domain_override: self.domain_override.as_deref(),
domain_environment: self.domain_environment.as_deref(),
model_environment: self.model_environment.as_deref(),
inherited_path: self.inherited_path.as_deref(),
inherited_environment: &self.inherited_environment,
})
}
}
#[must_use]
pub fn command_exit_code<I>(argv: I, process_env: &ProcessEnv, runtime: &RuntimeContext) -> i32
where
I: IntoIterator,
I::Item: Into<OsString> + Clone,
{
let doctor_path = runtime.config_path(None).ok();
let doctor = Doctor::new(
doctor_path,
runtime.current_dir.clone(),
runtime.home.clone(),
);
run_cli_from::<Cli, _, Doctor, _, _>(
&TOOL_SPEC,
process_env,
argv,
&doctor,
metadata_command,
|cli| run_command(cli, runtime),
)
}
pub fn run_symlink_mode(
invocation: &str,
args: Vec<OsString>,
runtime: &RuntimeContext,
) -> Result<i32, ClankerError> {
let config_path = runtime.config_path(None)?;
let loaded = load_config(&config_path)?;
let plan = build_symlink_plan(invocation, args, &loaded.config, runtime.launch_inputs()?)?;
execute_plan(&plan).map_err(Into::into)
}
fn metadata_command(cli: &Cli) -> Option<StandardCommand> {
match &cli.command {
Command::Meta { command } => Some(map_standard_command(
command,
JsonOutput::from_flag(cli.json),
)),
Command::Doctor
| Command::List
| Command::Current
| Command::Bake { .. }
| Command::Env { .. }
| Command::Harness(_) => None,
}
}
fn run_command(cli: Cli, runtime: &RuntimeContext) -> Result<i32, FatalCliError> {
let output = JsonOutput::from_flag(cli.json);
let command_label = match &cli.command {
Command::Meta { .. } => "meta",
Command::Doctor => "doctor",
Command::List => "list",
Command::Current => "current",
Command::Bake { .. } => "bake",
Command::Env { .. } => "env",
Command::Harness(_) => "launch",
};
run_command_inner(cli, runtime, output)
.map_err(|error| FatalCliError::new(command_label, output, error.to_string()))
}
fn run_command_inner(
cli: Cli,
runtime: &RuntimeContext,
output: JsonOutput,
) -> Result<i32, ClankerError> {
match cli.command {
Command::Meta { .. } => Err(ClankerError::UnroutedMetadataCommand),
Command::Doctor => {
let config_path = runtime.config_path(cli.config.as_deref()).ok();
Ok(run_doctor_with_output(
&Doctor::new(
config_path,
runtime.current_dir.clone(),
runtime.home.clone(),
),
output,
))
}
Command::List => {
let loaded = load_runtime_config(runtime, cli.config.as_deref())?;
emit_list(&loaded, output)?;
Ok(0)
}
Command::Current => {
emit_current(
&CurrentSession::from_environment(&runtime.inherited_environment)?,
output,
)?;
Ok(0)
}
Command::Bake {
dry_run,
check,
force,
} => run_bake(
cli.config.as_deref(),
runtime,
output,
dry_run,
check,
force,
),
Command::Env { harness, arguments } => {
run_environment(harness, arguments, cli.config.as_deref(), runtime, output)
}
Command::Harness(values) => run_harness(values, cli.config.as_deref(), runtime, output),
}
}
fn run_bake(
explicit_config: Option<&Path>,
runtime: &RuntimeContext,
output: JsonOutput,
dry_run: bool,
check: bool,
force: bool,
) -> Result<i32, ClankerError> {
let loaded = load_runtime_config(runtime, explicit_config)?;
let home = runtime.home.as_deref().ok_or(ClankerError::HomeNotSet)?;
let mode = match (dry_run, check, force) {
(true, _, _) => BakeMode::DryRun,
(_, true, _) => BakeMode::Check,
(_, _, true) => BakeMode::Force,
(false, false, false) => BakeMode::Write,
};
let report = bake_repo(&runtime.current_dir, &loaded.config, home, mode)?;
emit_bake_report(&report, output)?;
Ok(0)
}
fn load_runtime_config(
runtime: &RuntimeContext,
explicit: Option<&Path>,
) -> Result<LoadedConfig, ClankerError> {
let path = runtime.config_path(explicit)?;
load_config(&path).map_err(Into::into)
}
fn run_harness(
values: Vec<OsString>,
explicit_config: Option<&Path>,
runtime: &RuntimeContext,
output: JsonOutput,
) -> Result<i32, ClankerError> {
let request = parse_external_launch(values)?;
let loaded = load_runtime_config(runtime, explicit_config)?;
let invocation = format!("clanker {}", request.harness);
let plan = build_command_plan(
&invocation,
&request,
&loaded.config,
runtime.launch_inputs()?,
!request.dry_run,
)?;
if request.dry_run {
emit_dry_run(&plan, output)?;
Ok(0)
} else {
execute_plan(&plan).map_err(Into::into)
}
}
fn run_environment(
harness: OsString,
arguments: Vec<OsString>,
explicit_config: Option<&Path>,
runtime: &RuntimeContext,
output: JsonOutput,
) -> Result<i32, ClankerError> {
let mut request = parse_launch(harness, arguments)?;
request.no_prompt = true;
request.dry_run = true;
request.sandbox = false;
let loaded = load_runtime_config(runtime, explicit_config)?;
let invocation = format!("clanker env {}", request.harness);
let plan = build_command_plan(
&invocation,
&request,
&loaded.config,
runtime.launch_inputs()?,
false,
)?;
emit_environment(&plan, output)?;
Ok(0)
}
#[derive(Debug, Serialize)]
struct ListOutput {
harnesses: Vec<String>,
models: Vec<String>,
domains: Vec<String>,
}
fn emit_list(loaded: &LoadedConfig, output: JsonOutput) -> Result<(), ClankerError> {
let listing = ListOutput {
harnesses: loaded
.config
.harness
.keys()
.map(ToString::to_string)
.collect(),
models: loaded
.config
.model
.keys()
.map(ToString::to_string)
.collect(),
domains: loaded
.config
.domain
.keys()
.map(ToString::to_string)
.collect(),
};
if output.is_json() {
let data = serde_json::to_value(&listing)?;
println!(
"{}",
render_response("list", JsonOutput::Json, data, String::new())
);
} else {
println!("Harnesses:");
for name in &listing.harnesses {
println!(" {name}");
}
println!("Models:");
for name in &listing.models {
println!(" {name}");
}
println!("Domains:");
for name in &listing.domains {
println!(" {name}");
}
}
Ok(())
}
fn emit_current(session: &CurrentSession, output: JsonOutput) -> Result<(), ClankerError> {
if output.is_json() {
let data = serde_json::to_value(session)?;
println!(
"{}",
render_response("current", JsonOutput::Json, data, String::new())
);
return Ok(());
}
println!("active={}", session.active);
for (name, value) in [
("version", session.version.as_deref()),
("invocation", session.invocation.as_deref()),
("harness", session.harness.as_deref()),
("context", session.context.as_deref()),
("context_source", session.context_source.as_deref()),
] {
if let Some(value) = value {
println!("{name}={value}");
}
}
if let Some(domains) = &session.domains {
for domain in domains {
println!("domain={domain}");
}
}
for (name, value) in [
("model", session.model.as_deref()),
("family", session.family.as_deref()),
] {
if let Some(value) = value {
println!("{name}={value}");
}
}
Ok(())
}
fn emit_bake_report(report: &bake::BakeReport, output: JsonOutput) -> Result<(), ClankerError> {
if output.is_json() {
let data = serde_json::to_value(report)?;
println!(
"{}",
render_response("bake", JsonOutput::Json, data, String::new())
);
return Ok(());
}
println!("domain={}", report.domain);
for profile in &report.profiles {
println!("profile={profile}");
}
for skill in &report.skills {
println!("skill={skill}");
}
for path in &report.changed_files {
println!("changed={path}");
}
for path in &report.unchanged_files {
println!("unchanged={path}");
}
Ok(())
}
fn emit_dry_run(plan: &LaunchPlan, output: JsonOutput) -> Result<(), ClankerError> {
if output.is_json() {
let data = serde_json::to_value(LaunchPlanOutput::from(plan))?;
println!(
"{}",
render_response("launch", JsonOutput::Json, data, String::new())
);
} else {
println!("invocation={}", plan.invocation);
println!("harness={}", plan.harness);
println!("context={}", plan.context.name);
for domain in &plan.domains {
println!("domain={domain}");
}
if let Some(model) = &plan.model {
println!("model={model}");
}
if let Some(family) = &plan.family {
println!("family={family}");
}
println!("executable={}", plan.executable.to_string_lossy());
for argument in &plan.args {
println!("arg={}", argument.to_string_lossy());
}
for (key, value) in &plan.environment {
let displayed = if plan.secret_environment.contains(key) {
"<redacted>".into()
} else {
value.to_string_lossy()
};
println!("env.{}={displayed}", key.to_string_lossy());
}
if let Some(prompt_file) = &plan.prompt_file {
println!("prompt_file={}", prompt_file.display());
}
if let Some(prompt) = &plan.prompt {
println!("prompt<<CLANKER_PROMPT");
print!("{prompt}");
if !prompt.ends_with('\n') {
println!();
}
println!("CLANKER_PROMPT");
}
}
Ok(())
}
fn emit_environment(plan: &LaunchPlan, output: JsonOutput) -> Result<(), ClankerError> {
if output.is_json() {
let data = serde_json::to_value(LaunchPlanOutput::from(plan))?;
println!(
"{}",
render_response("env", JsonOutput::Json, data, String::new())
);
return Ok(());
}
for (key, value) in &plan.environment {
let key = key.to_str().ok_or_else(|| {
ClankerError::NonUtf8EnvironmentOutput(key.to_string_lossy().into_owned())
})?;
let value = value
.to_str()
.ok_or_else(|| ClankerError::NonUtf8EnvironmentOutput(key.to_string()))?;
println!("export {key}={}", shell_quote(value));
}
Ok(())
}
fn shell_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
#[must_use]
pub fn invocation_name(argv0: &OsStr) -> String {
Path::new(argv0)
.file_name()
.unwrap_or(argv0)
.to_string_lossy()
.into_owned()
}