From 9eba3180b4f004d284da61de7066231882b4cc88 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Mon, 12 Oct 2026 19:44:04 -0600 Subject: [PATCH] cli: the viewer check is just session-start, hooked for Claude Code and Codex New books dirs get .codex/hooks.json beside .claude/settings.json; both SessionStart hooks run one justfile recipe, which reports the viewer's state per [viewer] open_at_start (and opens it itself for "always"). Codex runs project hooks only once the dir is trusted and the hook approved, so new --codex says so; AGENTS.md stays the fallback. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Tpv1gBaepKjQiAVJqejGbA --- crates/ledger-cli/src/main.rs | 53 +++++++++++++++++++++++++++------ crates/ledger-cli/tests/init.rs | 20 +++++++++++-- 2 files changed, 62 insertions(+), 11 deletions(-) diff --git a/crates/ledger-cli/src/main.rs b/crates/ledger-cli/src/main.rs index 06e3c12..d9a62c3 100644 --- a/crates/ledger-cli/src/main.rs +++ b/crates/ledger-cli/src/main.rs @@ -15,8 +15,9 @@ //! `.gitignore`, and `books-data/`. //! 3. Registers the MCP server for this directory only: `.mcp.json`, plus //! `.claude/settings.json` approving it, so Claude Code loads the tools -//! without `claude mcp add` or a prompt. The same settings carry a -//! SessionStart hook that checks whether the viewer is open. +//! without `claude mcp add` or a prompt. Claude Code +//! (`.claude/settings.json`) and Codex (`.codex/hooks.json`) both get a +//! SessionStart hook that runs `just session-start`: the viewer check. //! 4. `git init`s the directory (its history is the archive) and commits. //! 5. Starts `claude` in the directory with the onboarding prompt, unless //! `--no-start` or there's no terminal. With `--codex`, registers the @@ -80,6 +81,18 @@ gui-fg: gui-status: @pgrep -f -- "ledger-gui --data-dir {{justfile_directory()}}/books-data( |$)" >/dev/null && echo open || { echo closed; exit 1; } +# The agents' session-start hook: the viewer's state, handled per [viewer] open_at_start +session-start: + #!/usr/bin/env bash + set -u + just gui-status &>/dev/null && { echo "wanderbooks viewer: open."; exit 0; } + pref=$(sed -n 's/^open_at_start *= *"\([a-z]*\)".*/\1/p' "{{justfile_directory()}}/wanderbooks.toml") + case "${pref:-ask}" in + never) ;; + always) echo "wanderbooks viewer: closed; open_at_start = \"always\", so it was opened: $(just gui 2>&1 | tail -1)" ;; + *) echo "wanderbooks viewer: CLOSED. [viewer] open_at_start = \"ask\": at the top of your first reply, before anything else, offer to open it (\`just gui\`)." ;; + esac + # Commit the journal: the git history of this dir is the archive backup: git add -A && (git commit -qm "books backup $(date -u +%Y-%m-%dT%H:%M:%SZ)" && echo committed || echo "nothing to commit") @@ -187,7 +200,8 @@ Bank feeds aren't automatic here: import the bank's CSV export ## Session start -Run `just gui-status`. If the viewer isn't open, follow `[viewer] +A SessionStart hook runs `just session-start` and hands you the result; +if it didn't, run `just gui-status` yourself. If the viewer isn't open, follow `[viewer] open_at_start` in `wanderbooks.toml`: `"ask"` (the default) offers to open it, `"always"` opens it with `just gui`, `"never"` leaves it. `just gui` starts it in the background and does nothing if it's already open. If the @@ -365,8 +379,8 @@ fn mcp_json(ledger_mcp: &Path, books: &Path) -> String { /// `.claude/settings.json`: approve this directory's server, and its tools, /// so the first session starts working instead of asking. The server still /// previews every posting for the human to confirm. A SessionStart hook -/// checks the viewer and hands the agent `[viewer] open_at_start`, so the -/// offer to open it doesn't hang on the agent remembering AGENTS.md. +/// runs `just session-start`, so the viewer check doesn't hang on the agent +/// remembering AGENTS.md. const CLAUDE_SETTINGS: &str = r#"{ "enabledMcpjsonServers": ["wanderbooks"], "permissions": { @@ -375,11 +389,26 @@ const CLAUDE_SETTINGS: &str = r#"{ "hooks": { "SessionStart": [ { + "matcher": "startup|resume|clear", "hooks": [ - { - "type": "command", - "command": "cd \"$CLAUDE_PROJECT_DIR\" && if just gui-status >/dev/null 2>&1; then echo 'wanderbooks viewer: open.'; else echo \"wanderbooks viewer: CLOSED. [viewer] open_at_start = $(sed -n 's/^open_at_start *= *//p' wanderbooks.toml). Act on this at the top of your first reply, before anything else: \\\"ask\\\" -> offer to open it (just gui); \\\"always\\\" -> run just gui; \\\"never\\\" -> say nothing.\"; fi" - } + { "type": "command", "command": "cd \"$CLAUDE_PROJECT_DIR\" && just session-start" } + ] + } + ] + } +} +"#; + +/// `.codex/hooks.json`: the same session-start check for Codex. Codex runs a +/// project hook only once the project is trusted and the hook approved +/// (`/hooks`); until then AGENTS.md's "Session start" is the fallback. +const CODEX_HOOKS: &str = r#"{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume|clear", + "hooks": [ + { "type": "command", "command": "cd \"$(git rev-parse --show-toplevel)\" && just session-start", "timeout": 10 } ] } ] @@ -460,6 +489,9 @@ fn scaffold( fs::create_dir_all(dir.join(".claude")).map_err(|e| format!("cannot create .claude: {e}"))?; fs::write(dir.join(".claude/settings.json"), CLAUDE_SETTINGS) .map_err(|e| format!("cannot write .claude/settings.json: {e}"))?; + fs::create_dir_all(dir.join(".codex")).map_err(|e| format!("cannot create .codex: {e}"))?; + fs::write(dir.join(".codex/hooks.json"), CODEX_HOOKS) + .map_err(|e| format!("cannot write .codex/hooks.json: {e}"))?; fs::create_dir_all(dir.join("books-data")) .map_err(|e| format!("cannot create books-data: {e}"))?; fs::write(dir.join("books-data/.gitkeep"), "") @@ -578,6 +610,9 @@ fn new(dir: &Path, force: bool, start: bool, agent: Agent) -> Result<(), String> } ); println!(" just backup commit the journal"); + if agent == Agent::Codex { + println!("In Codex, trust this directory and approve its session-start hook (/hooks)."); + } if start && is_terminal() { start_agent(&dir, agent)?; } else { diff --git a/crates/ledger-cli/tests/init.rs b/crates/ledger-cli/tests/init.rs index d118861..e6a7426 100644 --- a/crates/ledger-cli/tests/init.rs +++ b/crates/ledger-cli/tests/init.rs @@ -58,6 +58,7 @@ fn scaffold_lands_complete() { ".gitignore", ".mcp.json", ".claude/settings.json", + ".codex/hooks.json", ] { let p = books.join(f); assert!(p.exists(), "{f} exists"); @@ -101,11 +102,26 @@ fn the_server_is_registered_for_this_dir() { let hook = settings["hooks"]["SessionStart"][0]["hooks"][0]["command"] .as_str() .expect("SessionStart hook command"); - assert!(hook.contains("just gui-status") && hook.contains("open_at_start")); + assert!(hook.ends_with("just session-start"), "{hook}"); + let codex: serde_json::Value = + serde_json::from_str(&fs::read_to_string(books.join(".codex/hooks.json")).unwrap()) + .unwrap(); + let hook = codex["hooks"]["SessionStart"][0]["hooks"][0]["command"] + .as_str() + .expect("Codex SessionStart hook command"); + assert!(hook.ends_with("just session-start"), "{hook}"); // The justfile opens this dir's books in the GUI. let just = fs::read_to_string(books.join("justfile")).unwrap(); assert!(just.contains("ledger-gui\" --data-dir \"{{justfile_directory()}}/books-data\"")); - for recipe in ["agent:", "gui:", "gui-fg:", "gui-status:", "backup:", "update:"] { + for recipe in [ + "agent:", + "gui:", + "gui-fg:", + "gui-status:", + "session-start:", + "backup:", + "update:", + ] { assert!(just.contains(recipe), "justfile has {recipe}"); } assert_eq!(