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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tpv1gBaepKjQiAVJqejGbA
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-12 19:44:04 -06:00
1 parent 799aee58ea
commit 9eba3180b4
2 files changed
+61 -10

No files matched your search

+43 -8
View File
@@ -15,8 +15,9 @@
//! `.gitignore`, and `books-data/`. //! `.gitignore`, and `books-data/`.
//! 3. Registers the MCP server for this directory only: `.mcp.json`, plus //! 3. Registers the MCP server for this directory only: `.mcp.json`, plus
//! `.claude/settings.json` approving it, so Claude Code loads the tools //! `.claude/settings.json` approving it, so Claude Code loads the tools
//! without `claude mcp add` or a prompt. The same settings carry a //! without `claude mcp add` or a prompt. Claude Code
//! SessionStart hook that checks whether the viewer is open. //! (`.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. //! 4. `git init`s the directory (its history is the archive) and commits.
//! 5. Starts `claude` in the directory with the onboarding prompt, unless //! 5. Starts `claude` in the directory with the onboarding prompt, unless
//! `--no-start` or there's no terminal. With `--codex`, registers the //! `--no-start` or there's no terminal. With `--codex`, registers the
@@ -80,6 +81,18 @@ gui-fg:
gui-status: gui-status:
@pgrep -f -- "ledger-gui --data-dir {{justfile_directory()}}/books-data( |$)" >/dev/null && echo open || { echo closed; exit 1; } @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 # Commit the journal: the git history of this dir is the archive
backup: backup:
git add -A && (git commit -qm "books backup $(date -u +%Y-%m-%dT%H:%M:%SZ)" && echo committed || echo "nothing to commit") 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 ## 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 open_at_start` in `wanderbooks.toml`: `"ask"` (the default) offers to open
it, `"always"` opens it with `just gui`, `"never"` leaves it. `just gui` 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 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, /// `.claude/settings.json`: approve this directory's server, and its tools,
/// so the first session starts working instead of asking. The server still /// so the first session starts working instead of asking. The server still
/// previews every posting for the human to confirm. A SessionStart hook /// previews every posting for the human to confirm. A SessionStart hook
/// checks the viewer and hands the agent `[viewer] open_at_start`, so the /// runs `just session-start`, so the viewer check doesn't hang on the agent
/// offer to open it doesn't hang on the agent remembering AGENTS.md. /// remembering AGENTS.md.
const CLAUDE_SETTINGS: &str = r#"{ const CLAUDE_SETTINGS: &str = r#"{
"enabledMcpjsonServers": ["wanderbooks"], "enabledMcpjsonServers": ["wanderbooks"],
"permissions": { "permissions": {
@@ -375,13 +389,28 @@ const CLAUDE_SETTINGS: &str = r#"{
"hooks": { "hooks": {
"SessionStart": [ "SessionStart": [
{ {
"matcher": "startup|resume|clear",
"hooks": [ "hooks": [
{ { "type": "command", "command": "cd \"$CLAUDE_PROJECT_DIR\" && just session-start" }
"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"
} }
] ]
} }
}
"#;
/// `.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::create_dir_all(dir.join(".claude")).map_err(|e| format!("cannot create .claude: {e}"))?;
fs::write(dir.join(".claude/settings.json"), CLAUDE_SETTINGS) fs::write(dir.join(".claude/settings.json"), CLAUDE_SETTINGS)
.map_err(|e| format!("cannot write .claude/settings.json: {e}"))?; .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")) fs::create_dir_all(dir.join("books-data"))
.map_err(|e| format!("cannot create books-data: {e}"))?; .map_err(|e| format!("cannot create books-data: {e}"))?;
fs::write(dir.join("books-data/.gitkeep"), "") 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"); 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() { if start && is_terminal() {
start_agent(&dir, agent)?; start_agent(&dir, agent)?;
} else { } else {
+18 -2
View File
@@ -58,6 +58,7 @@ fn scaffold_lands_complete() {
".gitignore", ".gitignore",
".mcp.json", ".mcp.json",
".claude/settings.json", ".claude/settings.json",
".codex/hooks.json",
] { ] {
let p = books.join(f); let p = books.join(f);
assert!(p.exists(), "{f} exists"); 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"] let hook = settings["hooks"]["SessionStart"][0]["hooks"][0]["command"]
.as_str() .as_str()
.expect("SessionStart hook command"); .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. // The justfile opens this dir's books in the GUI.
let just = fs::read_to_string(books.join("justfile")).unwrap(); let just = fs::read_to_string(books.join("justfile")).unwrap();
assert!(just.contains("ledger-gui\" --data-dir \"{{justfile_directory()}}/books-data\"")); 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!(just.contains(recipe), "justfile has {recipe}");
} }
assert_eq!( assert_eq!(