use crate::capabilities::narration::stable_labeled;
use crate::config_service::ConfigService;
use crate::runtime::{ProviderChoice, SUPPORTED_PROVIDERS, resolve_for_settings};
use crate::settings::{ApprovalMode, SettingsStore};
use crate::tools::{BashTool, Workspace};
use async_trait::async_trait;
use chrono::Local;
use everruns_core::capabilities::{Capability, CapabilityStatus, SystemPromptContext};
use everruns_core::command::{
CommandArg, CommandDescriptor, CommandExecutionContext, CommandResult, CommandSource,
ExecuteCommandRequest,
};
use everruns_core::tool_narration::{ToolNarrationPhase, arg_str, truncate};
use everruns_core::tool_types::ToolCall;
use everruns_core::tools::{Tool, ToolExecutionResult};
use everruns_runtime::RuntimeProviderStore;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Arc, RwLock};
pub(crate) const ENVIRONMENT_CONTEXT_CAPABILITY_ID: &str = "code_environment_context";
pub(crate) struct CodingCliEnvironmentCapability {
repo_root: PathBuf,
active_root: Arc<RwLock<PathBuf>>,
}
impl CodingCliEnvironmentCapability {
pub(crate) fn new(repo_root: PathBuf, active_root: Arc<RwLock<PathBuf>>) -> Self {
Self {
repo_root,
active_root,
}
}
fn collect(&self) -> EnvironmentContext {
let active_path = self
.active_root
.read()
.map(|guard| guard.clone())
.unwrap_or_else(|_| self.repo_root.clone());
EnvironmentContext {
cwd: active_path.display().to_string(),
shell: shell_name(),
current_date: Local::now().format("%Y-%m-%d").to_string(),
timezone: local_timezone(),
git_repo: git_output(&self.repo_root, &["config", "--get", "remote.origin.url"])
.map(|remote| redact_git_remote_secret(&remote))
.or_else(|| {
git_output(&self.repo_root, &["rev-parse", "--show-toplevel"])
.map(|_| self.repo_root.display().to_string())
}),
git_user: git_output(&self.repo_root, &["config", "--get", "user.name"]),
git_email: git_output(&self.repo_root, &["config", "--get", "user.email"]),
repo_root: self.repo_root.display().to_string(),
git_current_branch: git_current_branch(&active_path),
worktree_path: if active_path != self.repo_root {
Some(active_path.display().to_string())
} else {
None
},
}
}
}
#[async_trait]
impl Capability for CodingCliEnvironmentCapability {
fn id(&self) -> &str {
ENVIRONMENT_CONTEXT_CAPABILITY_ID
}
fn name(&self) -> &str {
"Coding CLI Environment Context"
}
fn description(&self) -> &str {
"Adds current workspace, shell, date, timezone, and Git context to the prompt."
}
fn status(&self) -> CapabilityStatus {
CapabilityStatus::Available
}
fn category(&self) -> Option<&str> {
Some("Examples")
}
async fn system_prompt_contribution(&self, _ctx: &SystemPromptContext) -> Option<String> {
Some(render_environment_context(&self.collect()))
}
fn system_prompt_preview(&self) -> Option<String> {
Some(
"\
<environment_context>
<cwd>/path/to/workspace</cwd>
<shell>zsh</shell>
<current_date>YYYY-MM-DD</current_date>
<timezone>Region/City</timezone>
<git_repo>git remote or workspace root</git_repo>
<git_user>Git user name</git_user>
<git_email>Git user email</git_email>
<git_current_branch>branch or short commit</git_current_branch>
</environment_context>"
.to_string(),
)
}
}
#[derive(Debug)]
struct EnvironmentContext {
cwd: String,
shell: String,
current_date: String,
timezone: String,
git_repo: Option<String>,
git_user: Option<String>,
git_email: Option<String>,
repo_root: String,
git_current_branch: Option<String>,
worktree_path: Option<String>,
}
fn render_environment_context(context: &EnvironmentContext) -> String {
let mut out = String::new();
out.push_str("<environment_context>\n");
push_xml_field(&mut out, "cwd", &context.cwd);
push_xml_field(&mut out, "repo_root", &context.repo_root);
if let Some(path) = &context.worktree_path {
out.push_str(" <git_worktree>\n");
push_xml_field(&mut out, "path", path);
if let Some(branch) = &context.git_current_branch {
push_xml_field(&mut out, "branch", branch);
}
out.push_str(" </git_worktree>\n");
}
push_xml_field(&mut out, "shell", &context.shell);
push_xml_field(&mut out, "current_date", &context.current_date);
push_xml_field(&mut out, "timezone", &context.timezone);
if let Some(value) = &context.git_repo {
push_xml_field(&mut out, "git_repo", value);
}
if let Some(value) = &context.git_user {
push_xml_field(&mut out, "git_user", value);
}
if let Some(value) = &context.git_email {
push_xml_field(&mut out, "git_email", value);
}
if context.worktree_path.is_none()
&& let Some(value) = &context.git_current_branch
{
push_xml_field(&mut out, "git_current_branch", value);
}
out.push_str("</environment_context>");
out
}
fn push_xml_field(out: &mut String, name: &str, value: &str) {
out.push_str(" <");
out.push_str(name);
out.push('>');
out.push_str(&xml_escape(value));
out.push_str("</");
out.push_str(name);
out.push_str(">\n");
}
fn xml_escape(value: &str) -> String {
value
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
fn redact_git_remote_secret(remote: &str) -> String {
let scheme_end = remote.find("://");
let Some(scheme_end) = scheme_end else {
return remote.to_string();
};
let scheme = &remote[..scheme_end];
if !scheme.eq_ignore_ascii_case("http") && !scheme.eq_ignore_ascii_case("https") {
return remote.to_string();
}
let authority_start = scheme_end + 3;
let authority = &remote[authority_start..];
let authority_end_offset = authority.find(['/', '?', '#']).unwrap_or(authority.len());
let authority_end = authority_start + authority_end_offset;
let userinfo = &remote[authority_start..authority_end];
if let Some(at_offset) = userinfo.find('@') {
let host_port = &userinfo[at_offset + 1..];
return format!("{}{}", &remote[..authority_start], host_port) + &remote[authority_end..];
}
remote.to_string()
}
fn shell_name() -> String {
std::env::var("SHELL")
.ok()
.and_then(|shell| {
Path::new(&shell)
.file_name()
.map(|name| name.to_string_lossy().to_string())
})
.filter(|shell| !shell.is_empty())
.unwrap_or_else(|| "sh".to_string())
}
fn local_timezone() -> String {
if let Ok(tz) = std::env::var("TZ")
&& !tz.trim().is_empty()
{
return tz.trim().to_string();
}
if let Ok(target) = std::fs::read_link("/etc/localtime") {
let target = target.to_string_lossy();
if let Some((_, timezone)) = target.split_once("/zoneinfo/") {
return timezone.to_string();
}
}
"local".to_string()
}
fn git_current_branch(workspace_root: &Path) -> Option<String> {
git_output(workspace_root, &["branch", "--show-current"])
.filter(|branch| !branch.is_empty())
.or_else(|| git_output(workspace_root, &["rev-parse", "--short", "HEAD"]))
}
fn git_output(workspace_root: &Path, args: &[&str]) -> Option<String> {
let output = Command::new("git")
.arg("-C")
.arg(workspace_root)
.args(args)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!text.is_empty()).then_some(text)
}
pub(crate) const CODING_BASH_CAPABILITY_ID: &str = "yolop_bash";
pub(crate) struct CodingBashCapability {
pub(crate) workspace: Workspace,
pub(crate) expose_command: bool,
}
#[async_trait]
impl Capability for CodingBashCapability {
fn id(&self) -> &str {
CODING_BASH_CAPABILITY_ID
}
fn name(&self) -> &str {
"Coding CLI Bash"
}
fn description(&self) -> &str {
"Shell command execution rooted at the host workspace."
}
fn status(&self) -> CapabilityStatus {
CapabilityStatus::Available
}
fn category(&self) -> Option<&str> {
Some("Examples")
}
fn system_prompt_addition(&self) -> Option<&str> {
None
}
fn tools(&self) -> Vec<Box<dyn Tool>> {
vec![Box::new(BashTool::new(self.workspace.clone()))]
}
fn commands(&self) -> Vec<CommandDescriptor> {
if !self.expose_command {
return Vec::new();
}
vec![CommandDescriptor {
name: "shell".to_string(),
description: "run a shell command".to_string(),
source: CommandSource::System,
args: vec![CommandArg {
name: "command".to_string(),
description: "shell command".to_string(),
required: true,
suggestions: Vec::new(),
}],
}]
}
async fn execute_command(
&self,
request: &ExecuteCommandRequest,
_ctx: &CommandExecutionContext,
) -> everruns_core::Result<CommandResult> {
if request.name != "shell" {
return Err(everruns_core::AgentLoopError::config(format!(
"{} cannot execute /{}",
self.id(),
request.name
)));
}
let command = request
.arguments
.as_deref()
.map(str::trim)
.filter(|command| !command.is_empty())
.ok_or_else(|| everruns_core::AgentLoopError::config("/shell requires: command"))?;
let result = BashTool::new(self.workspace.clone())
.execute(json!({ "command": command, "output": "normal" }))
.await;
Ok(shell_command_result(result))
}
}
fn shell_command_result(result: ToolExecutionResult) -> CommandResult {
match result {
ToolExecutionResult::Success(value)
| ToolExecutionResult::SuccessWithImages { result: value, .. } => {
let success = value["success"].as_bool().unwrap_or(true);
CommandResult {
success,
message: format_shell_output(&value),
error_code: None,
error_fields: None,
}
}
ToolExecutionResult::ToolError(message) => CommandResult {
success: false,
message,
error_code: None,
error_fields: None,
},
ToolExecutionResult::InternalError(_) => CommandResult {
success: false,
message: "shell command failed internally".to_string(),
error_code: None,
error_fields: None,
},
ToolExecutionResult::ConnectionRequired { provider } => CommandResult {
success: false,
message: format!("shell command requires connection: {provider}"),
error_code: None,
error_fields: None,
},
}
}
fn format_shell_output(value: &serde_json::Value) -> String {
let mut parts = Vec::new();
if let Some(stdout) = value["stdout"]
.as_str()
.map(str::trim_end)
.filter(|text| !text.is_empty())
{
parts.push(stdout.to_string());
}
if let Some(stderr) = value["stderr"]
.as_str()
.map(str::trim_end)
.filter(|text| !text.is_empty())
{
parts.push(stderr.to_string());
}
if parts.is_empty() {
let exit_code = value["exit_code"].as_i64().unwrap_or(0);
format!("exit {exit_code}")
} else {
parts.join("\n")
}
}
pub(crate) const SETUP_CAPABILITY_ID: &str = "yolop_setup";
const TOKEN_PROVIDERS: &[&str] = &[
"openai",
"anthropic",
"google",
"openrouter",
"ollama",
"custom",
];
const BASE_URL_PROVIDERS: &[&str] = &["custom"];
pub(crate) struct SetupCapability {
pub(crate) provider: Arc<RwLock<ProviderChoice>>,
pub(crate) provider_store: Arc<dyn RuntimeProviderStore>,
pub(crate) config: Arc<dyn ConfigService>,
pub(crate) settings: Arc<SettingsStore>,
}
impl SetupCapability {
fn controller(&self) -> SetupController {
SetupController {
provider: self.provider.clone(),
provider_store: self.provider_store.clone(),
config: self.config.clone(),
settings: self.settings.clone(),
}
}
}
#[derive(Clone)]
pub(crate) struct SetupController {
provider: Arc<RwLock<ProviderChoice>>,
provider_store: Arc<dyn RuntimeProviderStore>,
config: Arc<dyn ConfigService>,
settings: Arc<SettingsStore>,
}
#[async_trait]
impl Capability for SetupCapability {
fn id(&self) -> &str {
SETUP_CAPABILITY_ID
}
fn name(&self) -> &str {
"Coding CLI Setup"
}
fn description(&self) -> &str {
"Configure provider, API key, and model."
}
fn status(&self) -> CapabilityStatus {
CapabilityStatus::Available
}
fn category(&self) -> Option<&str> {
Some("Examples")
}
fn system_prompt_addition(&self) -> Option<&str> {
Some(SETUP_TOOLS_PROMPT)
}
fn commands(&self) -> Vec<CommandDescriptor> {
vec![CommandDescriptor {
name: "setup".to_string(),
description: "Configure provider, API key, and model.".to_string(),
source: CommandSource::System,
args: vec![setup_command_arg()],
}]
}
fn tools(&self) -> Vec<Box<dyn Tool>> {
vec![
Box::new(SetReasoningEffortTool {
controller: self.controller(),
}),
Box::new(SetModelTool {
controller: self.controller(),
}),
Box::new(SetProviderTool {
controller: self.controller(),
}),
]
}
async fn execute_command(
&self,
request: &ExecuteCommandRequest,
_ctx: &CommandExecutionContext,
) -> everruns_core::Result<CommandResult> {
if request.name != "setup" {
return Err(everruns_core::AgentLoopError::config(format!(
"{} cannot execute /{}",
self.id(),
request.name
)));
}
let controller = self.controller();
let raw = request.arguments.as_deref().unwrap_or("").trim();
if raw.is_empty() || raw == "status" {
return Ok(controller.status_result());
}
let mut parts = raw.splitn(2, char::is_whitespace);
let action = parts.next().unwrap_or_default();
let rest = parts.next().unwrap_or_default().trim();
match action {
"provider" => controller.change_provider(rest).await,
"model" => controller.change_model(rest).await,
"effort" => controller.change_effort(rest).await,
"token" => controller.change_token(rest),
"url" => controller.change_base_url(rest),
"attribution" => controller.change_attribution(rest),
"approval" => controller.change_approval(rest),
_ => Ok(failed_result(
"usage: /setup — run guided setup; internal forms: status, provider <name> [model], token <provider> <value|clear>, url <provider> <base-url|clear>, model <id> [reasoning-effort], effort <reasoning-effort>, attribution <on|off>, approval <protective|normal|off>".to_string(),
)),
}
}
}
const SETUP_TOOLS_PROMPT: &str = "<capability id=\"yolop_setup\">\n\
You can reconfigure the live session yourself when it helps the task:\n\
`set_reasoning_effort` changes the model's reasoning effort (escalate before \
a hard step, deescalate for cheap follow-ups), `set_model` switches the model, \
and `set_provider` switches the provider. All three apply on the next turn of \
this session — no restart. Effort options are model-specific; if the level is \
unknown, `set_reasoning_effort` returns the accepted values, so retry with one \
of those. Prefer the smallest change that fits; do not thrash the model \
or provider mid-task.\n\
</capability>";
fn setup_command_arg() -> CommandArg {
let mut suggestions = vec![
"status".to_string(),
"model".to_string(),
"attribution on".to_string(),
"attribution off".to_string(),
"approval protective".to_string(),
"approval normal".to_string(),
"approval off".to_string(),
];
suggestions.extend(TOKEN_PROVIDERS.iter().flat_map(|provider| {
[
format!("token {provider} "),
format!("token {provider} clear"),
]
}));
suggestions.extend(
BASE_URL_PROVIDERS
.iter()
.flat_map(|provider| [format!("url {provider} "), format!("url {provider} clear")]),
);
suggestions.extend(
SUPPORTED_PROVIDERS
.iter()
.map(|provider| format!("provider {provider}")),
);
suggestions.extend(
SUPPORTED_PROVIDERS
.iter()
.flat_map(|provider| ProviderChoice::model_suggestions_for_provider(provider))
.copied()
.map(|model| format!("model {model}")),
);
CommandArg {
name: "action".to_string(),
description: "status | provider <name> [model] | token <provider> <value|clear> | url <provider> <base-url|clear> | model <id> | effort <level> | attribution <on|off> | approval <protective|normal|off>".to_string(),
required: false,
suggestions,
}
}
impl SetupController {
fn status_result(&self) -> CommandResult {
let current = self
.provider
.read()
.expect("provider lock poisoned")
.clone();
let snapshot = self.config.snapshot();
let saved = snapshot
.default_provider
.clone()
.unwrap_or_else(|| "<unset>".to_string());
let stored: Vec<&str> = snapshot.tokens.keys().map(String::as_str).collect();
let stored_label = if stored.is_empty() {
if snapshot.has_codex_auth() {
"codex_auth".to_string()
} else {
"none".to_string()
}
} else {
let mut stored = stored;
if snapshot.has_codex_auth() {
stored.push("codex_auth");
}
stored.join(", ")
};
CommandResult {
success: true,
message: format!(
"setup: provider={} model={} saved={saved} attribution={} approval={} stored tokens={stored_label} env keys present={}",
current.provider_name(),
current.label(),
on_off(snapshot.attribution_enabled()),
snapshot.approval_mode(),
env_credential_present()
),
error_code: None,
error_fields: None,
}
}
async fn change_provider(&self, raw: &str) -> everruns_core::Result<CommandResult> {
let mut parts = raw.splitn(2, char::is_whitespace);
let name = parts.next().unwrap_or_default();
let model_spec = parts.next().unwrap_or_default().trim();
if name.is_empty() {
return Ok(failed_result(format!(
"setup provider failed: choose one of {}",
SUPPORTED_PROVIDERS.join(", ")
)));
}
let snapshot = self.config.snapshot();
let resolved = match resolve_for_settings(name, &snapshot) {
Ok(resolved) => resolved,
Err(err) => return Ok(failed_result(format!("setup provider failed: {err}"))),
};
let mut notes = resolved.notes;
let next = if model_spec.is_empty() {
resolved.choice
} else {
match resolved.choice.resolve_model_spec(model_spec) {
Ok(n) => n,
Err(err) => return Ok(failed_result(format!("setup provider failed: {err}"))),
}
};
let (next, reconcile_notes) =
super::model_discovery::reconcile_provider_with_catalog(next, &snapshot).await;
notes.extend(reconcile_notes);
if next.model_id().trim().is_empty() {
return Ok(failed_result(format!(
"setup provider failed: no model configured for {name}; pick one with /setup"
)));
}
let mw = match next.model_with_provider(&snapshot) {
Ok(m) => m,
Err(err) => return Ok(failed_result(format!("setup provider failed: {err}"))),
};
if let Err(err) = self.provider_store.set_default_model(mw).await {
return Ok(failed_result(format!("setup provider failed: {err}")));
}
let provider_name = next.provider_name().to_string();
let label = next.label();
let model_persist = if model_spec.is_empty() {
Ok(())
} else {
self.settings
.set_model(provider_name.clone(), next.model_spec())
};
*self.provider.write().expect("provider lock poisoned") = next;
let persist_note = match model_persist.and_then(|()| {
self.settings
.set_default_provider(Some(provider_name.clone()))
}) {
Ok(()) => format!("saved to {}", self.settings.path().display()),
Err(err) => format!("warning: settings not saved: {err}"),
};
Ok(CommandResult {
success: true,
message: format!(
"setup provider changed: {label} ({persist_note}){}",
if notes.is_empty() {
String::new()
} else {
format!("; {}", notes.join("; "))
}
),
error_code: None,
error_fields: None,
})
}
async fn change_model(&self, raw: &str) -> everruns_core::Result<CommandResult> {
if raw.is_empty() {
let current = self
.provider
.read()
.expect("provider lock poisoned")
.clone();
let label = current.label();
return Ok(CommandResult {
success: true,
message: format!(
"setup model: {label}; {}",
self.model_suggestions_message(¤t).await
),
error_code: None,
error_fields: None,
});
}
let current = self
.provider
.read()
.expect("provider lock poisoned")
.clone();
let next = match current.resolve_model_spec(raw) {
Ok(n) => n,
Err(err) => {
return Ok(failed_result(format!("setup model failed: {err}")));
}
};
let mw = match next.model_with_provider(&self.config.snapshot()) {
Ok(m) => m,
Err(err) => {
return Ok(failed_result(format!("setup model failed: {err}")));
}
};
if let Err(err) = self.provider_store.set_default_model(mw).await {
return Ok(failed_result(format!("setup model failed: {err}")));
}
let label = next.label();
let persist_note = self.persist_model_choice(&next);
*self.provider.write().expect("provider lock poisoned") = next;
Ok(CommandResult {
success: true,
message: format!("setup model changed: {label}{persist_note}"),
error_code: None,
error_fields: None,
})
}
fn persist_model_choice(&self, next: &ProviderChoice) -> String {
let provider_name = next.provider_name().to_string();
let result = self
.settings
.set_default_provider(Some(provider_name.clone()))
.and_then(|()| self.settings.set_model(provider_name, next.model_spec()));
match result {
Ok(()) => String::new(),
Err(err) => format!(" (warning: settings not saved: {err})"),
}
}
async fn model_suggestions_message(&self, current: &ProviderChoice) -> String {
const MODEL_SUGGESTION_LIMIT: usize = 20;
const DISCOVERY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
let discovered = tokio::time::timeout(
DISCOVERY_TIMEOUT,
super::model_discovery::discover_provider_models(current, &self.config.snapshot()),
)
.await;
if let Ok(Ok(Some(models))) = discovered
&& !models.is_empty()
{
let provider = current.provider_name();
let ranked = super::model_ranking::rank_discovered_models(
provider,
models,
Some(current.model_id()),
);
let shown: Vec<&str> = ranked
.models
.iter()
.take(MODEL_SUGGESTION_LIMIT)
.map(|model| model.model_id.as_str())
.collect();
let suffix = if ranked.models.len() > MODEL_SUGGESTION_LIMIT {
format!(
" … and {} more",
ranked.models.len() - MODEL_SUGGESTION_LIMIT
)
} else {
String::new()
};
return format!("models from {provider} API: {}{suffix}", shown.join(", "));
}
format!(
"suggestions: {}",
ProviderChoice::model_suggestions_for_provider(current.provider_name()).join(", ")
)
}
async fn change_effort(&self, raw: &str) -> everruns_core::Result<CommandResult> {
let current = self
.provider
.read()
.expect("provider lock poisoned")
.clone();
if raw.is_empty() {
return Ok(CommandResult {
success: true,
message: format!(
"setup effort: {}; suggestions: {}",
current.label(),
current
.reasoning_effort_options()
.iter()
.map(|effort| effort.value.as_str())
.collect::<Vec<_>>()
.join(", ")
),
error_code: None,
error_fields: None,
});
}
let next = match current.resolve_reasoning_effort(raw) {
Ok(next) => next,
Err(err) => return Ok(failed_result(format!("setup effort failed: {err}"))),
};
let label = next.label();
let persist_note = self.persist_model_choice(&next);
*self.provider.write().expect("provider lock poisoned") = next;
Ok(CommandResult {
success: true,
message: format!("setup effort changed: {label}{persist_note}"),
error_code: None,
error_fields: None,
})
}
fn change_token(&self, raw: &str) -> everruns_core::Result<CommandResult> {
if raw.is_empty() {
let snapshot = self.config.snapshot();
let status: Vec<String> = TOKEN_PROVIDERS
.iter()
.map(|p| {
let marker = if snapshot.has_token(p) { "stored" } else { "-" };
format!("{p}: {marker}")
})
.collect();
return Ok(CommandResult {
success: true,
message: format!(
"setup tokens ({}): {}",
self.settings.path().display(),
status.join(", ")
),
error_code: None,
error_fields: None,
});
}
let mut parts = raw.splitn(2, char::is_whitespace);
let provider = parts.next().unwrap_or_default().to_ascii_lowercase();
let rest = parts.next().unwrap_or_default().trim();
if !TOKEN_PROVIDERS.contains(&provider.as_str()) {
return Ok(failed_result(format!(
"setup token failed: unknown provider `{provider}`; expected one of {}",
TOKEN_PROVIDERS.join(", ")
)));
}
if rest.is_empty() {
return Ok(failed_result(
"setup token failed: expected <provider> <value|clear>".to_string(),
));
}
if rest.eq_ignore_ascii_case("clear") {
return Ok(match self.settings.clear_token(&provider) {
Ok(true) => CommandResult {
success: true,
message: format!("setup token cleared for {provider}"),
error_code: None,
error_fields: None,
},
Ok(false) => CommandResult {
success: true,
message: format!("setup token: no token was stored for {provider}"),
error_code: None,
error_fields: None,
},
Err(err) => failed_result(format!("setup token clear failed: {err}")),
});
}
match self.settings.set_token(provider.clone(), rest.to_string()) {
Ok(()) => Ok(CommandResult {
success: true,
message: format!(
"setup token stored for {provider} (in {})",
self.settings.path().display()
),
error_code: None,
error_fields: None,
}),
Err(err) => Ok(failed_result(format!("setup token save failed: {err}"))),
}
}
fn change_base_url(&self, raw: &str) -> everruns_core::Result<CommandResult> {
let mut parts = raw.splitn(2, char::is_whitespace);
let provider = parts.next().unwrap_or_default().to_ascii_lowercase();
let rest = parts.next().unwrap_or_default().trim();
if !BASE_URL_PROVIDERS.contains(&provider.as_str()) {
return Ok(failed_result(format!(
"setup url failed: unknown provider `{provider}`; expected one of {}",
BASE_URL_PROVIDERS.join(", ")
)));
}
if rest.is_empty() {
let snapshot = self.config.snapshot();
let current = snapshot
.base_url_for(&provider)
.unwrap_or("<unset>")
.to_string();
return Ok(CommandResult {
success: true,
message: format!("setup url for {provider}: {current}"),
error_code: None,
error_fields: None,
});
}
if rest.eq_ignore_ascii_case("clear") {
return Ok(match self.settings.clear_base_url(&provider) {
Ok(true) => CommandResult {
success: true,
message: format!("setup url cleared for {provider}"),
error_code: None,
error_fields: None,
},
Ok(false) => CommandResult {
success: true,
message: format!("setup url: no base URL was stored for {provider}"),
error_code: None,
error_fields: None,
},
Err(err) => failed_result(format!("setup url clear failed: {err}")),
});
}
if !rest.starts_with("http://") && !rest.starts_with("https://") {
return Ok(failed_result(
"setup url failed: base URL must start with http:// or https://".to_string(),
));
}
match self
.settings
.set_base_url(provider.clone(), rest.to_string())
{
Ok(()) => Ok(CommandResult {
success: true,
message: format!(
"setup url stored for {provider} (in {})",
self.settings.path().display()
),
error_code: None,
error_fields: None,
}),
Err(err) => Ok(failed_result(format!("setup url save failed: {err}"))),
}
}
fn change_attribution(&self, raw: &str) -> everruns_core::Result<CommandResult> {
let trimmed = raw.trim();
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("status") {
let enabled = self.config.attribution_enabled();
return Ok(CommandResult {
success: true,
message: format!(
"setup attribution: {} ({})",
on_off(enabled),
self.settings.path().display()
),
error_code: None,
error_fields: None,
});
}
let enabled = match parse_on_off(trimmed) {
Some(enabled) => enabled,
None => {
return Ok(failed_result(
"setup attribution failed: expected on/off".to_string(),
));
}
};
match self.settings.set_attribution(enabled) {
Ok(()) => Ok(CommandResult {
success: true,
message: format!("setup attribution: {}", on_off(enabled)),
error_code: None,
error_fields: None,
}),
Err(err) => Ok(failed_result(format!(
"setup attribution save failed: {err}"
))),
}
}
fn change_approval(&self, raw: &str) -> everruns_core::Result<CommandResult> {
let trimmed = raw.trim();
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("status") {
return Ok(CommandResult {
success: true,
message: format!(
"setup approval: {} (protective | normal | off) ({})",
self.config.approval_mode(),
self.settings.path().display()
),
error_code: None,
error_fields: None,
});
}
let mode = match ApprovalMode::parse(trimmed) {
Some(mode) => mode,
None => {
return Ok(failed_result(
"setup approval failed: expected protective, normal, or off".to_string(),
));
}
};
match self.settings.set_approval_mode(mode) {
Ok(()) => Ok(CommandResult {
success: true,
message: format!("setup approval: {mode}"),
error_code: None,
error_fields: None,
}),
Err(err) => Ok(failed_result(format!("setup approval save failed: {err}"))),
}
}
}
fn parse_on_off(raw: &str) -> Option<bool> {
match raw.trim().to_ascii_lowercase().as_str() {
"on" | "true" | "yes" | "1" => Some(true),
"off" | "false" | "no" | "0" => Some(false),
_ => None,
}
}
fn on_off(enabled: bool) -> &'static str {
if enabled { "on" } else { "off" }
}
fn failed_result(message: String) -> CommandResult {
CommandResult {
success: false,
message,
error_code: None,
error_fields: None,
}
}
fn into_tool_result(outcome: everruns_core::Result<CommandResult>) -> ToolExecutionResult {
match outcome {
Ok(result) if result.success => ToolExecutionResult::success(json!({
"success": true,
"message": result.message,
})),
Ok(result) => ToolExecutionResult::tool_error(result.message),
Err(err) => ToolExecutionResult::tool_error(err.to_string()),
}
}
fn required_str_arg<'a>(arguments: &'a Value, key: &str) -> Result<&'a str, ToolExecutionResult> {
match arguments.get(key).and_then(Value::as_str) {
Some(value) if !value.trim().is_empty() => Ok(value.trim()),
_ => Err(ToolExecutionResult::tool_error(format!(
"'{key}' is required"
))),
}
}
struct SetReasoningEffortTool {
controller: SetupController,
}
#[async_trait]
impl Tool for SetReasoningEffortTool {
fn narrate(
&self,
tool_call: &ToolCall,
phase: ToolNarrationPhase,
locale: Option<&str>,
) -> Option<String> {
let _ = locale;
let effort = arg_str(&tool_call.arguments, &["effort"]).map(|value| truncate(value, 24));
Some(stable_labeled("Set reasoning effort", effort, phase))
}
fn name(&self) -> &str {
"set_reasoning_effort"
}
fn display_name(&self) -> Option<&str> {
Some("Set reasoning effort")
}
fn description(&self) -> &str {
"Change the current model's reasoning effort for this session (e.g. escalate to think \
harder before a difficult step, or deescalate for cheap follow-ups). Applies on the next \
turn — no restart. The valid set is model-specific; if the level is unknown the tool \
returns the accepted values so you can retry."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"effort": {
"type": "string",
"description": "Reasoning-effort level for the active model, e.g. low / medium / high (model-specific)."
}
},
"required": ["effort"],
"additionalProperties": false
})
}
async fn execute(&self, arguments: Value) -> ToolExecutionResult {
let effort = match required_str_arg(&arguments, "effort") {
Ok(value) => value,
Err(err) => return err,
};
into_tool_result(self.controller.change_effort(effort).await)
}
}
struct SetModelTool {
controller: SetupController,
}
#[async_trait]
impl Tool for SetModelTool {
fn narrate(
&self,
tool_call: &ToolCall,
phase: ToolNarrationPhase,
locale: Option<&str>,
) -> Option<String> {
let _ = locale;
let model = arg_str(&tool_call.arguments, &["model"]).map(|value| truncate(value, 48));
Some(stable_labeled("Set model", model, phase))
}
fn name(&self) -> &str {
"set_model"
}
fn display_name(&self) -> Option<&str> {
Some("Set model")
}
fn description(&self) -> &str {
"Switch the model used for this session. Applies on the next turn — no restart. The id is \
resolved against the current provider; an optional reasoning effort can be set at the same \
time. Avoid switching mid-task unless it clearly helps."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"model": {
"type": "string",
"description": "Model id for the active provider, e.g. `gpt-5.4` or `claude-sonnet-4-5`."
},
"reasoning_effort": {
"type": "string",
"description": "Optional reasoning-effort level to apply with the model (model-specific)."
}
},
"required": ["model"],
"additionalProperties": false
})
}
async fn execute(&self, arguments: Value) -> ToolExecutionResult {
let model = match required_str_arg(&arguments, "model") {
Ok(value) => value,
Err(err) => return err,
};
let spec = match arguments.get("reasoning_effort").and_then(Value::as_str) {
Some(effort) if !effort.trim().is_empty() => format!("{model} {}", effort.trim()),
_ => model.to_string(),
};
into_tool_result(self.controller.change_model(&spec).await)
}
}
struct SetProviderTool {
controller: SetupController,
}
#[async_trait]
impl Tool for SetProviderTool {
fn narrate(
&self,
tool_call: &ToolCall,
phase: ToolNarrationPhase,
locale: Option<&str>,
) -> Option<String> {
let _ = locale;
let provider =
arg_str(&tool_call.arguments, &["provider"]).map(|value| truncate(value, 24));
Some(stable_labeled("Set provider", provider, phase))
}
fn name(&self) -> &str {
"set_provider"
}
fn display_name(&self) -> Option<&str> {
Some("Set provider")
}
fn description(&self) -> &str {
"Switch the LLM provider for this session. Applies on the next turn — no restart. \
Optionally pin a model for the new provider at the same time. Requires that provider's \
credentials to already be configured."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"provider": {
"type": "string",
"description": "Provider name.",
"enum": SUPPORTED_PROVIDERS
},
"model": {
"type": "string",
"description": "Optional model id to select for the new provider."
}
},
"required": ["provider"],
"additionalProperties": false
})
}
async fn execute(&self, arguments: Value) -> ToolExecutionResult {
let provider = match required_str_arg(&arguments, "provider") {
Ok(value) => value,
Err(err) => return err,
};
let spec = match arguments.get("model").and_then(Value::as_str) {
Some(model) if !model.trim().is_empty() => format!("{provider} {}", model.trim()),
_ => provider.to_string(),
};
into_tool_result(self.controller.change_provider(&spec).await)
}
}
impl SetupCapability {
pub(crate) fn needs_onboarding(settings: &crate::settings::Settings) -> bool {
if settings.default_provider.is_some() {
return false;
}
if env_credential_present() {
return false;
}
settings.tokens.is_empty() && !settings.has_codex_auth()
}
}
fn env_credential_present() -> bool {
const VARS: &[&str] = &[
"OPENAI_API_KEY",
"CODEX_ACCESS_TOKEN",
"ANTHROPIC_API_KEY",
"OPENROUTER_API_KEY",
"GEMINI_API_KEY",
"GOOGLE_API_KEY",
"OLLAMA_BASE_URL",
"OLLAMA_API_KEY",
"CUSTOM_BASE_URL",
];
VARS.iter()
.any(|var| std::env::var(var).map(|v| !v.is_empty()).unwrap_or(false))
}
#[cfg(test)]
mod tests {
use super::*;
struct StubProviderStore;
#[async_trait::async_trait]
impl everruns_core::ProviderStore for StubProviderStore {
async fn get_resolved_model(
&self,
_model_id: everruns_core::ModelId,
) -> everruns_core::Result<Option<everruns_core::ResolvedModel>> {
Ok(None)
}
async fn get_default_model(
&self,
) -> everruns_core::Result<Option<everruns_core::ResolvedModel>> {
Ok(None)
}
}
#[async_trait::async_trait]
impl RuntimeProviderStore for StubProviderStore {
async fn set_default_model(
&self,
_model: everruns_core::ResolvedModel,
) -> everruns_core::Result<()> {
Ok(())
}
}
fn test_controller(
provider: ProviderChoice,
) -> (
SetupController,
Arc<RwLock<ProviderChoice>>,
tempfile::TempDir,
) {
let dir = tempfile::tempdir().expect("tempdir");
let settings = Arc::new(SettingsStore::open(dir.path().join("settings.toml")));
settings
.set_token("openai".to_string(), "sk-test".to_string())
.expect("seed openai token");
settings
.set_token("anthropic".to_string(), "sk-test".to_string())
.expect("seed anthropic token");
let provider = Arc::new(RwLock::new(provider));
let controller = SetupController {
provider: provider.clone(),
provider_store: Arc::new(StubProviderStore),
config: settings.clone(),
settings,
};
(controller, provider, dir)
}
#[tokio::test]
async fn set_reasoning_effort_tool_applies_live_and_validates() {
let (controller, provider, _dir) = test_controller(ProviderChoice::OpenAi {
model: "gpt-5.5".to_string(),
reasoning_effort: Some("medium".to_string()),
});
let tool = SetReasoningEffortTool {
controller: controller.clone(),
};
let result = tool.execute(json!({ "effort": "high" })).await;
assert!(result.is_success(), "escalate: {result:?}");
assert_eq!(provider.read().unwrap().reasoning_effort(), Some("high"));
let result = tool.execute(json!({})).await;
assert!(result.is_error(), "missing effort should error");
let result = tool.execute(json!({ "effort": "ludicrous" })).await;
assert!(result.is_error(), "unknown effort should error");
assert_eq!(provider.read().unwrap().reasoning_effort(), Some("high"));
}
#[tokio::test]
async fn set_model_tool_switches_model_and_optional_effort() {
let (controller, provider, _dir) = test_controller(ProviderChoice::OpenAi {
model: "gpt-5.5".to_string(),
reasoning_effort: Some("medium".to_string()),
});
let tool = SetModelTool { controller };
let result = tool
.execute(json!({ "model": "gpt-5.4", "reasoning_effort": "high" }))
.await;
assert!(result.is_success(), "set_model: {result:?}");
assert_eq!(provider.read().unwrap().model_id(), "gpt-5.4");
assert_eq!(provider.read().unwrap().reasoning_effort(), Some("high"));
let result = tool.execute(json!({})).await;
assert!(result.is_error(), "missing model should error");
}
#[test]
fn setup_capability_exposes_live_config_tools() {
let (controller, _provider, _dir) = test_controller(ProviderChoice::Sim);
let capability = SetupCapability {
provider: controller.provider.clone(),
provider_store: controller.provider_store.clone(),
config: controller.config.clone(),
settings: controller.settings.clone(),
};
let names: Vec<String> = capability
.tools()
.iter()
.map(|t| t.name().to_string())
.collect();
for expected in ["set_reasoning_effort", "set_model", "set_provider"] {
assert!(
names.iter().any(|n| n == expected),
"{expected} should be exposed by SetupCapability: {names:?}"
);
}
let prompt = capability.system_prompt_addition().expect("setup prompt");
assert!(prompt.contains("set_reasoning_effort"));
assert!(prompt.contains("set_model"));
assert!(prompt.contains("set_provider"));
}
#[tokio::test]
async fn set_provider_tool_switches_provider_live() {
let (controller, provider, _dir) = test_controller(ProviderChoice::Sim);
let tool = SetProviderTool { controller };
let result = tool.execute(json!({ "provider": "openai" })).await;
assert!(result.is_success(), "set_provider: {result:?}");
assert_eq!(provider.read().unwrap().provider_name(), "openai");
let result = tool.execute(json!({ "provider": "nope" })).await;
assert!(result.is_error(), "unknown provider should error");
}
#[test]
fn needs_onboarding_true_for_empty_settings() {
let _guard = crate::test_env::lock();
unsafe {
std::env::remove_var("OPENAI_API_KEY");
std::env::remove_var("ANTHROPIC_API_KEY");
std::env::remove_var("OPENROUTER_API_KEY");
std::env::remove_var("GEMINI_API_KEY");
std::env::remove_var("GOOGLE_API_KEY");
std::env::remove_var("OLLAMA_BASE_URL");
std::env::remove_var("OLLAMA_API_KEY");
std::env::remove_var("CUSTOM_BASE_URL");
std::env::remove_var("CUSTOM_API_KEY");
}
let settings = crate::settings::Settings::default();
assert!(SetupCapability::needs_onboarding(&settings));
}
#[test]
fn needs_onboarding_ignores_custom_api_key_without_base_url() {
let _guard = crate::test_env::lock();
unsafe {
std::env::remove_var("OPENAI_API_KEY");
std::env::remove_var("ANTHROPIC_API_KEY");
std::env::remove_var("OPENROUTER_API_KEY");
std::env::remove_var("GEMINI_API_KEY");
std::env::remove_var("GOOGLE_API_KEY");
std::env::remove_var("OLLAMA_BASE_URL");
std::env::remove_var("OLLAMA_API_KEY");
std::env::remove_var("CUSTOM_BASE_URL");
std::env::set_var("CUSTOM_API_KEY", "sk-orphan");
}
let settings = crate::settings::Settings::default();
assert!(SetupCapability::needs_onboarding(&settings));
unsafe {
std::env::set_var("CUSTOM_BASE_URL", "http://localhost:8000/v1");
}
assert!(!SetupCapability::needs_onboarding(&settings));
unsafe {
std::env::remove_var("CUSTOM_BASE_URL");
std::env::remove_var("CUSTOM_API_KEY");
}
}
#[test]
fn needs_onboarding_false_when_provider_is_saved() {
let settings = crate::settings::Settings {
default_provider: Some("anthropic".to_string()),
..Default::default()
};
assert!(!SetupCapability::needs_onboarding(&settings));
}
#[test]
fn needs_onboarding_false_when_token_is_saved() {
let mut tokens = std::collections::BTreeMap::new();
tokens.insert("openai".to_string(), "sk-test".to_string());
let settings = crate::settings::Settings {
default_provider: None,
tokens,
..Default::default()
};
assert!(!SetupCapability::needs_onboarding(&settings));
}
#[test]
fn shell_output_format_ignores_whitespace_only_streams() {
let value = serde_json::json!({
"stdout": "\n",
"stderr": " \n",
"exit_code": 0,
});
assert_eq!(format_shell_output(&value), "exit 0");
}
#[test]
fn environment_context_renders_requested_fields() {
let rendered = render_environment_context(&EnvironmentContext {
cwd: "/repo".to_string(),
shell: "zsh".to_string(),
current_date: "2026-05-20".to_string(),
timezone: "America/Chicago".to_string(),
git_repo: Some("https://github.com/everruns/everruns.git".to_string()),
git_user: Some("Chal & Yi".to_string()),
git_email: Some("chalyi@example.com".to_string()),
repo_root: "/repo".to_string(),
git_current_branch: Some("feature<context>".to_string()),
worktree_path: None,
});
assert!(rendered.starts_with("<environment_context>\n"));
assert!(rendered.contains(" <cwd>/repo</cwd>\n"));
assert!(rendered.contains(" <shell>zsh</shell>\n"));
assert!(rendered.contains(" <current_date>2026-05-20</current_date>\n"));
assert!(rendered.contains(" <timezone>America/Chicago</timezone>\n"));
assert!(
rendered.contains(" <git_repo>https://github.com/everruns/everruns.git</git_repo>\n")
);
assert!(rendered.contains(" <git_user>Chal & Yi</git_user>\n"));
assert!(rendered.contains(" <git_email>chalyi@example.com</git_email>\n"));
assert!(
rendered
.contains(" <git_current_branch>feature<context></git_current_branch>\n")
);
assert!(rendered.ends_with("</environment_context>"));
}
#[test]
fn redact_git_remote_secret_removes_http_userinfo() {
let remote = "https://user:ghp_SUPERSECRET@github.com/org/private.git";
assert_eq!(
redact_git_remote_secret(remote),
"https://github.com/org/private.git"
);
}
#[test]
fn redact_git_remote_secret_leaves_non_url_remote_unchanged() {
let remote = "git@github.com:everruns/everruns.git";
assert_eq!(redact_git_remote_secret(remote), remote);
}
#[test]
fn redact_git_remote_secret_preserves_ssh_url_username() {
let remote = "ssh://git@github.com/everruns/everruns.git";
assert_eq!(redact_git_remote_secret(remote), remote);
}
#[test]
fn redact_git_remote_secret_removes_http_token_only_userinfo() {
let remote = "https://ghp_TOKEN@github.com/org/private.git";
assert_eq!(
redact_git_remote_secret(remote),
"https://github.com/org/private.git"
);
}
#[test]
fn redact_git_remote_secret_leaves_https_without_userinfo_unchanged() {
let remote = "https://github.com/everruns/everruns.git";
assert_eq!(redact_git_remote_secret(remote), remote);
}
}