Skip to content

Commit 2eea101

Browse files
authored
fix: Claude Code stats not recording — stdout fallback + snake_case alias + doctor sandbox detection (#55)
<!-- AI-PR-DESCRIPTION-START --> ## PR Auto Describe ## Summary >This PR improves omni's diagnostic tools, cross-tool hook compatibility, and filesystem sandbox detection. It upgrades the `doctor` command to catch Claude Code-style filesystem restrictions via real write tests, adds support for Claude Code's tool response format, and unifies hook field naming between camelCase and snake_case. ## Key Changes - Enhanced `omni doctor` to validate actual filesystem/database write access instead of static permission checks, with actionable Claude Code sandbox warnings - Added cross-tool hook compatibility by supporting both camelCase and snake_case field names - Added support for Claude Code's stdout/stderr tool response format - Added a SQLite store writability test utility ## Detailed Breakdown - **`src/cli/doctor.rs`**: - Rewrote the ~/.omni config dir check: Replaced simplistic metadata permission tests with a real write test (creates + cleans up a temporary `.write_test` file) - Added a new database writability scan using the new `Store::test_write()` method, with tailored warnings for Claude Code users - Preserved existing fix-mode directory creation logic - **`src/hooks/dispatcher.rs` + `src/hooks/session_start.rs`**: Added serde deserialization aliases to support both camelCase (e.g. `hookEventName`) and snake_case (e.g. `hook_event_name`) field names - **`src/hooks/post_tool.rs`**: - Extended `ToolResponse` struct to accept `stdout` and `stderr` fields - Created `extract_tool_content()` that prioritizes structured `content` fields (used by Cursor/Windsurf) then falls back to concatenated stdout/stderr (Claude Code format) - Replaced legacy content extraction logic with this new handler - Added 4 new test cases covering Claude Code payloads, mixed stdout/stderr, empty output, and structured content field precedence - **`src/store/sqlite.rs`**: Added `test_write()` helper that validates database writability by creating and dropping a temporary test table, handling poisoned mutexes gracefully ## Notes All `omni doctor` warnings now include specific instructions for Claude Code users: add `~/.omni` to `sandbox.filesystem.allowWrite` in `~/.claude/settings.json` to resolve sandboxed write blocks. ## Breaking Changes None. All changes are backwards-compatible, with existing tooling and workflows unaffected. _Last updated: 2026-04-10 04:09:00_ <!-- AI-PR-DESCRIPTION-END -->
2 parents 9c96b06 + 994d8c6 commit 2eea101

5 files changed

Lines changed: 183 additions & 21 deletions

File tree

src/cli/doctor.rs

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -103,20 +103,34 @@ pub fn run(args: &[String]) -> anyhow::Result<()> {
103103

104104
println!(" {:<15} {}", "Binary:".bright_black(), version_info);
105105

106-
// 2. Config Dir
106+
// 2. Config Dir (with actual write test for sandbox detection)
107107
let conf_dir = dirs::home_dir()
108108
.unwrap_or_else(|| PathBuf::from("."))
109109
.join(".omni");
110-
if conf_dir.exists()
111-
&& fs::metadata(&conf_dir)
112-
.map(|m| !m.permissions().readonly())
113-
.unwrap_or(false)
114-
{
115-
println!(
116-
" {:<15} ~/.omni/ {}",
117-
"Config dir:".bright_black(),
118-
"[OK]".green().bold()
119-
);
110+
if conf_dir.exists() {
111+
// Actual write test catches sandbox restrictions
112+
let test_file = conf_dir.join(".write_test");
113+
match fs::write(&test_file, "ok") {
114+
Ok(_) => {
115+
let _ = fs::remove_file(&test_file);
116+
println!(
117+
" {:<15} ~/.omni/ {}",
118+
"Config dir:".bright_black(),
119+
"[OK]".green().bold()
120+
);
121+
}
122+
Err(_) => {
123+
println!(
124+
" {:<15} ~/.omni/ {}",
125+
"Config dir:".bright_black(),
126+
"[ERROR]".red().bold()
127+
);
128+
warnings.push(
129+
"Cannot write to ~/.omni/. If using Claude Code, add ~/.omni to sandbox.filesystem.allowWrite in ~/.claude/settings.json",
130+
);
131+
all_ok = false;
132+
}
133+
}
120134
} else {
121135
if fix_mode && fs::create_dir_all(&conf_dir).is_ok() {
122136
println!(
@@ -146,6 +160,25 @@ pub fn run(args: &[String]) -> anyhow::Result<()> {
146160
"[OK]".green().bold()
147161
);
148162

163+
// DB write test (catches sandbox restrictions on the database itself)
164+
if store.test_write() {
165+
println!(
166+
" {:<15} writable {}",
167+
"DB Write:".bright_black(),
168+
"[OK]".green().bold()
169+
);
170+
} else {
171+
println!(
172+
" {:<15} read-only {}",
173+
"DB Write:".bright_black(),
174+
"[ERROR]".red().bold()
175+
);
176+
warnings.push(
177+
"Database is read-only. Claude Code sandbox may be blocking writes to ~/.omni/omni.db. Add ~/.omni to sandbox.filesystem.allowWrite in ~/.claude/settings.json",
178+
);
179+
all_ok = false;
180+
}
181+
149182
if store.check_fts5() {
150183
println!(
151184
" {:<15} available {}",

src/hooks/dispatcher.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use std::sync::{Arc, Mutex};
88

99
#[derive(Deserialize)]
1010
struct HookPeeker {
11-
#[serde(rename = "hookEventName")]
11+
#[serde(rename = "hookEventName", alias = "hook_event_name")]
1212
hook_event_name: Option<String>,
1313
}
1414

src/hooks/post_tool.rs

Lines changed: 120 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ struct ToolInput {
2121
#[derive(Deserialize)]
2222
struct ToolResponse {
2323
content: Option<serde_json::Value>,
24+
stdout: Option<String>,
25+
stderr: Option<String>,
2426
}
2527

2628
#[derive(Serialize)]
@@ -62,6 +64,35 @@ fn extract_content(value: &serde_json::Value) -> Option<String> {
6264
None
6365
}
6466

67+
/// Extracts content from tool_response, trying `content` first (Cursor, Windsurf)
68+
/// then falling back to `stdout`/`stderr` (Claude Code format).
69+
fn extract_tool_content(input: &HookInput) -> Option<String> {
70+
let response = input.tool_response.as_ref()?;
71+
72+
// Try structured `content` field first
73+
if let Some(ref val) = response.content
74+
&& let Some(s) = extract_content(val)
75+
{
76+
return Some(s);
77+
}
78+
79+
// Fall back to stdout/stderr (Claude Code format)
80+
if let Some(ref stdout) = response.stdout
81+
&& !stdout.is_empty()
82+
{
83+
let mut result = stdout.clone();
84+
if let Some(ref stderr) = response.stderr
85+
&& !stderr.is_empty()
86+
{
87+
result.push_str("\n[stderr]\n");
88+
result.push_str(stderr);
89+
}
90+
return Some(result);
91+
}
92+
93+
None
94+
}
95+
6596
pub fn process_payload(
6697
input_str: &str,
6798
store: Option<Arc<Store>>,
@@ -79,12 +110,7 @@ pub fn process_payload(
79110
return None;
80111
}
81112

82-
let raw_val = parsed
83-
.tool_response
84-
.as_ref()
85-
.and_then(|r| r.content.as_ref())?;
86-
87-
let content = extract_content(raw_val)?;
113+
let content = extract_tool_content(&parsed)?;
88114

89115
if content.len() < 50 {
90116
return None;
@@ -357,4 +383,92 @@ mod tests {
357383
assert!(extracted.contains("world world"));
358384
assert!(extracted.ends_with("!"));
359385
}
386+
387+
#[test]
388+
fn test_claude_code_stdout_format() {
389+
let mut big_output =
390+
"total 42\ndrwxr-xr-x 15 user staff 480 Apr 10 10:00 .\n".to_string();
391+
for i in 0..30 {
392+
big_output.push_str(&format!(
393+
"-rw-r--r-- 1 user staff {} Apr 10 10:00 file{}.rs\n",
394+
i * 100,
395+
i
396+
));
397+
}
398+
let input = json!({
399+
"tool_name": "Bash",
400+
"tool_input": { "command": "ls -la" },
401+
"tool_response": {
402+
"stdout": big_output,
403+
"stderr": "",
404+
"interrupted": false,
405+
"isImage": false,
406+
"noOutputExpected": false
407+
}
408+
});
409+
let out = process_payload(&input.to_string(), None, None);
410+
assert!(out.is_some(), "Claude Code stdout format must be processed");
411+
let res = out.expect("must succeed");
412+
assert!(res.contains("PostToolUse"));
413+
}
414+
415+
#[test]
416+
fn test_claude_code_stdout_with_stderr() {
417+
let mut big_output = String::new();
418+
for i in 0..30 {
419+
big_output.push_str(&format!("line {} of output\n", i));
420+
}
421+
let input = json!({
422+
"tool_name": "Bash",
423+
"tool_input": { "command": "cargo build" },
424+
"tool_response": {
425+
"stdout": big_output,
426+
"stderr": "warning: unused variable",
427+
"interrupted": false
428+
}
429+
});
430+
let parsed: HookInput = serde_json::from_value(input).expect("must parse");
431+
let content = extract_tool_content(&parsed).expect("must extract");
432+
assert!(content.contains("line 0 of output"));
433+
assert!(content.contains("[stderr]"));
434+
assert!(content.contains("warning: unused variable"));
435+
}
436+
437+
#[test]
438+
fn test_claude_code_empty_stdout_ignored() {
439+
let input = json!({
440+
"tool_name": "Bash",
441+
"tool_input": { "command": "true" },
442+
"tool_response": {
443+
"stdout": "",
444+
"stderr": "",
445+
"interrupted": false
446+
}
447+
});
448+
let out = process_payload(&input.to_string(), None, None);
449+
assert!(out.is_none(), "Empty stdout should exit early");
450+
}
451+
452+
#[test]
453+
fn test_content_field_still_preferred_over_stdout() {
454+
let mut big_diff = "diff --git a/test.txt b/test.txt\nindex 123..456 100644\n--- a/test.txt\n+++ b/test.txt\n@@ -1,1 +1,2 @@\n-old\n+new line 1\n+new line 2\n".to_string();
455+
for _ in 0..50 {
456+
big_diff.push_str(" \n");
457+
}
458+
let input = json!({
459+
"tool_name": "Bash",
460+
"tool_input": { "command": "git diff" },
461+
"tool_response": {
462+
"content": big_diff,
463+
"stdout": "should be ignored when content is present"
464+
}
465+
});
466+
let out = process_payload(&input.to_string(), None, None);
467+
assert!(out.is_some());
468+
let res = out.expect("must succeed");
469+
assert!(
470+
res.contains("test.txt"),
471+
"content field should be used, not stdout"
472+
);
473+
}
360474
}

src/hooks/session_start.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ use std::sync::Arc;
77

88
#[derive(Deserialize)]
99
struct HookInput {
10-
#[serde(rename = "hookEventName")]
10+
#[serde(rename = "hookEventName", alias = "hook_event_name")]
1111
hook_event_name: String,
12-
#[serde(rename = "sessionId")]
12+
#[serde(rename = "sessionId", alias = "session_id")]
1313
session_id: String,
14-
#[serde(rename = "workingDirectory")]
14+
#[serde(rename = "workingDirectory", alias = "working_directory")]
1515
working_directory: String,
1616
}
1717

src/store/sqlite.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,21 @@ impl Store {
748748
params![ts_threshold],
749749
);
750750
}
751+
752+
/// Test that the database is actually writable (catches sandbox restrictions)
753+
pub fn test_write(&self) -> bool {
754+
let conn = match self.conn.lock() {
755+
Ok(c) => c,
756+
Err(_) => return false,
757+
};
758+
match conn.execute("CREATE TABLE IF NOT EXISTS _write_test (id INTEGER)", []) {
759+
Ok(_) => {
760+
let _ = conn.execute("DROP TABLE IF EXISTS _write_test", []);
761+
true
762+
}
763+
Err(_) => false,
764+
}
765+
}
751766
}
752767

753768
#[cfg(test)]

0 commit comments

Comments
 (0)