diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index 22bd852..c12e5a4 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -504,6 +504,26 @@ pub fn mvp_tool_specs() -> Vec { }), required_permission: PermissionMode::WorkspaceWrite, }, + ToolSpec { + name: "EnterPlanMode", + description: "Enable a worktree-local planning mode override and remember the previous local setting for ExitPlanMode.", + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + required_permission: PermissionMode::WorkspaceWrite, + }, + ToolSpec { + name: "ExitPlanMode", + description: "Restore or clear the worktree-local planning mode override created by EnterPlanMode.", + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + required_permission: PermissionMode::WorkspaceWrite, + }, ToolSpec { name: "StructuredOutput", description: "Return structured output in the requested format.", @@ -565,6 +585,8 @@ pub fn execute_tool(name: &str, input: &Value) -> Result { "Sleep" => from_value::(input).and_then(run_sleep), "SendUserMessage" | "Brief" => from_value::(input).and_then(run_brief), "Config" => from_value::(input).and_then(run_config), + "EnterPlanMode" => from_value::(input).and_then(run_enter_plan_mode), + "ExitPlanMode" => from_value::(input).and_then(run_exit_plan_mode), "StructuredOutput" => { from_value::(input).and_then(run_structured_output) } @@ -658,6 +680,14 @@ fn run_config(input: ConfigInput) -> Result { to_pretty_json(execute_config(input)?) } +fn run_enter_plan_mode(input: EnterPlanModeInput) -> Result { + to_pretty_json(execute_enter_plan_mode(input)?) +} + +fn run_exit_plan_mode(input: ExitPlanModeInput) -> Result { + to_pretty_json(execute_exit_plan_mode(input)?) +} + fn run_structured_output(input: StructuredOutputInput) -> Result { to_pretty_json(execute_structured_output(input)?) } @@ -810,6 +840,14 @@ struct ConfigInput { value: Option, } +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +struct EnterPlanModeInput {} + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +struct ExitPlanModeInput {} + #[derive(Debug, Deserialize)] #[serde(untagged)] enum ConfigValue { @@ -967,6 +1005,32 @@ struct ConfigOutput { error: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PlanModeState { + #[serde(rename = "hadLocalOverride")] + had_local_override: bool, + #[serde(rename = "previousLocalMode")] + previous_local_mode: Option, +} + +#[derive(Debug, Serialize)] +struct PlanModeOutput { + success: bool, + operation: String, + changed: bool, + active: bool, + managed: bool, + message: String, + #[serde(rename = "settingsPath")] + settings_path: String, + #[serde(rename = "statePath")] + state_path: String, + #[serde(rename = "previousLocalMode")] + previous_local_mode: Option, + #[serde(rename = "currentLocalMode")] + current_local_mode: Option, +} + #[derive(Debug, Serialize)] struct StructuredOutputResult { data: String, @@ -2575,6 +2639,148 @@ fn execute_config(input: ConfigInput) -> Result { } } +const PERMISSION_DEFAULT_MODE_PATH: &[&str] = &["permissions", "defaultMode"]; + +fn execute_enter_plan_mode(_input: EnterPlanModeInput) -> Result { + let settings_path = config_file_for_scope(ConfigScope::Settings)?; + let state_path = plan_mode_state_file()?; + let mut document = read_json_object(&settings_path)?; + let current_local_mode = get_nested_value(&document, PERMISSION_DEFAULT_MODE_PATH).cloned(); + let current_is_plan = + matches!(current_local_mode.as_ref(), Some(Value::String(value)) if value == "plan"); + + if let Some(state) = read_plan_mode_state(&state_path)? { + if current_is_plan { + return Ok(PlanModeOutput { + success: true, + operation: String::from("enter"), + changed: false, + active: true, + managed: true, + message: String::from("Plan mode override is already active for this worktree."), + settings_path: settings_path.display().to_string(), + state_path: state_path.display().to_string(), + previous_local_mode: state.previous_local_mode, + current_local_mode, + }); + } + clear_plan_mode_state(&state_path)?; + } + + if current_is_plan { + return Ok(PlanModeOutput { + success: true, + operation: String::from("enter"), + changed: false, + active: true, + managed: false, + message: String::from( + "Worktree-local plan mode is already enabled outside EnterPlanMode; leaving it unchanged.", + ), + settings_path: settings_path.display().to_string(), + state_path: state_path.display().to_string(), + previous_local_mode: None, + current_local_mode, + }); + } + + let state = PlanModeState { + had_local_override: current_local_mode.is_some(), + previous_local_mode: current_local_mode.clone(), + }; + write_plan_mode_state(&state_path, &state)?; + set_nested_value( + &mut document, + PERMISSION_DEFAULT_MODE_PATH, + Value::String(String::from("plan")), + ); + write_json_object(&settings_path, &document)?; + + Ok(PlanModeOutput { + success: true, + operation: String::from("enter"), + changed: true, + active: true, + managed: true, + message: String::from("Enabled worktree-local plan mode override."), + settings_path: settings_path.display().to_string(), + state_path: state_path.display().to_string(), + previous_local_mode: state.previous_local_mode, + current_local_mode: get_nested_value(&document, PERMISSION_DEFAULT_MODE_PATH).cloned(), + }) +} + +fn execute_exit_plan_mode(_input: ExitPlanModeInput) -> Result { + let settings_path = config_file_for_scope(ConfigScope::Settings)?; + let state_path = plan_mode_state_file()?; + let mut document = read_json_object(&settings_path)?; + let current_local_mode = get_nested_value(&document, PERMISSION_DEFAULT_MODE_PATH).cloned(); + let current_is_plan = + matches!(current_local_mode.as_ref(), Some(Value::String(value)) if value == "plan"); + + let Some(state) = read_plan_mode_state(&state_path)? else { + return Ok(PlanModeOutput { + success: true, + operation: String::from("exit"), + changed: false, + active: current_is_plan, + managed: false, + message: String::from("No EnterPlanMode override is active for this worktree."), + settings_path: settings_path.display().to_string(), + state_path: state_path.display().to_string(), + previous_local_mode: None, + current_local_mode, + }); + }; + + if !current_is_plan { + clear_plan_mode_state(&state_path)?; + return Ok(PlanModeOutput { + success: true, + operation: String::from("exit"), + changed: false, + active: false, + managed: false, + message: String::from( + "Cleared stale EnterPlanMode state because plan mode was already changed outside the tool.", + ), + settings_path: settings_path.display().to_string(), + state_path: state_path.display().to_string(), + previous_local_mode: state.previous_local_mode, + current_local_mode, + }); + } + + if state.had_local_override { + if let Some(previous_local_mode) = state.previous_local_mode.clone() { + set_nested_value( + &mut document, + PERMISSION_DEFAULT_MODE_PATH, + previous_local_mode, + ); + } else { + remove_nested_value(&mut document, PERMISSION_DEFAULT_MODE_PATH); + } + } else { + remove_nested_value(&mut document, PERMISSION_DEFAULT_MODE_PATH); + } + write_json_object(&settings_path, &document)?; + clear_plan_mode_state(&state_path)?; + + Ok(PlanModeOutput { + success: true, + operation: String::from("exit"), + changed: true, + active: false, + managed: false, + message: String::from("Restored the prior worktree-local plan mode setting."), + settings_path: settings_path.display().to_string(), + state_path: state_path.display().to_string(), + previous_local_mode: state.previous_local_mode, + current_local_mode: get_nested_value(&document, PERMISSION_DEFAULT_MODE_PATH).cloned(), + }) +} + fn execute_structured_output( input: StructuredOutputInput, ) -> Result { @@ -2902,6 +3108,72 @@ fn set_nested_value(root: &mut serde_json::Map, path: &[&str], ne set_nested_value(map, rest, new_value); } +fn remove_nested_value(root: &mut serde_json::Map, path: &[&str]) -> bool { + let Some((first, rest)) = path.split_first() else { + return false; + }; + if rest.is_empty() { + return root.remove(*first).is_some(); + } + + let mut should_remove_parent = false; + let removed = root.get_mut(*first).is_some_and(|entry| { + entry.as_object_mut().is_some_and(|map| { + let removed = remove_nested_value(map, rest); + should_remove_parent = removed && map.is_empty(); + removed + }) + }); + + if should_remove_parent { + root.remove(*first); + } + + removed +} + +fn plan_mode_state_file() -> Result { + Ok(config_file_for_scope(ConfigScope::Settings)? + .parent() + .ok_or_else(|| String::from("settings.local.json has no parent directory"))? + .join("tool-state") + .join("plan-mode.json")) +} + +fn read_plan_mode_state(path: &Path) -> Result, String> { + match std::fs::read_to_string(path) { + Ok(contents) => { + if contents.trim().is_empty() { + return Ok(None); + } + serde_json::from_str(&contents) + .map(Some) + .map_err(|error| error.to_string()) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error.to_string()), + } +} + +fn write_plan_mode_state(path: &Path, state: &PlanModeState) -> Result<(), String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + std::fs::write( + path, + serde_json::to_string_pretty(state).map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string()) +} + +fn clear_plan_mode_state(path: &Path) -> Result<(), String> { + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.to_string()), + } +} + fn iso8601_timestamp() -> String { if let Ok(output) = Command::new("date") .args(["-u", "+%Y-%m-%dT%H:%M:%SZ"]) @@ -3190,6 +3462,8 @@ mod tests { assert!(names.contains(&"Sleep")); assert!(names.contains(&"SendUserMessage")); assert!(names.contains(&"Config")); + assert!(names.contains(&"EnterPlanMode")); + assert!(names.contains(&"ExitPlanMode")); assert!(names.contains(&"StructuredOutput")); assert!(names.contains(&"REPL")); assert!(names.contains(&"PowerShell")); @@ -4385,6 +4659,140 @@ mod tests { let _ = std::fs::remove_dir_all(root); } + #[test] + fn enter_and_exit_plan_mode_round_trip_existing_local_override() { + let _guard = env_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let root = std::env::temp_dir().join(format!( + "clawd-plan-mode-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("time") + .as_nanos() + )); + let home = root.join("home"); + let cwd = root.join("cwd"); + std::fs::create_dir_all(home.join(".claw")).expect("home dir"); + std::fs::create_dir_all(cwd.join(".claw")).expect("cwd dir"); + std::fs::write( + cwd.join(".claw").join("settings.local.json"), + r#"{"permissions":{"defaultMode":"acceptEdits"}}"#, + ) + .expect("write local settings"); + + let original_home = std::env::var("HOME").ok(); + let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok(); + let original_dir = std::env::current_dir().expect("cwd"); + std::env::set_var("HOME", &home); + std::env::remove_var("CLAW_CONFIG_HOME"); + std::env::set_current_dir(&cwd).expect("set cwd"); + + let enter = execute_tool("EnterPlanMode", &json!({})).expect("enter plan mode"); + let enter_output: serde_json::Value = serde_json::from_str(&enter).expect("json"); + assert_eq!(enter_output["changed"], true); + assert_eq!(enter_output["managed"], true); + assert_eq!(enter_output["previousLocalMode"], "acceptEdits"); + assert_eq!(enter_output["currentLocalMode"], "plan"); + + let local_settings = std::fs::read_to_string(cwd.join(".claw").join("settings.local.json")) + .expect("local settings after enter"); + assert!(local_settings.contains(r#""defaultMode": "plan""#)); + let state = + std::fs::read_to_string(cwd.join(".claw").join("tool-state").join("plan-mode.json")) + .expect("plan mode state"); + assert!(state.contains(r#""hadLocalOverride": true"#)); + assert!(state.contains(r#""previousLocalMode": "acceptEdits""#)); + + let exit = execute_tool("ExitPlanMode", &json!({})).expect("exit plan mode"); + let exit_output: serde_json::Value = serde_json::from_str(&exit).expect("json"); + assert_eq!(exit_output["changed"], true); + assert_eq!(exit_output["managed"], false); + assert_eq!(exit_output["previousLocalMode"], "acceptEdits"); + assert_eq!(exit_output["currentLocalMode"], "acceptEdits"); + + let local_settings = std::fs::read_to_string(cwd.join(".claw").join("settings.local.json")) + .expect("local settings after exit"); + assert!(local_settings.contains(r#""defaultMode": "acceptEdits""#)); + assert!(!cwd + .join(".claw") + .join("tool-state") + .join("plan-mode.json") + .exists()); + + std::env::set_current_dir(&original_dir).expect("restore cwd"); + match original_home { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } + match original_config_home { + Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value), + None => std::env::remove_var("CLAW_CONFIG_HOME"), + } + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn exit_plan_mode_clears_override_when_enter_created_it_from_empty_local_state() { + let _guard = env_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let root = std::env::temp_dir().join(format!( + "clawd-plan-mode-empty-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("time") + .as_nanos() + )); + let home = root.join("home"); + let cwd = root.join("cwd"); + std::fs::create_dir_all(home.join(".claw")).expect("home dir"); + std::fs::create_dir_all(cwd.join(".claw")).expect("cwd dir"); + + let original_home = std::env::var("HOME").ok(); + let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok(); + let original_dir = std::env::current_dir().expect("cwd"); + std::env::set_var("HOME", &home); + std::env::remove_var("CLAW_CONFIG_HOME"); + std::env::set_current_dir(&cwd).expect("set cwd"); + + let enter = execute_tool("EnterPlanMode", &json!({})).expect("enter plan mode"); + let enter_output: serde_json::Value = serde_json::from_str(&enter).expect("json"); + assert_eq!(enter_output["previousLocalMode"], serde_json::Value::Null); + assert_eq!(enter_output["currentLocalMode"], "plan"); + + let exit = execute_tool("ExitPlanMode", &json!({})).expect("exit plan mode"); + let exit_output: serde_json::Value = serde_json::from_str(&exit).expect("json"); + assert_eq!(exit_output["changed"], true); + assert_eq!(exit_output["currentLocalMode"], serde_json::Value::Null); + + let local_settings = std::fs::read_to_string(cwd.join(".claw").join("settings.local.json")) + .expect("local settings after exit"); + let local_settings_json: serde_json::Value = + serde_json::from_str(&local_settings).expect("valid settings json"); + assert_eq!( + local_settings_json.get("permissions"), + None, + "permissions override should be removed on exit" + ); + assert!(!cwd + .join(".claw") + .join("tool-state") + .join("plan-mode.json") + .exists()); + + std::env::set_current_dir(&original_dir).expect("restore cwd"); + match original_home { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } + match original_config_home { + Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value), + None => std::env::remove_var("CLAW_CONFIG_HOME"), + } + let _ = std::fs::remove_dir_all(root); + } + #[test] fn structured_output_echoes_input_payload() { let result = execute_tool("StructuredOutput", &json!({"ok": true, "items": [1, 2, 3]}))