fix: support current Claude and Gemini CLI behavior

This commit is contained in:
lucaronin
2026-05-10 09:58:54 +02:00
parent b9d417ec1d
commit a2a6feea52
3 changed files with 343 additions and 40 deletions

View File

@@ -28,6 +28,23 @@
# ─────────────────────────────────────────────────────────────────────────
set -e
ensure_cargo_tooling() {
if command -v cargo >/dev/null 2>&1; then
return 0
fi
if [ -s "$HOME/.cargo/env" ]; then
# shellcheck disable=SC1091
. "$HOME/.cargo/env"
fi
if ! command -v cargo >/dev/null 2>&1; then
echo "❌ cargo must be available before pushing"
echo " Install Rust via https://rustup.rs or ensure ~/.cargo/bin is in PATH."
exit 1
fi
}
ensure_node_tooling() {
if command -v node >/dev/null 2>&1 && command -v pnpm >/dev/null 2>&1; then
return 0
@@ -85,6 +102,7 @@ else
fi
require_main_push
ensure_node_tooling
ensure_cargo_tooling
START_TIME=$(date +%s)

View File

@@ -51,6 +51,15 @@ pub struct ChatStreamRequest {
pub session_id: Option<String>,
}
const CLAUDE_SAFE_AGENT_TOOLS: &str = "Read,Edit,MultiEdit,Write,Glob,Grep,LS";
const CLAUDE_POWER_USER_AGENT_TOOLS: &str = "Read,Edit,MultiEdit,Write,Glob,Grep,LS,Bash";
const CLAUDE_CHAT_DISALLOWED_TOOLS_COMPAT: &str =
"Bash,Glob,Grep,Read,Edit,Write,NotebookEdit,WebFetch,WebSearch,TodoWrite,Task,MultiEdit,LS";
const CLAUDE_SAFE_DISALLOWED_TOOLS_COMPAT: &str =
"Bash,NotebookEdit,WebFetch,WebSearch,TodoWrite,Task";
const CLAUDE_POWER_USER_DISALLOWED_TOOLS_COMPAT: &str =
"NotebookEdit,WebFetch,WebSearch,TodoWrite,Task";
// ---------------------------------------------------------------------------
// Finding the `claude` binary
// ---------------------------------------------------------------------------
@@ -218,11 +227,36 @@ where
{
let bin = find_claude_binary()?;
let args = build_chat_args(&req);
run_claude_subprocess(&bin, &args, None, &mut emit)
let fallback_args = [build_chat_args_compat(&req)];
run_claude_subprocess(
ClaudeSubprocessRequest {
bin: &bin,
args: &args,
fallback_args: &fallback_args,
cwd: None,
},
&mut emit,
)
}
/// Build CLI arguments for a chat stream request.
fn build_chat_args(req: &ChatStreamRequest) -> Vec<String> {
build_chat_args_with_tool_policy(req, "--tools", String::new())
}
fn build_chat_args_compat(req: &ChatStreamRequest) -> Vec<String> {
build_chat_args_with_tool_policy(
req,
"--disallowedTools",
CLAUDE_CHAT_DISALLOWED_TOOLS_COMPAT.into(),
)
}
fn build_chat_args_with_tool_policy(
req: &ChatStreamRequest,
tool_flag: &str,
tool_value: String,
) -> Vec<String> {
let mut args: Vec<String> = vec![
"-p".into(),
req.message.clone(),
@@ -230,8 +264,8 @@ fn build_chat_args(req: &ChatStreamRequest) -> Vec<String> {
"stream-json".into(),
"--verbose".into(),
"--include-partial-messages".into(),
"--tools".into(),
String::new(), // empty string → disable all built-in tools
tool_flag.into(),
tool_value,
];
if let Some(ref sp) = req.system_prompt {
@@ -256,12 +290,44 @@ where
{
let bin = find_claude_binary()?;
let args = build_agent_args(&req)?;
run_claude_subprocess(&bin, &args, Some(&req.vault_path), &mut emit)
let fallback_args = vec![
build_agent_args_without_session_persistence(&req)?,
build_agent_args_compat(&req)?,
];
run_claude_subprocess(
ClaudeSubprocessRequest {
bin: &bin,
args: &args,
fallback_args: &fallback_args,
cwd: Some(&req.vault_path),
},
&mut emit,
)
}
/// Build CLI arguments for an agent stream request.
/// Native tools (bash, read, write, edit) are enabled by default — no `--tools ""`.
fn build_agent_args(req: &AgentStreamRequest) -> Result<Vec<String>, String> {
build_agent_args_with_tool_policy(req, ClaudeAgentToolPolicy::strict(req.permission_mode))
}
fn build_agent_args_without_session_persistence(
req: &AgentStreamRequest,
) -> Result<Vec<String>, String> {
build_agent_args_with_tool_policy(
req,
ClaudeAgentToolPolicy::strict_without_session_persistence(req.permission_mode),
)
}
fn build_agent_args_compat(req: &AgentStreamRequest) -> Result<Vec<String>, String> {
build_agent_args_with_tool_policy(req, ClaudeAgentToolPolicy::compat(req.permission_mode))
}
fn build_agent_args_with_tool_policy(
req: &AgentStreamRequest,
policy: ClaudeAgentToolPolicy,
) -> Result<Vec<String>, String> {
let mcp_config = build_mcp_config(&req.vault_path)?;
let mut args: Vec<String> = vec![
@@ -276,16 +342,24 @@ fn build_agent_args(req: &AgentStreamRequest) -> Result<Vec<String>, String> {
"--strict-mcp-config".into(),
"--permission-mode".into(),
"acceptEdits".into(),
"--tools".into(),
agent_tools(req.permission_mode).into(),
"--no-session-persistence".into(),
policy.tool_flag.into(),
policy.tool_value.into(),
];
if let Some(allowed_tools) = preapproved_agent_tools(req.permission_mode) {
if policy.include_session_persistence_flag {
args.push("--no-session-persistence".into());
}
if let Some(allowed_tools) = policy.preapproved_tools {
args.push("--allowedTools".into());
args.push(allowed_tools.into());
}
if let Some(disallowed_tools) = policy.disallowed_tools {
args.push("--disallowedTools".into());
args.push(disallowed_tools.into());
}
if let Some(ref sp) = req.system_prompt {
if !sp.is_empty() {
args.push("--append-system-prompt".into());
@@ -296,10 +370,47 @@ fn build_agent_args(req: &AgentStreamRequest) -> Result<Vec<String>, String> {
Ok(args)
}
struct ClaudeAgentToolPolicy {
tool_flag: &'static str,
tool_value: &'static str,
include_session_persistence_flag: bool,
preapproved_tools: Option<&'static str>,
disallowed_tools: Option<&'static str>,
}
impl ClaudeAgentToolPolicy {
fn strict(permission_mode: AiAgentPermissionMode) -> Self {
Self {
tool_flag: "--tools",
tool_value: agent_tools(permission_mode),
include_session_persistence_flag: true,
preapproved_tools: preapproved_agent_tools(permission_mode),
disallowed_tools: None,
}
}
fn strict_without_session_persistence(permission_mode: AiAgentPermissionMode) -> Self {
Self {
include_session_persistence_flag: false,
..Self::strict(permission_mode)
}
}
fn compat(permission_mode: AiAgentPermissionMode) -> Self {
Self {
tool_flag: "--allowedTools",
tool_value: agent_tools(permission_mode),
include_session_persistence_flag: false,
preapproved_tools: None,
disallowed_tools: Some(disallowed_agent_tools_compat(permission_mode)),
}
}
}
fn agent_tools(permission_mode: AiAgentPermissionMode) -> &'static str {
match permission_mode {
AiAgentPermissionMode::Safe => "Read,Edit,MultiEdit,Write,Glob,Grep,LS",
AiAgentPermissionMode::PowerUser => "Read,Edit,MultiEdit,Write,Glob,Grep,LS,Bash",
AiAgentPermissionMode::Safe => CLAUDE_SAFE_AGENT_TOOLS,
AiAgentPermissionMode::PowerUser => CLAUDE_POWER_USER_AGENT_TOOLS,
}
}
@@ -310,6 +421,13 @@ fn preapproved_agent_tools(permission_mode: AiAgentPermissionMode) -> Option<&'s
}
}
fn disallowed_agent_tools_compat(permission_mode: AiAgentPermissionMode) -> &'static str {
match permission_mode {
AiAgentPermissionMode::Safe => CLAUDE_SAFE_DISALLOWED_TOOLS_COMPAT,
AiAgentPermissionMode::PowerUser => CLAUDE_POWER_USER_DISALLOWED_TOOLS_COMPAT,
}
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
@@ -340,45 +458,62 @@ struct StreamState {
emitted_text: bool,
}
struct ClaudeSubprocessRequest<'a> {
bin: &'a PathBuf,
args: &'a [String],
fallback_args: &'a [Vec<String>],
cwd: Option<&'a str>,
}
/// Core subprocess runner shared by chat and agent modes.
/// When `cwd` is `Some`, the subprocess starts with that working directory.
fn run_claude_subprocess<F>(
bin: &PathBuf,
args: &[String],
cwd: Option<&str>,
request: ClaudeSubprocessRequest<'_>,
emit: &mut F,
) -> Result<String, String>
where
F: FnMut(ClaudeStreamEvent),
{
let mut state = StreamState {
session_id: String::new(),
tool_inputs: HashMap::new(),
current_tool_id: None,
emitted_text: false,
};
let attempts = std::iter::once(request.args)
.chain(request.fallback_args.iter().map(Vec::as_slice))
.enumerate();
let cmd = build_claude_command(bin, args, cwd);
let run = crate::cli_agent_runtime::run_json_line_process(
cmd,
"claude",
emit,
|message| ClaudeStreamEvent::Error { message },
|json, emit, session_id| {
dispatch_event(json, &mut state, emit);
*session_id = state.session_id.clone();
},
)?;
for (index, attempt_args) in attempts {
let mut state = StreamState {
session_id: String::new(),
tool_inputs: HashMap::new(),
current_tool_id: None,
emitted_text: false,
};
if !run.status.success() && state.session_id.is_empty() {
emit(ClaudeStreamEvent::Error {
message: format_failed_claude_exit(&run.stderr_output, run.status),
});
let cmd = build_claude_command(request.bin, attempt_args, request.cwd);
let run = crate::cli_agent_runtime::run_json_line_process(
cmd,
"claude",
emit,
|message| ClaudeStreamEvent::Error { message },
|json, emit, session_id| {
dispatch_event(json, &mut state, emit);
*session_id = state.session_id.clone();
},
)?;
if !run.status.success() && state.session_id.is_empty() {
let has_fallback = index < request.fallback_args.len();
if has_fallback && is_unsupported_claude_flag_error(&run.stderr_output) {
continue;
}
emit(ClaudeStreamEvent::Error {
message: format_failed_claude_exit(&run.stderr_output, run.status),
});
}
emit(ClaudeStreamEvent::Done);
return Ok(state.session_id);
}
emit(ClaudeStreamEvent::Done);
Ok(state.session_id)
Ok(String::new())
}
fn build_claude_command(
@@ -418,6 +553,24 @@ fn is_claude_auth_error(stderr_output: &str) -> bool {
.any(|pattern| lower.contains(pattern))
}
fn is_unsupported_claude_flag_error(stderr_output: &str) -> bool {
let lower = stderr_output.to_ascii_lowercase();
let mentions_compat_flag = ["--tools", "--no-session-persistence"]
.iter()
.any(|flag| lower.contains(flag));
let looks_unsupported = [
"unknown option",
"unknown argument",
"unrecognized option",
"unexpected argument",
"unknown command-line option",
]
.iter()
.any(|pattern| lower.contains(pattern));
mentions_compat_flag && looks_unsupported
}
/// Parse a single JSON line from the stream and emit the appropriate event.
fn dispatch_event<F>(json: &serde_json::Value, state: &mut StreamState, emit: &mut F)
where
@@ -805,6 +958,60 @@ mod tests {
assert_eq!(arg_value_after(&args, "--allowedTools"), Some("Bash"));
}
#[test]
fn chat_args_compat_disallows_builtin_tools_when_tools_flag_is_unavailable() {
let args = build_chat_args_compat(&chat_request!("hello", None, None));
assert_args_contain!(args, ["--disallowedTools"]);
assert_args_lack!(args, ["--tools"]);
assert!(arg_value_after(&args, "--disallowedTools")
.is_some_and(|tools| tools.contains("NotebookEdit")));
}
#[test]
fn agent_args_compat_uses_allowed_and_disallowed_tools_without_removed_flags() {
let args = build_agent_args_compat(&agent_request!(
"Rename the note",
None,
AiAgentPermissionMode::Safe,
))
.unwrap();
assert_eq!(
arg_value_after(&args, "--allowedTools"),
Some("Read,Edit,MultiEdit,Write,Glob,Grep,LS")
);
assert_args_contain!(args, ["--disallowedTools"]);
assert_args_lack!(args, ["--tools"]);
assert_args_lack!(args, ["--no-session-persistence"]);
}
#[test]
fn agent_args_without_session_persistence_keeps_tools_allowlist() {
let args = build_agent_args_without_session_persistence(&agent_request!(
"Rename the note",
None,
AiAgentPermissionMode::Safe,
))
.unwrap();
assert_args_contain!(args, ["--tools", "Read,Edit,MultiEdit,Write,Glob,Grep,LS"]);
assert_args_lack!(args, ["--no-session-persistence"]);
}
#[test]
fn unsupported_claude_flag_errors_are_detected() {
assert!(is_unsupported_claude_flag_error(
"error: unknown option '--tools'"
));
assert!(is_unsupported_claude_flag_error(
"Unknown argument: --no-session-persistence"
));
assert!(!is_unsupported_claude_flag_error(
"Claude CLI is not authenticated"
));
}
#[test]
fn build_mcp_config_is_valid_json() {
if let Ok(config_str) = build_mcp_config("/tmp/test-vault") {
@@ -1176,7 +1383,15 @@ mod tests {
std::fs::write(&path, script).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
let mut events = vec![];
let result = run_claude_subprocess(&path, args, None, &mut |e| events.push(e));
let result = run_claude_subprocess(
ClaudeSubprocessRequest {
bin: &path,
args,
fallback_args: &[],
cwd: None,
},
&mut |e| events.push(e),
);
(result, events)
}
@@ -1196,6 +1411,54 @@ mod tests {
assert!(matches!(&events[3], ClaudeStreamEvent::Done));
}
#[cfg(unix)]
#[test]
fn run_subprocess_retries_with_fallback_args_for_removed_flags() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mock-claude");
std::fs::write(
&path,
concat!(
"#!/bin/sh\n",
"for arg in \"$@\"; do\n",
" if [ \"$arg\" = \"--tools\" ]; then\n",
" echo \"error: unknown option '--tools'\" >&2\n",
" exit 1\n",
" fi\n",
"done\n",
"echo '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"fallback\"}'\n",
"echo '{\"type\":\"result\",\"result\":\"Done\",\"session_id\":\"fallback\"}'\n",
),
)
.unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
let args = vec!["--tools".to_string(), "Read".to_string()];
let fallback_args = vec![vec!["--allowedTools".to_string(), "Read".to_string()]];
let mut events = vec![];
let result = run_claude_subprocess(
ClaudeSubprocessRequest {
bin: &path,
args: &args,
fallback_args: &fallback_args,
cwd: None,
},
&mut |event| events.push(event),
);
assert_eq!(result.unwrap(), "fallback");
assert!(matches!(
events.first(),
Some(ClaudeStreamEvent::Init { session_id }) if session_id == "fallback"
));
assert!(!events
.iter()
.any(|event| matches!(event, ClaudeStreamEvent::Error { .. })));
assert!(matches!(events.last(), Some(ClaudeStreamEvent::Done)));
}
#[test]
fn run_subprocess_closes_stdin_even_when_parent_stdin_pipe_is_open() {
use std::io::Read;
@@ -1255,7 +1518,15 @@ mod tests {
];
std::env::set_var("TOLARIA_STDIN_PROBE_MOCK_CLAUDE_CHILD", "1");
let mut events = vec![];
let result = run_claude_subprocess(&fake_bin, &args, None, &mut |event| events.push(event));
let result = run_claude_subprocess(
ClaudeSubprocessRequest {
bin: &fake_bin,
args: &args,
fallback_args: &[],
cwd: None,
},
&mut |event| events.push(event),
);
std::env::remove_var("TOLARIA_STDIN_PROBE_MOCK_CLAUDE_CHILD");
assert_eq!(result.unwrap(), "stdin-ok");
@@ -1542,7 +1813,15 @@ mod tests {
fn run_subprocess_spawn_failure() {
let fake_bin = PathBuf::from("/nonexistent/binary/path");
let mut events = vec![];
let result = run_claude_subprocess(&fake_bin, &[], None, &mut |e| events.push(e));
let result = run_claude_subprocess(
ClaudeSubprocessRequest {
bin: &fake_bin,
args: &[],
fallback_args: &[],
cwd: None,
},
&mut |e| events.push(e),
);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Failed to start claude"));
}

View File

@@ -20,6 +20,7 @@ pub(crate) fn build_command(
.arg("--prompt")
.arg(build_prompt(request))
.env("GEMINI_CLI_SYSTEM_SETTINGS_PATH", settings_path)
.env("GEMINI_CLI_TRUST_WORKSPACE", "true")
.env("NO_COLOR", "1")
.current_dir(&request.vault_path)
.stdin(Stdio::null())
@@ -125,6 +126,11 @@ mod tests {
assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault")));
assert!(settings_path.is_some());
assert!(settings_dir.path().join("settings.json").exists());
let trust_workspace = command
.get_envs()
.find(|(key, _)| *key == OsStr::new("GEMINI_CLI_TRUST_WORKSPACE"))
.and_then(|(_, value)| value);
assert_eq!(trust_workspace, Some(OsStr::new("true")));
}
#[test]