generated from nhcarrigan/template
feat: CLI v2.1.81–v2.1.104 support (#261)
## Summary Implements support for all Claude Code CLI changes from v2.1.81 through v2.1.104, closing issues #253–#260. ### Changes - **#253** — New `CwdChanged` hook event: parse and emit `claude:cwd-changed` Tauri event; new `CwdChangedEvent` type - **#254** — New `FileChanged` hook event: parse and emit `claude:file-changed` Tauri event; new `FileChangedEvent` type - **#255** — Idle-return prompt: TUI-only feature, not present in `--output-format stream-json` mode — closed as not applicable - **#256** — New `TaskCreated` and `PermissionDenied` hook events: parse and emit `claude:task-created` / `claude:permission-denied` Tauri events; `PermissionDenied` also triggers `CharacterState::Permission` character animation - **#257** — Defer permission request: no PreToolUse hook response mechanism in Hikari Desktop — closed as not applicable - **#258** — `Monitor` tool: added `"Monitor"` to `SEARCH_TOOLS` constant so it maps to `CharacterState::Searching` - **#259** — `disableSkillShellExecution` setting: wired through `ClaudeStartOptions`, `HikariConfig`, `--settings` JSON, TypeScript interface, and exposed in the Config Sidebar UI - **#260** — Updated `SUPPORTED_CLI_VERSION` constant from `"2.1.80"` to `"2.1.104"` All changes pass `check-all.sh` (ESLint → Prettier → svelte-check → Vitest → Clippy → cargo test with llvm-cov). ✨ This PR was created with help from Hikari~ 🌸 Reviewed-on: #261 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #261.
This commit is contained in:
@@ -54,6 +54,9 @@ pub struct ClaudeStartOptions {
|
|||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub session_name: Option<String>,
|
pub session_name: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub disable_skill_shell_execution: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -203,6 +206,11 @@ pub struct HikariConfig {
|
|||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub model_overrides: Option<HashMap<String, String>>,
|
pub model_overrides: Option<HashMap<String, String>>,
|
||||||
|
|
||||||
|
/// Prevents skill scripts from executing shell commands (Claude Code v2.1.91+).
|
||||||
|
/// Passes `"disableSkillShellExecution": true` via the `--settings` flag.
|
||||||
|
#[serde(default)]
|
||||||
|
pub disable_skill_shell_execution: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for HikariConfig {
|
impl Default for HikariConfig {
|
||||||
@@ -254,6 +262,7 @@ impl Default for HikariConfig {
|
|||||||
enable_claudeai_mcp_servers: true,
|
enable_claudeai_mcp_servers: true,
|
||||||
auto_memory_directory: None,
|
auto_memory_directory: None,
|
||||||
model_overrides: None,
|
model_overrides: None,
|
||||||
|
disable_skill_shell_execution: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -405,6 +414,7 @@ mod tests {
|
|||||||
assert!(config.enable_claudeai_mcp_servers);
|
assert!(config.enable_claudeai_mcp_servers);
|
||||||
assert!(config.auto_memory_directory.is_none());
|
assert!(config.auto_memory_directory.is_none());
|
||||||
assert!(config.model_overrides.is_none());
|
assert!(config.model_overrides.is_none());
|
||||||
|
assert!(!config.disable_skill_shell_execution);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -459,6 +469,7 @@ mod tests {
|
|||||||
"claude-opus-4-6".to_string(),
|
"claude-opus-4-6".to_string(),
|
||||||
"arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-opus-4-6-v1".to_string(),
|
"arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-opus-4-6-v1".to_string(),
|
||||||
)])),
|
)])),
|
||||||
|
disable_skill_shell_execution: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&config).unwrap();
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
|
|||||||
@@ -318,6 +318,42 @@ pub struct PostCompactEvent {
|
|||||||
pub conversation_id: Option<String>,
|
pub conversation_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CwdChangedEvent {
|
||||||
|
pub cwd: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub conversation_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct FileChangedEvent {
|
||||||
|
pub file: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub conversation_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TaskCreatedEvent {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub task_id: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub parent_tool_use_id: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub conversation_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PermissionDeniedEvent {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tool_name: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub reason: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub conversation_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct AgentStartEvent {
|
pub struct AgentStartEvent {
|
||||||
pub tool_use_id: String,
|
pub tool_use_id: String,
|
||||||
@@ -741,4 +777,137 @@ mod tests {
|
|||||||
assert!(serialized.contains("\"session_id\":\"sess-xyz\""));
|
assert!(serialized.contains("\"session_id\":\"sess-xyz\""));
|
||||||
assert!(!serialized.contains("conversation_id"));
|
assert!(!serialized.contains("conversation_id"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cwd_changed_event_serialization() {
|
||||||
|
let event = CwdChangedEvent {
|
||||||
|
cwd: "/home/naomi/code/my-project".to_string(),
|
||||||
|
conversation_id: Some("conv-abc".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&event).unwrap();
|
||||||
|
assert!(serialized.contains("\"cwd\":\"/home/naomi/code/my-project\""));
|
||||||
|
assert!(serialized.contains("\"conversation_id\":\"conv-abc\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cwd_changed_event_omits_none_fields() {
|
||||||
|
let event = CwdChangedEvent {
|
||||||
|
cwd: "/tmp/workspace".to_string(),
|
||||||
|
conversation_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&event).unwrap();
|
||||||
|
assert!(serialized.contains("\"cwd\":\"/tmp/workspace\""));
|
||||||
|
assert!(!serialized.contains("conversation_id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_file_changed_event_serialization() {
|
||||||
|
let event = FileChangedEvent {
|
||||||
|
file: "/home/naomi/code/my-project/src/main.rs".to_string(),
|
||||||
|
conversation_id: Some("conv-abc".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&event).unwrap();
|
||||||
|
assert!(serialized.contains("\"file\":\"/home/naomi/code/my-project/src/main.rs\""));
|
||||||
|
assert!(serialized.contains("\"conversation_id\":\"conv-abc\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_file_changed_event_omits_none_fields() {
|
||||||
|
let event = FileChangedEvent {
|
||||||
|
file: "/tmp/test.txt".to_string(),
|
||||||
|
conversation_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&event).unwrap();
|
||||||
|
assert!(serialized.contains("\"file\":\"/tmp/test.txt\""));
|
||||||
|
assert!(!serialized.contains("conversation_id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_task_created_event_serialization() {
|
||||||
|
let event = TaskCreatedEvent {
|
||||||
|
task_id: Some("task-abc123".to_string()),
|
||||||
|
description: Some("Explore the codebase".to_string()),
|
||||||
|
parent_tool_use_id: Some("toolu_xyz".to_string()),
|
||||||
|
conversation_id: Some("conv-abc".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&event).unwrap();
|
||||||
|
assert!(serialized.contains("\"task_id\":\"task-abc123\""));
|
||||||
|
assert!(serialized.contains("\"description\":\"Explore the codebase\""));
|
||||||
|
assert!(serialized.contains("\"parent_tool_use_id\":\"toolu_xyz\""));
|
||||||
|
assert!(serialized.contains("\"conversation_id\":\"conv-abc\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_task_created_event_omits_none_fields() {
|
||||||
|
let event = TaskCreatedEvent {
|
||||||
|
task_id: None,
|
||||||
|
description: None,
|
||||||
|
parent_tool_use_id: None,
|
||||||
|
conversation_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&event).unwrap();
|
||||||
|
assert_eq!(serialized, "{}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_task_created_event_partial_fields() {
|
||||||
|
let event = TaskCreatedEvent {
|
||||||
|
task_id: Some("task-001".to_string()),
|
||||||
|
description: None,
|
||||||
|
parent_tool_use_id: None,
|
||||||
|
conversation_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&event).unwrap();
|
||||||
|
assert!(serialized.contains("\"task_id\":\"task-001\""));
|
||||||
|
assert!(!serialized.contains("description"));
|
||||||
|
assert!(!serialized.contains("parent_tool_use_id"));
|
||||||
|
assert!(!serialized.contains("conversation_id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_permission_denied_event_serialization() {
|
||||||
|
let event = PermissionDeniedEvent {
|
||||||
|
tool_name: Some("Bash".to_string()),
|
||||||
|
reason: Some("Tool not in allow list".to_string()),
|
||||||
|
conversation_id: Some("conv-abc".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&event).unwrap();
|
||||||
|
assert!(serialized.contains("\"tool_name\":\"Bash\""));
|
||||||
|
assert!(serialized.contains("\"reason\":\"Tool not in allow list\""));
|
||||||
|
assert!(serialized.contains("\"conversation_id\":\"conv-abc\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_permission_denied_event_omits_none_fields() {
|
||||||
|
let event = PermissionDeniedEvent {
|
||||||
|
tool_name: None,
|
||||||
|
reason: None,
|
||||||
|
conversation_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&event).unwrap();
|
||||||
|
assert_eq!(serialized, "{}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_permission_denied_event_partial_fields() {
|
||||||
|
let event = PermissionDeniedEvent {
|
||||||
|
tool_name: Some("Edit".to_string()),
|
||||||
|
reason: None,
|
||||||
|
conversation_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&event).unwrap();
|
||||||
|
assert!(serialized.contains("\"tool_name\":\"Edit\""));
|
||||||
|
assert!(!serialized.contains("reason"));
|
||||||
|
assert!(!serialized.contains("conversation_id"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+361
-7
@@ -15,10 +15,11 @@ use crate::process_ext::HideWindow;
|
|||||||
use crate::stats::{calculate_cost, StatsUpdateEvent, UsageStats};
|
use crate::stats::{calculate_cost, StatsUpdateEvent, UsageStats};
|
||||||
use crate::types::{
|
use crate::types::{
|
||||||
AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent,
|
AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent,
|
||||||
ConnectionStatus, ContentBlock, ElicitationEvent, ElicitationResultEvent, MessageCost,
|
ConnectionStatus, ContentBlock, CwdChangedEvent, ElicitationEvent, ElicitationResultEvent,
|
||||||
OutputEvent, PermissionPromptEvent, PermissionPromptEventItem, QuestionOption, SessionEvent,
|
FileChangedEvent, MessageCost, OutputEvent, PermissionDeniedEvent, PermissionPromptEvent,
|
||||||
PostCompactEvent, StateChangeEvent, StopFailureEvent, TodoItem, TodoUpdateEvent,
|
PermissionPromptEventItem, PostCompactEvent, QuestionOption, SessionEvent, StateChangeEvent,
|
||||||
UserQuestionEvent, WorkingDirectoryEvent, WorktreeEvent, WorktreeInfo,
|
StopFailureEvent, TaskCreatedEvent, TodoItem, TodoUpdateEvent, UserQuestionEvent,
|
||||||
|
WorkingDirectoryEvent, WorktreeEvent, WorktreeInfo,
|
||||||
};
|
};
|
||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
@@ -36,7 +37,10 @@ struct PendingToolUse {
|
|||||||
tool_input: serde_json::Value,
|
tool_input: serde_json::Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"];
|
// "Monitor" added in Claude Code v2.1.98 — it streams events and is observational in nature,
|
||||||
|
// so it maps to the Searching character state. It may appear as name "Monitor" in tool_use
|
||||||
|
// blocks (confirmed by CLI source inspection; it is a local_bash with kind="monitor" internally).
|
||||||
|
const SEARCH_TOOLS: [&str; 6] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch", "Monitor"];
|
||||||
const CODING_TOOLS: [&str; 3] = ["Edit", "Write", "NotebookEdit"];
|
const CODING_TOOLS: [&str; 3] = ["Edit", "Write", "NotebookEdit"];
|
||||||
|
|
||||||
fn detect_wsl() -> bool {
|
fn detect_wsl() -> bool {
|
||||||
@@ -312,7 +316,7 @@ impl WslBridge {
|
|||||||
.map(|m| !m.is_empty())
|
.map(|m| !m.is_empty())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
if has_memory_dir || has_overrides {
|
if has_memory_dir || has_overrides || options.disable_skill_shell_execution {
|
||||||
let mut settings = serde_json::Map::new();
|
let mut settings = serde_json::Map::new();
|
||||||
if let Some(ref dir) = options.auto_memory_directory {
|
if let Some(ref dir) = options.auto_memory_directory {
|
||||||
if !dir.is_empty() {
|
if !dir.is_empty() {
|
||||||
@@ -329,6 +333,12 @@ impl WslBridge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if options.disable_skill_shell_execution {
|
||||||
|
settings.insert(
|
||||||
|
"disableSkillShellExecution".to_string(),
|
||||||
|
serde_json::Value::Bool(true),
|
||||||
|
);
|
||||||
|
}
|
||||||
if let Ok(settings_json) = serde_json::to_string(&settings) {
|
if let Ok(settings_json) = serde_json::to_string(&settings) {
|
||||||
cmd.args(["--settings", &settings_json]);
|
cmd.args(["--settings", &settings_json]);
|
||||||
}
|
}
|
||||||
@@ -499,7 +509,7 @@ impl WslBridge {
|
|||||||
.map(|m| !m.is_empty())
|
.map(|m| !m.is_empty())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
if has_memory_dir || has_overrides {
|
if has_memory_dir || has_overrides || options.disable_skill_shell_execution {
|
||||||
let mut settings = serde_json::Map::new();
|
let mut settings = serde_json::Map::new();
|
||||||
if let Some(ref dir) = options.auto_memory_directory {
|
if let Some(ref dir) = options.auto_memory_directory {
|
||||||
if !dir.is_empty() {
|
if !dir.is_empty() {
|
||||||
@@ -516,6 +526,12 @@ impl WslBridge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if options.disable_skill_shell_execution {
|
||||||
|
settings.insert(
|
||||||
|
"disableSkillShellExecution".to_string(),
|
||||||
|
serde_json::Value::Bool(true),
|
||||||
|
);
|
||||||
|
}
|
||||||
if let Ok(settings_json) = serde_json::to_string(&settings) {
|
if let Ok(settings_json) = serde_json::to_string(&settings) {
|
||||||
let escaped = settings_json.replace('\'', "'\\''");
|
let escaped = settings_json.replace('\'', "'\\''");
|
||||||
claude_cmd.push_str(&format!(" --settings '{}'", escaped));
|
claude_cmd.push_str(&format!(" --settings '{}'", escaped));
|
||||||
@@ -1078,6 +1094,10 @@ fn handle_stderr(
|
|||||||
let is_elicitation_result = line.contains("[ElicitationResult Hook]");
|
let is_elicitation_result = line.contains("[ElicitationResult Hook]");
|
||||||
let is_stop_failure = line.contains("[StopFailure Hook]");
|
let is_stop_failure = line.contains("[StopFailure Hook]");
|
||||||
let is_post_compact = line.contains("[PostCompact Hook]");
|
let is_post_compact = line.contains("[PostCompact Hook]");
|
||||||
|
let is_cwd_changed = line.contains("[CwdChanged Hook]");
|
||||||
|
let is_file_changed = line.contains("[FileChanged Hook]");
|
||||||
|
let is_task_created = line.contains("[TaskCreated Hook]");
|
||||||
|
let is_permission_denied = line.contains("[PermissionDenied Hook]");
|
||||||
|
|
||||||
let line_type = if is_worktree_create || is_worktree_remove {
|
let line_type = if is_worktree_create || is_worktree_remove {
|
||||||
"worktree"
|
"worktree"
|
||||||
@@ -1089,6 +1109,14 @@ fn handle_stderr(
|
|||||||
"error"
|
"error"
|
||||||
} else if is_post_compact {
|
} else if is_post_compact {
|
||||||
"compact-prompt"
|
"compact-prompt"
|
||||||
|
} else if is_cwd_changed {
|
||||||
|
"cwd-changed"
|
||||||
|
} else if is_file_changed {
|
||||||
|
"file-changed"
|
||||||
|
} else if is_task_created {
|
||||||
|
"task-created"
|
||||||
|
} else if is_permission_denied {
|
||||||
|
"permission-denied"
|
||||||
} else {
|
} else {
|
||||||
"error"
|
"error"
|
||||||
};
|
};
|
||||||
@@ -1227,6 +1255,124 @@ fn handle_stderr(
|
|||||||
parent_tool_use_id: None,
|
parent_tool_use_id: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
} else if is_cwd_changed {
|
||||||
|
let data = parse_cwd_changed_hook(&line);
|
||||||
|
let friendly_content =
|
||||||
|
format!("Working directory changed to: {}", data.cwd);
|
||||||
|
|
||||||
|
let _ = app.emit(
|
||||||
|
"claude:cwd-changed",
|
||||||
|
CwdChangedEvent {
|
||||||
|
cwd: data.cwd,
|
||||||
|
conversation_id: conversation_id.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = app.emit(
|
||||||
|
"claude:output",
|
||||||
|
OutputEvent {
|
||||||
|
line_type: "cwd-changed".to_string(),
|
||||||
|
content: friendly_content,
|
||||||
|
tool_name: None,
|
||||||
|
conversation_id: conversation_id.clone(),
|
||||||
|
cost: None,
|
||||||
|
parent_tool_use_id: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else if is_file_changed {
|
||||||
|
let data = parse_file_changed_hook(&line);
|
||||||
|
let friendly_content = if data.file.is_empty() {
|
||||||
|
"File changed".to_string()
|
||||||
|
} else {
|
||||||
|
format!("File changed: {}", data.file)
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = app.emit(
|
||||||
|
"claude:file-changed",
|
||||||
|
FileChangedEvent {
|
||||||
|
file: data.file,
|
||||||
|
conversation_id: conversation_id.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = app.emit(
|
||||||
|
"claude:output",
|
||||||
|
OutputEvent {
|
||||||
|
line_type: "file-changed".to_string(),
|
||||||
|
content: friendly_content,
|
||||||
|
tool_name: None,
|
||||||
|
conversation_id: conversation_id.clone(),
|
||||||
|
cost: None,
|
||||||
|
parent_tool_use_id: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else if is_task_created {
|
||||||
|
let data = parse_task_created_hook(&line);
|
||||||
|
let friendly_content = match data.description.as_deref() {
|
||||||
|
Some(desc) if !desc.is_empty() => format!("Task created: {}", desc),
|
||||||
|
_ => "Task created".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = app.emit(
|
||||||
|
"claude:task-created",
|
||||||
|
TaskCreatedEvent {
|
||||||
|
task_id: data.task_id,
|
||||||
|
description: data.description,
|
||||||
|
parent_tool_use_id: data.parent_tool_use_id,
|
||||||
|
conversation_id: conversation_id.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = app.emit(
|
||||||
|
"claude:output",
|
||||||
|
OutputEvent {
|
||||||
|
line_type: "task-created".to_string(),
|
||||||
|
content: friendly_content,
|
||||||
|
tool_name: None,
|
||||||
|
conversation_id: conversation_id.clone(),
|
||||||
|
cost: None,
|
||||||
|
parent_tool_use_id: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else if is_permission_denied {
|
||||||
|
let data = parse_permission_denied_hook(&line);
|
||||||
|
let friendly_content = match (data.tool_name.as_deref(), data.reason.as_deref()) {
|
||||||
|
(Some(tool), Some(reason)) => {
|
||||||
|
format!("Permission denied for {}: {}", tool, reason)
|
||||||
|
}
|
||||||
|
(Some(tool), None) => format!("Permission denied for {}", tool),
|
||||||
|
_ => "Permission denied".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = app.emit(
|
||||||
|
"claude:state-change",
|
||||||
|
StateChangeEvent {
|
||||||
|
state: CharacterState::Permission,
|
||||||
|
tool_name: data.tool_name.clone(),
|
||||||
|
conversation_id: conversation_id.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = app.emit(
|
||||||
|
"claude:permission-denied",
|
||||||
|
PermissionDeniedEvent {
|
||||||
|
tool_name: data.tool_name,
|
||||||
|
reason: data.reason,
|
||||||
|
conversation_id: conversation_id.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = app.emit(
|
||||||
|
"claude:output",
|
||||||
|
OutputEvent {
|
||||||
|
line_type: "permission-denied".to_string(),
|
||||||
|
content: friendly_content,
|
||||||
|
tool_name: None,
|
||||||
|
conversation_id: conversation_id.clone(),
|
||||||
|
cost: None,
|
||||||
|
parent_tool_use_id: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
let _ = app.emit(
|
let _ = app.emit(
|
||||||
"claude:output",
|
"claude:output",
|
||||||
@@ -1440,6 +1586,85 @@ fn parse_post_compact_hook(line: &str) -> PostCompactData {
|
|||||||
PostCompactData { session_id }
|
PostCompactData { session_id }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct CwdChangedData {
|
||||||
|
cwd: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_cwd_changed_hook(line: &str) -> CwdChangedData {
|
||||||
|
let cwd = extract_quoted_value(line, "cwd")
|
||||||
|
.or_else(|| extract_quoted_value(line, "path"))
|
||||||
|
.or_else(|| {
|
||||||
|
line.split("[CwdChanged Hook]")
|
||||||
|
.nth(1)
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
CwdChangedData { cwd }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct FileChangedData {
|
||||||
|
file: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_file_changed_hook(line: &str) -> FileChangedData {
|
||||||
|
let file = extract_quoted_value(line, "file")
|
||||||
|
.or_else(|| extract_quoted_value(line, "path"))
|
||||||
|
.or_else(|| {
|
||||||
|
line.split("[FileChanged Hook]")
|
||||||
|
.nth(1)
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
FileChangedData { file }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct TaskCreatedData {
|
||||||
|
task_id: Option<String>,
|
||||||
|
description: Option<String>,
|
||||||
|
parent_tool_use_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_task_created_hook(line: &str) -> TaskCreatedData {
|
||||||
|
let task_id = extract_debug_string_value(line, "task_id")
|
||||||
|
.or_else(|| extract_quoted_value(line, "task_id"));
|
||||||
|
|
||||||
|
let description = extract_quoted_value(line, "description")
|
||||||
|
.or_else(|| extract_quoted_value(line, "prompt"));
|
||||||
|
|
||||||
|
let parent_tool_use_id = if line.contains("parent_tool_use_id=Some") {
|
||||||
|
line.split("parent_tool_use_id=Some(\"")
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|s| s.split('"').next())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
TaskCreatedData { task_id, description, parent_tool_use_id }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct PermissionDeniedData {
|
||||||
|
tool_name: Option<String>,
|
||||||
|
reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_permission_denied_hook(line: &str) -> PermissionDeniedData {
|
||||||
|
let tool_name = extract_quoted_value(line, "tool_name")
|
||||||
|
.or_else(|| extract_quoted_value(line, "tool"))
|
||||||
|
.or_else(|| extract_debug_string_value(line, "tool_name"));
|
||||||
|
|
||||||
|
let reason = extract_quoted_value(line, "reason")
|
||||||
|
.or_else(|| extract_quoted_value(line, "message"));
|
||||||
|
|
||||||
|
PermissionDeniedData { tool_name, reason }
|
||||||
|
}
|
||||||
|
|
||||||
/// Extracts a double-quoted string value from a `key="value"` pair in a hook line.
|
/// Extracts a double-quoted string value from a `key="value"` pair in a hook line.
|
||||||
/// Handles escape sequences within the quoted value.
|
/// Handles escape sequences within the quoted value.
|
||||||
fn extract_quoted_value(line: &str, key: &str) -> Option<String> {
|
fn extract_quoted_value(line: &str, key: &str) -> Option<String> {
|
||||||
@@ -2546,6 +2771,11 @@ mod tests {
|
|||||||
get_tool_state("WebFetch"),
|
get_tool_state("WebFetch"),
|
||||||
CharacterState::Searching
|
CharacterState::Searching
|
||||||
));
|
));
|
||||||
|
// Monitor tool added in v2.1.98 — observational/streaming, maps to Searching
|
||||||
|
assert!(matches!(
|
||||||
|
get_tool_state("Monitor"),
|
||||||
|
CharacterState::Searching
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -3588,6 +3818,130 @@ mod tests {
|
|||||||
assert_eq!(data.session_id, None);
|
assert_eq!(data.session_id, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_cwd_changed_hook_with_cwd_key() {
|
||||||
|
let line = r#"[CwdChanged Hook] cwd="/home/naomi/code/my-project""#;
|
||||||
|
let data = parse_cwd_changed_hook(line);
|
||||||
|
assert_eq!(data.cwd, "/home/naomi/code/my-project");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_cwd_changed_hook_with_path_key() {
|
||||||
|
let line = r#"[CwdChanged Hook] path="/tmp/workspace""#;
|
||||||
|
let data = parse_cwd_changed_hook(line);
|
||||||
|
assert_eq!(data.cwd, "/tmp/workspace");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_cwd_changed_hook_bare_path_fallback() {
|
||||||
|
let line = "[CwdChanged Hook] /home/naomi/code/project";
|
||||||
|
let data = parse_cwd_changed_hook(line);
|
||||||
|
assert_eq!(data.cwd, "/home/naomi/code/project");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_cwd_changed_hook_empty_line() {
|
||||||
|
let line = "[CwdChanged Hook]";
|
||||||
|
let data = parse_cwd_changed_hook(line);
|
||||||
|
assert_eq!(data.cwd, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_file_changed_hook_with_file_key() {
|
||||||
|
let line = r#"[FileChanged Hook] file="/home/naomi/code/project/src/main.rs""#;
|
||||||
|
let data = parse_file_changed_hook(line);
|
||||||
|
assert_eq!(data.file, "/home/naomi/code/project/src/main.rs");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_file_changed_hook_with_path_key() {
|
||||||
|
let line = r#"[FileChanged Hook] path="/tmp/test.txt""#;
|
||||||
|
let data = parse_file_changed_hook(line);
|
||||||
|
assert_eq!(data.file, "/tmp/test.txt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_file_changed_hook_bare_path_fallback() {
|
||||||
|
let line = "[FileChanged Hook] /home/naomi/code/project/README.md";
|
||||||
|
let data = parse_file_changed_hook(line);
|
||||||
|
assert_eq!(data.file, "/home/naomi/code/project/README.md");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_file_changed_hook_empty_line() {
|
||||||
|
let line = "[FileChanged Hook]";
|
||||||
|
let data = parse_file_changed_hook(line);
|
||||||
|
assert_eq!(data.file, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_task_created_hook_with_all_fields() {
|
||||||
|
let line = r#"[TaskCreated Hook] task_id=Some("task-abc123"), description="Explore the codebase", parent_tool_use_id=Some("toolu_xyz")"#;
|
||||||
|
let data = parse_task_created_hook(line);
|
||||||
|
assert_eq!(data.task_id, Some("task-abc123".to_string()));
|
||||||
|
assert_eq!(data.description, Some("Explore the codebase".to_string()));
|
||||||
|
assert_eq!(data.parent_tool_use_id, Some("toolu_xyz".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_task_created_hook_with_description_only() {
|
||||||
|
let line = r#"[TaskCreated Hook] description="Search for relevant files""#;
|
||||||
|
let data = parse_task_created_hook(line);
|
||||||
|
assert_eq!(data.description, Some("Search for relevant files".to_string()));
|
||||||
|
assert_eq!(data.task_id, None);
|
||||||
|
assert_eq!(data.parent_tool_use_id, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_task_created_hook_no_parent() {
|
||||||
|
let line = r#"[TaskCreated Hook] task_id=Some("task-001"), description="Run tests""#;
|
||||||
|
let data = parse_task_created_hook(line);
|
||||||
|
assert_eq!(data.task_id, Some("task-001".to_string()));
|
||||||
|
assert_eq!(data.description, Some("Run tests".to_string()));
|
||||||
|
assert_eq!(data.parent_tool_use_id, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_task_created_hook_empty_line() {
|
||||||
|
let line = "[TaskCreated Hook]";
|
||||||
|
let data = parse_task_created_hook(line);
|
||||||
|
assert_eq!(data.task_id, None);
|
||||||
|
assert_eq!(data.description, None);
|
||||||
|
assert_eq!(data.parent_tool_use_id, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_permission_denied_hook_with_all_fields() {
|
||||||
|
let line = r#"[PermissionDenied Hook] tool_name="Bash", reason="Tool not in allow list""#;
|
||||||
|
let data = parse_permission_denied_hook(line);
|
||||||
|
assert_eq!(data.tool_name, Some("Bash".to_string()));
|
||||||
|
assert_eq!(data.reason, Some("Tool not in allow list".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_permission_denied_hook_tool_only() {
|
||||||
|
let line = r#"[PermissionDenied Hook] tool_name="Edit""#;
|
||||||
|
let data = parse_permission_denied_hook(line);
|
||||||
|
assert_eq!(data.tool_name, Some("Edit".to_string()));
|
||||||
|
assert_eq!(data.reason, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_permission_denied_hook_tool_key_alias() {
|
||||||
|
let line = r#"[PermissionDenied Hook] tool="Write", reason="Workspace not trusted""#;
|
||||||
|
let data = parse_permission_denied_hook(line);
|
||||||
|
assert_eq!(data.tool_name, Some("Write".to_string()));
|
||||||
|
assert_eq!(data.reason, Some("Workspace not trusted".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_permission_denied_hook_empty_line() {
|
||||||
|
let line = "[PermissionDenied Hook]";
|
||||||
|
let data = parse_permission_denied_hook(line);
|
||||||
|
assert_eq!(data.tool_name, None);
|
||||||
|
assert_eq!(data.reason, None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_build_stop_failure_message_no_fields() {
|
fn test_build_stop_failure_message_no_fields() {
|
||||||
let data = StopFailureData {
|
let data = StopFailureData {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
const SUPPORTED_CLI_VERSION = "2.1.80";
|
const SUPPORTED_CLI_VERSION = "2.1.104";
|
||||||
|
|
||||||
let installedVersion = $state("Loading...");
|
let installedVersion = $state("Loading...");
|
||||||
let latestNpmVersion = $state<string | null>(null);
|
let latestNpmVersion = $state<string | null>(null);
|
||||||
|
|||||||
@@ -63,6 +63,7 @@
|
|||||||
enable_claudeai_mcp_servers: true,
|
enable_claudeai_mcp_servers: true,
|
||||||
auto_memory_directory: null,
|
auto_memory_directory: null,
|
||||||
model_overrides: null,
|
model_overrides: null,
|
||||||
|
disable_skill_shell_execution: false,
|
||||||
max_output_tokens: null,
|
max_output_tokens: null,
|
||||||
trusted_workspaces: [],
|
trusted_workspaces: [],
|
||||||
background_image_path: null,
|
background_image_path: null,
|
||||||
@@ -585,6 +586,22 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Disable Skill Shell Execution -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={config.disable_skill_shell_execution}
|
||||||
|
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-[var(--text-primary)]">Disable skill shell execution</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
||||||
|
Passes <code class="font-mono">disableSkillShellExecution: true</code> to prevent skill scripts
|
||||||
|
from executing shell commands (requires Claude Code v2.1.91+)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Include Git Instructions -->
|
<!-- Include Git Instructions -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="flex items-center gap-3 cursor-pointer">
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
|||||||
@@ -93,6 +93,7 @@
|
|||||||
enable_claudeai_mcp_servers: true,
|
enable_claudeai_mcp_servers: true,
|
||||||
auto_memory_directory: null,
|
auto_memory_directory: null,
|
||||||
model_overrides: null,
|
model_overrides: null,
|
||||||
|
disable_skill_shell_execution: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
let streamerModeActive = $state(false);
|
let streamerModeActive = $state(false);
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ describe("config store", () => {
|
|||||||
enable_claudeai_mcp_servers: true,
|
enable_claudeai_mcp_servers: true,
|
||||||
auto_memory_directory: null,
|
auto_memory_directory: null,
|
||||||
model_overrides: null,
|
model_overrides: null,
|
||||||
|
disable_skill_shell_execution: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(config.model).toBe("claude-sonnet-4");
|
expect(config.model).toBe("claude-sonnet-4");
|
||||||
@@ -289,6 +290,7 @@ describe("config store", () => {
|
|||||||
enable_claudeai_mcp_servers: true,
|
enable_claudeai_mcp_servers: true,
|
||||||
auto_memory_directory: null,
|
auto_memory_directory: null,
|
||||||
model_overrides: null,
|
model_overrides: null,
|
||||||
|
disable_skill_shell_execution: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(config.model).toBeNull();
|
expect(config.model).toBeNull();
|
||||||
@@ -908,6 +910,7 @@ describe("config store", () => {
|
|||||||
enable_claudeai_mcp_servers: true,
|
enable_claudeai_mcp_servers: true,
|
||||||
auto_memory_directory: null,
|
auto_memory_directory: null,
|
||||||
model_overrides: null,
|
model_overrides: null,
|
||||||
|
disable_skill_shell_execution: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockInvokeImpl = vi.mocked(invoke);
|
const mockInvokeImpl = vi.mocked(invoke);
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ export interface HikariConfig {
|
|||||||
auto_memory_directory: string | null;
|
auto_memory_directory: string | null;
|
||||||
// Model overrides for provider-specific model IDs (AWS Bedrock, Google Vertex, etc.)
|
// Model overrides for provider-specific model IDs (AWS Bedrock, Google Vertex, etc.)
|
||||||
model_overrides: Record<string, string> | null;
|
model_overrides: Record<string, string> | null;
|
||||||
|
// Prevents skill scripts from executing shell commands (Claude Code v2.1.91+)
|
||||||
|
disable_skill_shell_execution: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultConfig: HikariConfig = {
|
const defaultConfig: HikariConfig = {
|
||||||
@@ -149,6 +151,7 @@ const defaultConfig: HikariConfig = {
|
|||||||
enable_claudeai_mcp_servers: true,
|
enable_claudeai_mcp_servers: true,
|
||||||
auto_memory_directory: null,
|
auto_memory_directory: null,
|
||||||
model_overrides: null,
|
model_overrides: null,
|
||||||
|
disable_skill_shell_execution: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
function createConfigStore() {
|
function createConfigStore() {
|
||||||
|
|||||||
Reference in New Issue
Block a user