use std::{
io::{self, Read},
process::ExitCode,
};
use basis::{
AllowAll, Approver, DenyAll, DenyAllGate, JsonlWriter, PreparedRun, RunProfile, RunSpec,
Runtime, ShellAccess, Workspace,
};
use basis_host::ApprovalPolicy;
use crate::{approver::TerminalApprover, cli::RunArgs, exit::exit_code};
pub(crate) async fn execute_run(args: RunArgs) -> Result<ExitCode, String> {
let prompt = prompt_from(args.prompt.clone())?;
if prompt.trim().is_empty() {
return Err("prompt is empty".to_string());
}
let spec = run_spec(&args, prompt);
let workspace = match args.workspace {
Some(path) => path,
None => {
std::env::current_dir().map_err(|error| format!("no working directory: {error}"))?
}
};
let store_dir = basis_tasks::Tasks::store_dir(&workspace)
.map_err(|error| format!("open data directory: {error}"))?;
let runtime = Runtime::builder().with_store_dir(store_dir);
let builder = Workspace::builder(workspace);
let (runtime, builder) = basis_tasks::configure_builders(
runtime,
builder,
args.provider.as_deref(),
args.base_url.as_deref(),
args.model.as_deref(),
ShellAccess::from_flag(!args.no_shell),
)
.map_err(|error| error.to_string())?;
let builder = builder.with_runtime_builder(runtime);
let approver = approver(args.approve);
let workspace = builder.open().await.map_err(|error| error.to_string())?;
let run = workspace.prepare(spec).map_err(|error| error.to_string())?;
let mut run = gated(run, args.approve);
let report = run
.execute_with_approver(JsonlWriter::new(io::stdout()), approver)
.await
.map_err(|error| error.to_string())?;
Ok(ExitCode::from(exit_code(&report)))
}
fn approver(policy: ApprovalPolicy) -> Box<dyn Approver> {
match policy {
ApprovalPolicy::Always => Box::new(AllowAll),
ApprovalPolicy::Prompt => Box::new(TerminalApprover::new()),
ApprovalPolicy::Never => Box::new(DenyAll),
}
}
fn gated(run: PreparedRun, policy: ApprovalPolicy) -> PreparedRun {
match policy {
ApprovalPolicy::Never => run.with_tool_authorizer(DenyAllGate),
ApprovalPolicy::Always | ApprovalPolicy::Prompt => run,
}
}
fn run_spec(args: &RunArgs, prompt: String) -> RunSpec {
let mut spec = RunSpec::new(prompt);
if let Some(effort) = args.effort {
spec = spec.with_effort(effort.into());
}
if let Some(deadline) = args.deadline {
spec = spec.with_deadline(deadline.duration());
}
if let Some(tool_budget) = args.tool_budget {
spec = spec.with_tool_budget(tool_budget);
}
if let Some(token_budget) = args.token_budget {
spec = spec.with_token_budget(token_budget);
}
if let Some(system_prompt) = crate::cli::system_prompt(
args.system_prompt.clone(),
args.append_system_prompt.clone(),
) {
spec = spec.with_profile(RunProfile::new().with_system_prompt(system_prompt));
}
spec
}
pub(crate) fn prompt_from(argument: String) -> Result<String, String> {
match argument.as_str() {
"-" => read_prompt(io::stdin().lock()),
_ => Ok(argument),
}
}
fn read_prompt(mut source: impl Read) -> Result<String, String> {
let mut prompt = String::new();
source
.read_to_string(&mut prompt)
.map_err(|error| format!("could not read the prompt from stdin: {error}"))?;
if prompt.trim().is_empty() {
return Err(
"no prompt on stdin: `-` reads one from stdin, and nothing arrived".to_string(),
);
}
Ok(prompt)
}
#[cfg(test)]
mod tests {
use std::{path::Path, time::Duration};
use super::*;
#[test]
fn a_dash_reads_the_prompt_from_stdin() {
let prompt = read_prompt(&b"fix the failing test\nthen push\n"[..])
.expect("a prompt arrived on stdin");
assert_eq!(
prompt, "fix the failing test\nthen push\n",
"a multi-line prompt must arrive whole, not truncated at the first newline"
);
}
#[test]
fn an_empty_stdin_says_where_the_prompt_was_looked_for() {
let reason = read_prompt(&b" \n"[..]).expect_err("whitespace is not a prompt");
assert!(
reason.contains("stdin"),
"the reason must name stdin: {reason}"
);
}
#[test]
fn a_prompt_that_is_not_a_dash_is_taken_as_written() {
assert_eq!(
prompt_from("fix the failing test".to_string()).expect("a literal prompt"),
"fix the failing test"
);
}
fn run_args(flags: &[&str]) -> RunArgs {
use clap::Parser;
use crate::cli::{Cli, Command};
let mut argv = vec!["basis", "spawn", "prompt"];
argv.extend_from_slice(flags);
let Some(Command::Spawn(parsed)) = Cli::try_parse_from(argv).expect("parses").command
else {
panic!("spawn parses");
};
parsed
}
#[test]
fn the_replace_flag_reaches_the_minted_spec() {
let args = run_args(&["--system-prompt", "you are Acme's reviewer"]);
let spec = run_spec(&args, "prompt".to_string());
let expected = RunSpec::new("prompt").with_profile(RunProfile::new().with_system_prompt(
basis::SystemPrompt::Replace("you are Acme's reviewer".to_string()),
));
assert_eq!(spec, expected);
}
#[test]
fn the_append_flag_reaches_the_minted_spec() {
let args = run_args(&["--append-system-prompt", "answer in Latin"]);
let spec = run_spec(&args, "prompt".to_string());
let expected = RunSpec::new("prompt").with_profile(
RunProfile::new()
.with_system_prompt(basis::SystemPrompt::Append("answer in Latin".to_string())),
);
assert_eq!(spec, expected);
}
#[test]
fn neither_flag_leaves_the_spec_at_the_workspace_default() {
let args = run_args(&[]);
let spec = run_spec(&args, "prompt".to_string());
assert_eq!(
spec,
RunSpec::new("prompt"),
"unsaid must leave the profile empty, not force a system prompt"
);
}
#[test]
fn the_attended_route_is_reached_only_for_the_stream_it_renders() {
use clap::Parser;
use crate::{
cli::{Cli, Command},
route::{Route, route},
};
for flags in [
vec![],
vec!["--json"],
vec!["--await"],
vec!["--json", "--await"],
vec!["--resumable"],
vec!["--detached"],
] {
let mut argv = vec!["basis", "spawn", "a prompt"];
argv.extend_from_slice(&flags);
let Some(Command::Spawn(args)) = Cli::try_parse_from(argv).expect("parses").command
else {
panic!("spawn parses");
};
for in_task in [false, true] {
if route(&args, in_task) == Route::Attended {
assert!(args.json, "attended without --json: {flags:?}");
}
}
}
}
fn writing_mock(workspace: &Path) -> mentra::test::MockRuntime {
mentra::test::MockRuntime::builder()
.model("mock-model", "openai")
.with_policy(mentra::RuntimePolicy::workspace_bounded(workspace))
.with_tool_authorizer(basis::ApprovalGate::new())
.tool_calls(vec![mentra::test::MockToolCall::new(
"files",
serde_json::json!({
"operations": [{ "op": "create", "path": "made.txt", "content": "hi" }]
}),
)])
.text("done")
.build()
.expect("mock runtime builds")
}
async fn wrote_a_file_under(policy: ApprovalPolicy, workspace: &Path) -> (bool, usize) {
let mock = writing_mock(workspace);
let session = mock
.runtime()
.create_session_with_config(
"test",
mock.model(),
mentra::agent::AgentConfig {
workspace: mentra::agent::WorkspaceConfig {
base_dir: workspace.to_path_buf(),
..Default::default()
},
..Default::default()
},
)
.expect("session");
let run = basis::run::prepare_with_session(
session,
workspace,
"make a file",
&basis::ContextConfig {
file_name: "AGENTS.md".to_string(),
global_dir: None,
walk_parents: false,
},
"openai",
"mock-model",
)
.expect("prepared");
run.session()
.permission_handle()
.remember_rule(mentra::session::RememberedRule {
key: mentra::session::RuleKey {
tool_name: "files".to_string(),
pattern: None,
},
allow: true,
scope: mentra::session::PermissionRuleScope::Global,
reason: None,
})
.expect("the rule is remembered");
let mut run = gated(run, policy);
let report = tokio::time::timeout(
Duration::from_secs(10),
run.execute_with_approver(basis::CollectingSink::new(), approver(policy)),
)
.await
.expect("a refusal never waits on anyone, so this must not hang")
.expect("the run completes");
let asked = report
.sink
.events()
.iter()
.filter(|event| matches!(event, basis::Event::PermissionRequested { .. }))
.count();
(workspace.join("made.txt").exists(), asked)
}
#[tokio::test]
async fn a_durable_rule_cannot_allow_what_never_refuses() {
let workspace = tempfile::tempdir().expect("tempdir");
let (written, _asked) = wrote_a_file_under(ApprovalPolicy::Never, workspace.path()).await;
assert!(
!written,
"a durable allow must not outlive the `--approve never` that refuses it"
);
}
#[tokio::test]
async fn a_durable_rule_still_answers_where_the_run_would_have_allowed() {
let workspace = tempfile::tempdir().expect("tempdir");
let (written, asked) = wrote_a_file_under(ApprovalPolicy::Always, workspace.path()).await;
assert!(
written,
"a seeded rule must still answer for a policy that permits the call"
);
assert_eq!(
asked, 0,
"and it must answer without asking, or the seed was never really live"
);
}
}