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:
1 parent
799aee58ea
commit
9eba3180b4
2 files changed
+61
-10
No files matched your search
@@ -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 {
|
||||||
|
|||||||
@@ -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!(
|
||||||
|
|||||||
Reference in new issue
Block a user