use argonauts_core::asking::AskReply;
use argonauts_core::events::approval::AnswerOption;
use serde_json::Value;
use crate::choices::{choose, option};
const EDIT_TOOLS: &[&str] = &["Edit", "Write", "MultiEdit", "NotebookEdit"];
const COMMAND_TOOL: &str = "Bash";
pub(crate) const OUTSIDE_TOOL: &str = "ExternalDirectory";
const MAX_ROWS: usize = 12;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Grant {
Edits,
Commands(String),
Tool(String),
Never,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct Question {
pub title: String,
pub body: Vec<String>,
pub grant: Grant,
pub about: String,
}
impl Question {
pub(crate) fn of(tool: &str, input: &Value) -> Self {
if EDIT_TOOLS.contains(&tool) {
let file = text(input, &["file_path", "notebook_path", "path"]);
let file = if file.is_empty() { "a file" } else { &file };
return Self {
title: format!("Make this edit to {file}?"),
body: edit_rows(input),
grant: Grant::Edits,
about: format!("the edit to {file}"),
};
}
if tool == COMMAND_TOOL {
let command = text(input, &["command"]);
let mut body: Vec<String> = command.lines().map(str::to_owned).collect();
let description = text(input, &["description"]);
if !description.is_empty() {
body.push(description);
}
let first = command.lines().next().unwrap_or_default().trim();
let mut body = capped(body);
let grant = match super::rm::always_asked(&command) {
Some(why) => {
body.push(format!("Claude Code always asks about this one: {why}."));
Grant::Never
}
None => Grant::Commands(command_prefix(&command)),
};
return Self {
title: "Run this command?".to_owned(),
body,
grant,
about: crate::controller::recap::title_from_request(first),
};
}
if tool == OUTSIDE_TOOL {
let folder = text(input, &["path"]);
return Self {
title: format!("Work outside your folder, in {folder}?"),
about: format!("working in {folder}"),
body: vec![folder],
grant: Grant::Never,
};
}
let shown = serde_json::to_string_pretty(input).unwrap_or_default();
Self {
title: String::new(),
body: capped(shown.lines().map(str::to_owned).collect()),
grant: Grant::Tool(tool.to_owned()),
about: tool.to_owned(),
}
}
pub(crate) fn title(&self, cli: &str, tool: &str) -> String {
if self.title.is_empty() {
format!("Let {cli} use {tool}?")
} else {
self.title.clone()
}
}
pub(crate) fn options(&self, cli: &str) -> Vec<AnswerOption> {
let again = match &self.grant {
Grant::Edits => {
Some("Yes, and allow edits for the rest of this conversation".to_owned())
}
Grant::Commands(prefix) => Some(format!(
"Yes, and don't ask again for {prefix} in this project"
)),
Grant::Tool(tool) => Some(format!(
"Yes, and don't ask again for {tool} in this project"
)),
Grant::Never => None,
};
let mut options = vec![option('y', "Yes", choose("yes"), true)];
options.extend(again.map(|again| option('a', &again, choose("always"), false)));
options.push(option(
'n',
&format!("No, and tell {cli} what to do instead (esc)"),
choose("no"),
false,
));
options
}
}
pub(crate) fn allowed(tool: &str, input: &Value, edits_allowed: bool, patterns: &[String]) -> bool {
if tool == OUTSIDE_TOOL {
return false;
}
if EDIT_TOOLS.contains(&tool) {
let file = text(input, &["file_path", "notebook_path", "path"]);
return edits_allowed && !in_setup_folder(&file);
}
if tool == COMMAND_TOOL {
let command = text(input, &["command"]);
return !names_setup_folder(&command)
&& patterns
.iter()
.any(|pattern| command_matches(pattern, &command));
}
patterns.iter().any(|pattern| pattern == tool)
}
pub(crate) fn command_pattern(prefix: &str) -> String {
format!("{prefix} *")
}
pub(crate) fn command_matches(pattern: &str, command: &str) -> bool {
let command = command.trim();
if command.is_empty() || has_shell_operators(command) {
return false;
}
match pattern.strip_suffix(" *") {
Some(base) => command == base || command.starts_with(&format!("{base} ")),
None => command == pattern,
}
}
pub(crate) fn command_prefix(command: &str) -> String {
let mut words = Vec::new();
let mut takes_module = false;
for word in command.split_whitespace() {
if words.len() == 3 || has_shell_operators(word) {
break;
}
if takes_module {
words.push(word);
takes_module = false;
continue;
}
if word == "-m" {
words.push(word);
takes_module = true;
continue;
}
if word.starts_with('-') || word.contains('/') || word.contains('=') {
break;
}
words.push(word);
}
if words.is_empty() {
command.trim().to_owned()
} else {
words.join(" ")
}
}
fn has_shell_operators(text: &str) -> bool {
["&&", "||", ";", "|", ">", "<", "`", "$(", "\n", "&"]
.iter()
.any(|operator| text.contains(operator))
}
pub(crate) fn no() -> AskReply {
AskReply::deny("The user said no to this. Stop, and wait for what they say next.")
}
pub(crate) fn autopilot_allows(tool: &str, input: &Value) -> bool {
if Question::of(tool, input).grant == Grant::Never {
return false;
}
if EDIT_TOOLS.contains(&tool) {
return !in_setup_folder(&text(input, &["file_path", "notebook_path", "path"]));
}
tool != COMMAND_TOOL || !names_setup_folder(&text(input, &["command"]))
}
pub(crate) fn not_on_autopilot() -> AskReply {
AskReply::deny(
"Nobody is at the terminal: the user is on autopilot, and this is asked about every \
time. Carry on without it, and say in your answer what you left undone.",
)
}
fn edit_rows(input: &Value) -> Vec<String> {
let mut rows = Vec::new();
let mut pair = |old: &str, new: &str| {
rows.extend(old.lines().map(|line| format!("- {line}")));
rows.extend(new.lines().map(|line| format!("+ {line}")));
};
if let Some(diff) = input.get("diff").and_then(Value::as_str) {
let changed = diff
.lines()
.filter(|line| !line.starts_with("+++") && !line.starts_with("---"))
.filter_map(|line| match line.split_at_checked(1) {
Some(("+", rest)) => Some(format!("+ {rest}")),
Some(("-", rest)) => Some(format!("- {rest}")),
_ => None,
});
return capped(changed.collect());
}
if let Some(edits) = input.get("edits").and_then(Value::as_array) {
for edit in edits {
pair(&text(edit, &["old_string"]), &text(edit, &["new_string"]));
}
} else if input.get("old_string").is_some() {
pair(&text(input, &["old_string"]), &text(input, &["new_string"]));
} else {
pair("", &text(input, &["content", "new_source"]));
}
capped(rows)
}
fn capped(mut rows: Vec<String>) -> Vec<String> {
if rows.len() > MAX_ROWS {
let hidden = rows.len() - (MAX_ROWS - 1);
rows.truncate(MAX_ROWS - 1);
rows.push(format!("… +{hidden} lines"));
}
rows
}
pub(crate) fn names_setup_folder(command: &str) -> bool {
command.contains(".argonauts")
}
pub(crate) fn in_setup_folder(path: &str) -> bool {
std::path::Path::new(path)
.components()
.any(|part| part.as_os_str() == ".argonauts")
}
fn text(value: &Value, keys: &[&str]) -> String {
keys.iter()
.find_map(|key| value.get(*key).and_then(Value::as_str))
.unwrap_or_default()
.to_owned()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn a_folder_outside_yours_is_asked_about_every_time() {
let input = json!({"path": "/"});
let question = Question::of(OUTSIDE_TOOL, &input);
assert_eq!(
question.title("OpenCode", OUTSIDE_TOOL),
"Work outside your folder, in /?"
);
let keys: Vec<char> = question
.options("OpenCode")
.iter()
.map(|option| option.key)
.collect();
assert_eq!(keys, ['y', 'n']);
let everything = [OUTSIDE_TOOL.to_owned(), "* *".to_owned()];
assert!(!allowed(OUTSIDE_TOOL, &input, true, &everything));
}
#[test]
fn each_tool_gets_claude_codes_question() {
let edit = Question::of(
"Edit",
&json!({"file_path": "invoice/money.py", "old_string": "sign = 1", "new_string": "negative = True"}),
);
assert_eq!(
edit.title("Claude Code", "Edit"),
"Make this edit to invoice/money.py?"
);
assert_eq!(edit.body, ["- sign = 1", "+ negative = True"]);
let labels: Vec<_> = edit
.options("Claude Code")
.into_iter()
.map(|option| option.label)
.collect();
assert_eq!(
labels,
[
"Yes",
"Yes, and allow edits for the rest of this conversation",
"No, and tell Claude Code what to do instead (esc)"
]
);
let bash = Question::of("Bash", &json!({"command": "python -m pytest -q"}));
assert_eq!(bash.title("Claude Code", "Bash"), "Run this command?");
assert_eq!(bash.grant, Grant::Commands("python -m pytest".into()));
let rm = Question::of("Bash", &json!({"command": r#"rm -rf "$BUILD_DIR/tmp""#}));
assert_eq!(rm.grant, Grant::Never);
assert_eq!(
rm.body,
[
r#"rm -rf "$BUILD_DIR/tmp""#,
"Claude Code always asks about this one: if BUILD_DIR were empty, it would \
remove /tmp."
]
);
let labels: Vec<_> = rm
.options("Claude Code")
.into_iter()
.map(|option| option.label)
.collect();
assert_eq!(
labels,
["Yes", "No, and tell Claude Code what to do instead (esc)"]
);
let fetch = Question::of("WebFetch", &json!({"url": "https://example.com"}));
assert_eq!(
fetch.title("Claude Code", "WebFetch"),
"Let Claude Code use WebFetch?"
);
assert_eq!(fetch.grant, Grant::Tool("WebFetch".into()));
}
#[test]
fn a_saved_pattern_covers_its_commands_and_nothing_chained() {
let pattern = command_pattern(&command_prefix("cargo test --workspace"));
assert_eq!(pattern, "cargo test *");
assert!(command_matches(&pattern, "cargo test"));
assert!(command_matches(&pattern, "cargo test -p app"));
assert!(!command_matches(&pattern, "cargo testify"));
assert!(!command_matches(&pattern, "cargo test && rm -rf x"));
assert!(!command_matches(&pattern, "cargo test > out.txt"));
assert_eq!(command_prefix("echo hi > shell.txt"), "echo hi");
assert_eq!(command_prefix("./run.sh"), "./run.sh");
let patterns = vec![pattern, "WebFetch".to_owned()];
assert!(allowed(
"Bash",
&json!({"command": "cargo test"}),
false,
&patterns
));
assert!(!allowed(
"Bash",
&json!({"command": "ls"}),
false,
&patterns
));
let into_settings = json!({"command": "cargo test --manifest-path .argonauts/x.toml"});
assert!(!allowed("Bash", &into_settings, true, &patterns));
assert!(allowed("WebFetch", &json!({}), false, &patterns));
assert!(!allowed("Edit", &json!({}), false, &patterns));
assert!(allowed("Write", &json!({}), true, &patterns));
let settings = json!({"file_path": "/repo/.argonauts/config.toml"});
assert!(!allowed("Write", &settings, true, &patterns));
let role = json!({"file_path": ".argonauts/roles/reviewer.md"});
assert!(!allowed("Edit", &role, true, &patterns));
assert!(allowed(
"Edit",
&json!({"file_path": "src/app.rs"}),
true,
&patterns
));
}
}