diff --git a/crates/tempyr-cli/src/commands/init.rs b/crates/tempyr-cli/src/commands/init.rs index 509804c..f9831f1 100644 --- a/crates/tempyr-cli/src/commands/init.rs +++ b/crates/tempyr-cli/src/commands/init.rs @@ -13,11 +13,13 @@ use super::index_cmd; use super::journal_init::{self, Visibility}; use super::managed::{self, ManagedArtifact, WriteOutcome}; use super::onboarding::{ - self, EmbeddingProviderChoice, ExistingDocMode, ExistingDocs, OnboardingSelections, + self, EmbeddingProviderChoice, ExistingDocMode, ExistingDocs, JournalSetupDefaults, + OnboardingSelections, }; use super::process_utils::wait_for_child_exit; use tempyr_core::project; use tempyr_index::embeddings; +use tempyr_journal::path as journal_path; const DEFAULT_SCHEMA: &str = include_str!("../../../../schema/default-schema.toml"); const CLAUDE_DOC_TEMPLATE: &str = include_str!("../../assets/CLAUDE.template.md"); @@ -44,11 +46,12 @@ pub fn run(json_output: bool, force_wizard: bool, no_wizard: bool) -> anyhow::Re } let existing_docs = detect_existing_docs(&cwd); + let journal_defaults = journal_setup_defaults(&cwd); let selections = if should_launch_wizard(force_wizard, no_wizard) { - onboarding::run(existing_docs)? + onboarding::run(existing_docs, journal_defaults)? .ok_or_else(|| anyhow::anyhow!("Initialization cancelled."))? } else { - noninteractive_defaults(existing_docs) + noninteractive_defaults(existing_docs, journal_defaults) }; initialize_project(&cwd, &selections) @@ -66,7 +69,10 @@ fn should_launch_wizard(force_wizard: bool, no_wizard: bool) -> bool { && std::io::stderr().is_terminal() } -fn noninteractive_defaults(existing_docs: ExistingDocs) -> OnboardingSelections { +fn noninteractive_defaults( + existing_docs: ExistingDocs, + journal_defaults: JournalSetupDefaults, +) -> OnboardingSelections { OnboardingSelections { provider: EmbeddingProviderChoice::Voyage, api_key: None, @@ -74,6 +80,9 @@ fn noninteractive_defaults(existing_docs: ExistingDocs) -> OnboardingSelections create_env_local_from_template: false, validate_provider_setup: false, run_index_rebuild: false, + enable_journal: journal_defaults.enable_journal, + configure_journal_fetch_refspec: journal_defaults.configure_journal_fetch_refspec, + bootstrap_journal_layout: journal_defaults.bootstrap_journal_layout, install_render_overrides: false, install_claude_hooks: true, install_claude_skill: true, @@ -90,6 +99,25 @@ fn noninteractive_defaults(existing_docs: ExistingDocs) -> OnboardingSelections } } +fn journal_setup_defaults(root: &Path) -> JournalSetupDefaults { + let in_git = is_inside_git_repo(root); + JournalSetupDefaults { + enable_journal: !matches!(journal_init::detect_visibility(root), Visibility::Public), + configure_journal_fetch_refspec: in_git, + bootstrap_journal_layout: in_git, + } +} + +fn is_inside_git_repo(root: &Path) -> bool { + Command::new("git") + .args(["rev-parse", "--is-inside-work-tree"]) + .current_dir(root) + .output() + .ok() + .map(|o| o.status.success()) + .unwrap_or(false) +} + fn detect_existing_docs(root: &Path) -> ExistingDocs { ExistingDocs { claude_md: root.join("CLAUDE.md").is_file(), @@ -107,15 +135,12 @@ fn initialize_project(root: &Path, selections: &OnboardingSelections) -> anyhow: fs::write(tempyr_dir.join("schema.toml"), DEFAULT_SCHEMA)?; - // Decide whether to ship `[journal] enabled = true`. If the project - // is a git repo whose `origin` looks public, default to disabled - // (with a clear warning) — agent reasoning shouldn't be world- - // readable by default. Anything else (private, undetermined, no - // git) gets `enabled = true`. The user can always flip the flag. - let journal_outcome = decide_journal_init(root); + // Apply the selected journal setup choices before writing config so the + // summary matches the final config and local git setup. + let journal_outcome = apply_journal_init(root, selections); let mut config_text = render_config(selections.provider); config_text.push_str(&journal_init::render_journal_config_block( - journal_outcome.enabled, + selections.enable_journal, )); fs::write(tempyr_dir.join("config.toml"), config_text)?; @@ -281,88 +306,70 @@ fn initialize_project(root: &Path, selections: &OnboardingSelections) -> anyhow: /// Result of preparing the journal section during init. struct JournalInitOutcome { - /// Goes directly into `[journal] enabled` in config.toml. - enabled: bool, /// Lines to append to the init-summary printout. summary_lines: Vec, } -/// Decide whether to ship `[journal] enabled = true` and configure the -/// auto-fetch refspec on `origin`. Best-effort throughout: any sub-step -/// that fails just gets surfaced as a warning line in the summary; we -/// never abort the whole init. -fn decide_journal_init(root: &Path) -> JournalInitOutcome { - // If the project root isn't inside a git repo, journals can still - // be configured later. Note this in the summary and ship enabled = - // true (no public-repo concern without a remote). - let in_git = Command::new("git") - .args(["rev-parse", "--is-inside-work-tree"]) - .current_dir(root) - .output() - .ok() - .map(|o| o.status.success()) - .unwrap_or(false); - - if !in_git { - return JournalInitOutcome { - enabled: true, - summary_lines: vec![ - " .tempyr/config.toml - [journal] enabled = true (no git repo detected; \ -configure remote later)" - .to_string(), - ], - }; - } - - let visibility = journal_init::detect_visibility(root); - let mut summary_lines = Vec::new(); - let enabled = match visibility { - Visibility::Public => { - summary_lines.push( - " .tempyr/config.toml - [journal] enabled = false (origin appears to be a \ -public GitHub repo; flip to true if you intentionally want world-readable \ -journal refs)" - .to_string(), - ); - false - } - Visibility::Private => { - summary_lines.push( - " .tempyr/config.toml - [journal] enabled = true (origin appears private)" - .to_string(), - ); - true - } - Visibility::Undetermined => { - summary_lines.push( - " .tempyr/config.toml - [journal] enabled = true (origin visibility \ -undetermined; install `gh` and re-run if you want a sharper default)" - .to_string(), - ); - true - } - }; +/// Apply the selected journal setup choices. Best-effort throughout: +/// failures are surfaced in the init summary and never abort project setup. +fn apply_journal_init(root: &Path, selections: &OnboardingSelections) -> JournalInitOutcome { + let mut summary_lines = vec![journal_config_summary(root, selections.enable_journal)]; // Configure the auto-fetch refspec so a regular `git fetch origin` // also pulls journal refs. Failure is non-fatal — flip into a // warning line. - match journal_init::configure_auto_fetch_refspec(root, "origin") { - Ok(true) => summary_lines.push( - " git config - added remote.origin.fetch = +refs/tempyr/journals/*" - .to_string(), - ), - Ok(false) => { - // Already present — quiet success, no summary line needed. + if selections.configure_journal_fetch_refspec { + match journal_init::configure_auto_fetch_refspec(root, "origin") { + Ok(true) => summary_lines.push( + " git config - added remote.origin.fetch = +refs/tempyr/journals/*" + .to_string(), + ), + Ok(false) => { + // Already present — quiet success, no summary line needed. + } + Err(e) => summary_lines.push(format!( + " git config - warning: could not add journal fetch refspec: {e}" + )), } - Err(e) => summary_lines.push(format!( - " git config - warning: could not add journal fetch refspec: {e}" - )), } - JournalInitOutcome { - enabled, - summary_lines, + if selections.bootstrap_journal_layout { + match journal_path::git_common_dir(root).and_then(|common_dir| { + journal_path::ensure_layout(&common_dir)?; + Ok(common_dir) + }) { + Ok(common_dir) => summary_lines.push(format!( + " journal layout - initialized under {}", + journal_path::journals_root(&common_dir).display() + )), + Err(e) => summary_lines.push(format!( + " journal layout - warning: could not initialize journal layout: {e}" + )), + } } + + JournalInitOutcome { summary_lines } +} + +fn journal_config_summary(root: &Path, enabled: bool) -> String { + let enabled_text = if enabled { "true" } else { "false" }; + if !is_inside_git_repo(root) { + return format!( + " .tempyr/config.toml - [journal] enabled = {enabled_text} (no git repo detected; configure remote later)" + ); + } + + let suffix = match (journal_init::detect_visibility(root), enabled) { + (Visibility::Public, false) => { + "origin appears public; flip to true only if journal refs may be world-readable" + } + (Visibility::Public, true) => "selected during onboarding; warning: origin appears public", + (Visibility::Private, true) => "origin appears private", + (Visibility::Private, false) => "disabled during onboarding; origin appears private", + (Visibility::Undetermined, true) => "origin visibility undetermined", + (Visibility::Undetermined, false) => "disabled during onboarding", + }; + format!(" .tempyr/config.toml - [journal] enabled = {enabled_text} ({suffix})") } fn handle_doc_target( @@ -1373,6 +1380,67 @@ mod tests { assert_eq!(message, "skipped (VOYAGE_API_KEY not set yet)"); } + #[test] + fn initialize_project_runs_initial_index_when_selected() { + let tmp = tempfile::tempdir().unwrap(); + let selections = OnboardingSelections { + run_index_rebuild: true, + validate_provider_setup: false, + install_claude_hooks: false, + install_claude_skill: false, + install_claude_agent: false, + install_claude_doc: false, + install_codex_skill: false, + install_codex_doc: false, + write_mcp_setup_notes: false, + configure_journal_fetch_refspec: false, + bootstrap_journal_layout: false, + ..OnboardingSelections::interactive_defaults(ExistingDocs { + claude_md: false, + agents_md: false, + }) + }; + + initialize_project(tmp.path(), &selections).unwrap(); + + let graph_dir = tmp.path().join("graph"); + let ctx = ProjectContext::find(Some(graph_dir.as_path())).unwrap(); + let index_path = ctx.queryable_index_path().unwrap(); + assert!(index_path.exists()); + } + + #[test] + fn initialize_project_bootstraps_journal_layout_when_selected() { + let tmp = tempfile::tempdir().unwrap(); + let status = Command::new("git") + .arg("init") + .current_dir(tmp.path()) + .status() + .unwrap(); + assert!(status.success()); + let selections = OnboardingSelections { + run_index_rebuild: false, + validate_provider_setup: false, + install_claude_hooks: false, + install_claude_skill: false, + install_claude_agent: false, + install_claude_doc: false, + install_codex_skill: false, + install_codex_doc: false, + write_mcp_setup_notes: false, + configure_journal_fetch_refspec: false, + bootstrap_journal_layout: true, + ..OnboardingSelections::interactive_defaults(ExistingDocs { + claude_md: false, + agents_md: false, + }) + }; + + initialize_project(tmp.path(), &selections).unwrap(); + + assert!(tmp.path().join(".git/tempyr/journals/open").is_dir()); + } + #[test] fn claude_merge_uses_accept_edits_and_restricted_tools() { let docs = [ diff --git a/crates/tempyr-cli/src/commands/onboarding.rs b/crates/tempyr-cli/src/commands/onboarding.rs index 8698972..bdafcfb 100644 --- a/crates/tempyr-cli/src/commands/onboarding.rs +++ b/crates/tempyr-cli/src/commands/onboarding.rs @@ -178,6 +178,9 @@ pub struct OnboardingSelections { pub create_env_local_from_template: bool, pub validate_provider_setup: bool, pub run_index_rebuild: bool, + pub enable_journal: bool, + pub configure_journal_fetch_refspec: bool, + pub bootstrap_journal_layout: bool, pub install_render_overrides: bool, pub install_claude_hooks: bool, pub install_claude_skill: bool, @@ -189,15 +192,43 @@ pub struct OnboardingSelections { pub existing_doc_mode: ExistingDocMode, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct JournalSetupDefaults { + pub enable_journal: bool, + pub configure_journal_fetch_refspec: bool, + pub bootstrap_journal_layout: bool, +} + +impl Default for JournalSetupDefaults { + fn default() -> Self { + Self { + enable_journal: true, + configure_journal_fetch_refspec: true, + bootstrap_journal_layout: true, + } + } +} + impl OnboardingSelections { + #[cfg(test)] pub fn interactive_defaults(existing_docs: ExistingDocs) -> Self { + Self::interactive_defaults_with_journal(existing_docs, JournalSetupDefaults::default()) + } + + pub fn interactive_defaults_with_journal( + existing_docs: ExistingDocs, + journal_defaults: JournalSetupDefaults, + ) -> Self { Self { provider: EmbeddingProviderChoice::Voyage, api_key: None, write_api_key_for_tempyr: true, create_env_local_from_template: true, validate_provider_setup: true, - run_index_rebuild: false, + run_index_rebuild: true, + enable_journal: journal_defaults.enable_journal, + configure_journal_fetch_refspec: journal_defaults.configure_journal_fetch_refspec, + bootstrap_journal_layout: journal_defaults.bootstrap_journal_layout, install_render_overrides: false, install_claude_hooks: true, install_claude_skill: true, @@ -221,6 +252,7 @@ enum Page { Provider, CoreSetup, ApiKey, + JournalSetup, AgentIntegrations, ExistingDocs, Review, @@ -235,6 +267,23 @@ enum CoreOption { InstallRenderOverrides, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum JournalOption { + EnableJournal, + ConfigureFetchRefspec, + BootstrapLayout, +} + +impl JournalOption { + fn all() -> [Self; 3] { + [ + Self::EnableJournal, + Self::ConfigureFetchRefspec, + Self::BootstrapLayout, + ] + } +} + #[derive(Debug, Clone, PartialEq, Eq)] enum ApiKeyValidation { Empty, @@ -248,18 +297,31 @@ struct WizardState { existing_docs: ExistingDocs, page_index: usize, core_index: usize, + journal_index: usize, agent_index: usize, existing_docs_index: usize, api_key_input: String, } impl WizardState { + #[cfg(test)] fn new(existing_docs: ExistingDocs) -> Self { + Self::new_with_journal_defaults(existing_docs, JournalSetupDefaults::default()) + } + + fn new_with_journal_defaults( + existing_docs: ExistingDocs, + journal_defaults: JournalSetupDefaults, + ) -> Self { Self { - selections: OnboardingSelections::interactive_defaults(existing_docs), + selections: OnboardingSelections::interactive_defaults_with_journal( + existing_docs, + journal_defaults, + ), existing_docs, page_index: 0, core_index: 0, + journal_index: 0, agent_index: 0, existing_docs_index: 0, api_key_input: String::new(), @@ -271,6 +333,7 @@ impl WizardState { if self.should_show_api_key_page() { pages.push(Page::ApiKey); } + pages.push(Page::JournalSetup); pages.push(Page::AgentIntegrations); if self.selected_existing_docs() { pages.push(Page::ExistingDocs); @@ -318,9 +381,12 @@ impl WizardState { } } -pub fn run(existing_docs: ExistingDocs) -> anyhow::Result> { +pub fn run( + existing_docs: ExistingDocs, + journal_defaults: JournalSetupDefaults, +) -> anyhow::Result> { let mut guard = TerminalGuard::enter()?; - let mut state = WizardState::new(existing_docs); + let mut state = WizardState::new_with_journal_defaults(existing_docs, journal_defaults); loop { guard @@ -347,6 +413,7 @@ pub fn run(existing_docs: ExistingDocs) -> anyhow::Result handle_provider(&mut state, key.code), Page::CoreSetup => handle_core_setup(&mut state, key.code), Page::ApiKey => handle_api_key(&mut state, key.code), + Page::JournalSetup => handle_journal_setup(&mut state, key.code), Page::AgentIntegrations => handle_agent_integrations(&mut state, key.code), Page::ExistingDocs => handle_existing_docs(&mut state, key.code), Page::Review => match key.code { @@ -411,6 +478,24 @@ fn handle_core_setup(state: &mut WizardState, key: KeyCode) { } } +fn handle_journal_setup(state: &mut WizardState, key: KeyCode) { + let max_index = JournalOption::all().len().saturating_sub(1); + state.journal_index = state.journal_index.min(max_index); + + match key { + KeyCode::Up | KeyCode::Char('k') => { + state.journal_index = state.journal_index.saturating_sub(1); + } + KeyCode::Down | KeyCode::Char('j') => { + state.journal_index = (state.journal_index + 1).min(max_index); + } + KeyCode::Char(' ') => toggle_journal_option(state), + KeyCode::Enter | KeyCode::Right | KeyCode::Char('n') => state.next_page(), + KeyCode::Left | KeyCode::Backspace | KeyCode::Char('b') => state.prev_page(), + _ => {} + } +} + fn handle_api_key(state: &mut WizardState, key: KeyCode) { match key { KeyCode::Enter | KeyCode::Right if api_key_can_continue(state) => state.next_page(), @@ -511,6 +596,22 @@ fn toggle_core_option(state: &mut WizardState) { } } +fn toggle_journal_option(state: &mut WizardState) { + match JournalOption::all().get(state.journal_index).copied() { + Some(JournalOption::EnableJournal) => { + state.selections.enable_journal = !state.selections.enable_journal; + } + Some(JournalOption::ConfigureFetchRefspec) => { + state.selections.configure_journal_fetch_refspec = + !state.selections.configure_journal_fetch_refspec; + } + Some(JournalOption::BootstrapLayout) => { + state.selections.bootstrap_journal_layout = !state.selections.bootstrap_journal_layout; + } + None => {} + } +} + fn toggle_agent_checkbox(state: &mut WizardState) { match state.agent_index { 0 => state.selections.install_claude_hooks = !state.selections.install_claude_hooks, @@ -584,6 +685,7 @@ fn render(area: Rect, frame: &mut ratatui::Frame<'_>, state: &WizardState) { Page::Provider => render_provider(frame, chunks[1], state), Page::CoreSetup => render_core_setup(frame, chunks[1], state), Page::ApiKey => render_api_key(frame, chunks[1], state), + Page::JournalSetup => render_journal_setup(frame, chunks[1], state), Page::AgentIntegrations => render_agent_integrations(frame, chunks[1], state), Page::ExistingDocs => render_existing_docs(frame, chunks[1], state), Page::Review => render_review(frame, chunks[1], state), @@ -626,6 +728,12 @@ fn current_header(state: &WizardState) -> Vec> { "Tempyr validates it as you type and stores it in Tempyr's shared worktree env when available, falling back to .env.local.", ), ], + Page::JournalSetup => vec![ + Line::from("Choose how Tempyr should set up the session journal."), + Line::from( + "Journals capture agent plans, findings, decisions, and dead ends as git refs.", + ), + ], Page::AgentIntegrations => vec![ Line::from("Toggle the agent integrations you want Tempyr to scaffold."), Line::from("Hooks, skills, docs, and MCP notes can be managed independently."), @@ -651,6 +759,9 @@ fn current_footer(state: &WizardState) -> &'static str { Page::ApiKey => { "Type or paste the key Backspace: delete Delete: clear Enter: continue when valid Left: back Esc: cancel" } + Page::JournalSetup => { + "Up/Down: move Space: toggle option Enter: continue Backspace/Left: back" + } Page::AgentIntegrations | Page::ExistingDocs => { "Up/Down: move Space: toggle/select Enter: continue Backspace/Left: back" } @@ -665,8 +776,9 @@ fn render_welcome(frame: &mut ratatui::Frame<'_>, area: Rect) { Line::from("1. Create .tempyr/, graph/, and the base project config."), Line::from("2. Ask which embedding provider this repo should use."), Line::from("3. Collect and validate an API key if you want Tempyr to store one."), - Line::from("4. Scaffold Claude Code and Codex integration files."), - Line::from("5. Review the plan before anything is written."), + Line::from("4. Configure the session journal and initial index."), + Line::from("5. Scaffold Claude Code and Codex integration files."), + Line::from("6. Review the plan before anything is written."), ]; frame.render_widget(Paragraph::new(text).wrap(Wrap { trim: true }), area); } @@ -865,6 +977,51 @@ fn render_api_key(frame: &mut ratatui::Frame<'_>, area: Rect, state: &WizardStat frame.render_widget(help, sections[3]); } +fn render_journal_setup(frame: &mut ratatui::Frame<'_>, area: Rect, state: &WizardState) { + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(52), Constraint::Percentage(48)]) + .split(area); + + let options = JournalOption::all(); + let items: Vec> = options + .iter() + .enumerate() + .map(|(index, option)| { + ListItem::new(checkbox_line( + state.journal_index == index, + journal_option_enabled(state, *option), + journal_option_label(*option), + )) + }) + .collect(); + let list = List::new(items) + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Journal setup "), + ); + let mut list_state = ListState::default(); + list_state.select(Some( + state.journal_index.min(options.len().saturating_sub(1)), + )); + frame.render_stateful_widget(list, columns[0], &mut list_state); + + let option = options + .get(state.journal_index.min(options.len().saturating_sub(1))) + .copied() + .unwrap_or(JournalOption::EnableJournal); + let detail = Paragraph::new(journal_option_detail_lines(option)) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Option details "), + ) + .wrap(Wrap { trim: true }); + frame.render_widget(detail, columns[1]); +} + fn render_agent_integrations(frame: &mut ratatui::Frame<'_>, area: Rect, state: &WizardState) { let rows = [ checkbox_line( @@ -1051,6 +1208,17 @@ fn render_review(frame: &mut ratatui::Frame<'_>, area: Rect, state: &WizardState ("doc", state.selections.install_codex_doc), ])), ]), + Line::from(vec![ + Span::styled("Journal: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(enabled_list(&[ + ("auto-publish", state.selections.enable_journal), + ( + "fetch refs", + state.selections.configure_journal_fetch_refspec, + ), + ("bootstrap", state.selections.bootstrap_journal_layout), + ])), + ]), Line::from(vec![ Span::styled("MCP notes: ", Style::default().add_modifier(Modifier::BOLD)), Span::raw(if state.selections.write_mcp_setup_notes { @@ -1202,6 +1370,62 @@ fn core_option_detail_lines(state: &WizardState, option: CoreOption) -> Vec bool { + match option { + JournalOption::EnableJournal => state.selections.enable_journal, + JournalOption::ConfigureFetchRefspec => state.selections.configure_journal_fetch_refspec, + JournalOption::BootstrapLayout => state.selections.bootstrap_journal_layout, + } +} + +fn journal_option_label(option: JournalOption) -> &'static str { + match option { + JournalOption::EnableJournal => "Enable journal auto-publish", + JournalOption::ConfigureFetchRefspec => "Fetch journal refs with git fetch", + JournalOption::BootstrapLayout => "Initialize local journal layout now", + } +} + +fn journal_option_detail_lines(option: JournalOption) -> Vec> { + match option { + JournalOption::EnableJournal => vec![ + Line::from(Span::styled( + "Journal auto-publish", + Style::default().add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from( + "Writes [journal] enabled = true so finalized sessions can be published to refs/tempyr/journals/*.", + ), + Line::from( + "Disable this for repos where agent reasoning should stay local unless you flush manually.", + ), + ], + JournalOption::ConfigureFetchRefspec => vec![ + Line::from(Span::styled( + "Fetch journal refs", + Style::default().add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from( + "Adds a remote.origin.fetch refspec so regular git fetch also pulls journal refs from other machines.", + ), + Line::from("Tempyr skips this safely when the project is not inside a git repo."), + ], + JournalOption::BootstrapLayout => vec![ + Line::from(Span::styled( + "Initialize journal layout", + Style::default().add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from( + "Creates the local /tempyr/journals/open directory now instead of waiting for the first agent session.", + ), + Line::from("This only touches git-private storage, not tracked project files."), + ], + } +} + fn api_key_validation(state: &WizardState) -> ApiKeyValidation { let Some(env_var) = state.selections.provider.env_var() else { return ApiKeyValidation::Valid; @@ -1320,6 +1544,7 @@ mod tests { Page::Provider, Page::CoreSetup, Page::ApiKey, + Page::JournalSetup, Page::AgentIntegrations, Page::Review, ] @@ -1383,7 +1608,43 @@ mod tests { handle_api_key(&mut state, KeyCode::Enter); assert_eq!(state.api_key_input, "pa-1234567890abcdef"); - assert_eq!(state.current_page(), Page::AgentIntegrations); + assert_eq!(state.current_page(), Page::JournalSetup); + } + + #[test] + fn interactive_defaults_run_index_and_show_journal_setup() { + let selections = OnboardingSelections::interactive_defaults(ExistingDocs { + claude_md: false, + agents_md: false, + }); + + assert!(selections.run_index_rebuild); + assert!(selections.enable_journal); + assert!(selections.configure_journal_fetch_refspec); + assert!(selections.bootstrap_journal_layout); + } + + #[test] + fn journal_setup_toggles_each_option() { + let mut state = WizardState::new(ExistingDocs { + claude_md: false, + agents_md: false, + }); + state.page_index = state + .pages() + .iter() + .position(|page| *page == Page::JournalSetup) + .unwrap(); + + handle_journal_setup(&mut state, KeyCode::Char(' ')); + handle_journal_setup(&mut state, KeyCode::Down); + handle_journal_setup(&mut state, KeyCode::Char(' ')); + handle_journal_setup(&mut state, KeyCode::Down); + handle_journal_setup(&mut state, KeyCode::Char(' ')); + + assert!(!state.selections.enable_journal); + assert!(!state.selections.configure_journal_fetch_refspec); + assert!(!state.selections.bootstrap_journal_layout); } #[test] diff --git a/crates/tempyr-cli/tests/integration.rs b/crates/tempyr-cli/tests/integration.rs index cc36071..4a73a21 100644 --- a/crates/tempyr-cli/tests/integration.rs +++ b/crates/tempyr-cli/tests/integration.rs @@ -1082,10 +1082,10 @@ fn test_status_agent_override_attributes_journal_entry() { ); } -/// `tempyr journal bootstrap` should create the journal layout -/// idempotently. `tempyr journal finalize` should mark the active -/// session as ready. Together these power the SessionStart / -/// SessionEnd Claude Code hooks. +/// `tempyr init` should create the journal layout for git repos. +/// `tempyr journal bootstrap` should remain idempotent, and +/// `tempyr journal finalize` should mark the active session as ready. +/// Together these power the SessionStart / SessionEnd Claude Code hooks. #[test] fn test_journal_bootstrap_and_finalize_lifecycle() { let tmp = TempDir::new().unwrap(); @@ -1094,11 +1094,11 @@ fn test_journal_bootstrap_and_finalize_lifecycle() { let journals_dir = tmp.path().join(".git/tempyr/journals"); assert!( - !journals_dir.exists(), - "journals dir should not exist before bootstrap" + journals_dir.exists(), + "git-backed init should bootstrap journals dir" ); - // bootstrap: creates the layout, emits JSON when --json passed + // bootstrap remains idempotent and emits JSON when --json passed let output = tempyr() .current_dir(tmp.path()) .args(["--json", "journal", "bootstrap"])