diff --git a/CHANGELOG.md b/CHANGELOG.md index c866f5d..ba2a2c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,18 @@ versioning follows [Semantic Versioning](https://semver.org/). least one iter recorded a model; hidden otherwise. See `docs/LOOP.md` "Per-task model selection" for syntax + the per-model prompt-cache caveat. +- **`hew init` re-run UX.** Re-running `hew init` in an already-inited + directory now detects the prior install via per-runtime artifact + markers (`.claude/skills/hew/SKILL.md`, `.agents/skills/hew-execute/SKILL.md`, + `HEW:BEGIN` in `.cursorrules`/`.windsurfrules`, `CLAUDE.md` for generic) + and routes to one of three modes instead of silently re-prompting and + overwriting `~/.config/hew/config.toml`: **Refresh** (default — + re-lay skill files only, keep config), **Reconfigure** (full prompt + chain + config overwrite, opt in via `--reconfigure`), or **Cancel** + (no changes). Interactive runs get a 3-option picker; non-interactive + runs without `--reconfigure` default to Refresh. The summary panel + header reflects the chosen mode (`Setup complete` / `Refreshed` / + `Reconfigured`). Fresh installs are unaffected. (hew-0wa) ### Changed diff --git a/hew-core/src/install.rs b/hew-core/src/install.rs index 438b0fc..8134bfa 100644 --- a/hew-core/src/install.rs +++ b/hew-core/src/install.rs @@ -52,6 +52,39 @@ pub fn detect_runtimes(project_root: &Path) -> Vec { .collect() } +/// Stricter detection than [`detect_runtimes`]: does the install artifact +/// hew specifically would write actually exist? Used by `hew init` to decide +/// between Fresh, Refresh, Reconfigure, and Cancel flows on a re-run. A user +/// who has `.claude/` from some other tool but no hew install must still get +/// the Fresh flow (full prompt chain). +/// +/// Per runtime: +/// +/// - Claude → `.claude/skills/hew/SKILL.md` exists +/// - Codex → `.agents/skills/hew-execute/SKILL.md` exists +/// - Cursor → `.cursorrules` contains the `HEW:BEGIN` marker +/// - Windsurf → `.windsurfrules` contains the `HEW:BEGIN` marker +/// - Generic → `CLAUDE.md` at root exists (weak signal — generic adapter +/// writes the bundle unmarked, so the file's mere presence is all we can +/// key on) +pub fn detect_existing(runtime: Runtime, root: &Path) -> bool { + match runtime { + Runtime::Claude => { + root.join(".claude").join("skills").join("hew").join("SKILL.md").exists() + } + Runtime::Codex => { + root.join(".agents").join("skills").join("hew-execute").join("SKILL.md").exists() + } + Runtime::Cursor => file_contains(&root.join(".cursorrules"), "HEW:BEGIN"), + Runtime::Windsurf => file_contains(&root.join(".windsurfrules"), "HEW:BEGIN"), + Runtime::Generic => root.join("CLAUDE.md").exists(), + } +} + +fn file_contains(path: &Path, needle: &str) -> bool { + fs::read_to_string(path).map(|s| s.contains(needle)).unwrap_or(false) +} + /// How the running `hew` binary was installed. Used by `hew update` to /// route to the appropriate platform upgrade tool instead of the /// receipt-based in-process updater (which is never wired up because @@ -1156,6 +1189,54 @@ mod tests { assert!(!found.contains(&Runtime::Codex)); } + #[test] + fn detect_existing_claude_keys_on_skill_md() { + let tmp = tempfile::tempdir().unwrap(); + // Empty .claude/ does NOT count — must be a hew install. + fs::create_dir(tmp.path().join(".claude")).unwrap(); + assert!(!detect_existing(Runtime::Claude, tmp.path())); + // After install() the marker file exists. + install(Runtime::Claude, tmp.path()).unwrap(); + assert!(detect_existing(Runtime::Claude, tmp.path())); + } + + #[test] + fn detect_existing_codex_keys_on_hew_execute_skill_md() { + let tmp = tempfile::tempdir().unwrap(); + // Empty .codex/ alone is not enough — codex install also writes .agents/. + fs::create_dir(tmp.path().join(".codex")).unwrap(); + assert!(!detect_existing(Runtime::Codex, tmp.path())); + install(Runtime::Codex, tmp.path()).unwrap(); + assert!(detect_existing(Runtime::Codex, tmp.path())); + } + + #[test] + fn detect_existing_cursor_requires_hew_marker() { + let tmp = tempfile::tempdir().unwrap(); + // Foreign .cursorrules without the marker must NOT count. + fs::write(tmp.path().join(".cursorrules"), "user rules\n").unwrap(); + assert!(!detect_existing(Runtime::Cursor, tmp.path())); + install(Runtime::Cursor, tmp.path()).unwrap(); + assert!(detect_existing(Runtime::Cursor, tmp.path())); + } + + #[test] + fn detect_existing_windsurf_requires_hew_marker() { + let tmp = tempfile::tempdir().unwrap(); + fs::write(tmp.path().join(".windsurfrules"), "user rules\n").unwrap(); + assert!(!detect_existing(Runtime::Windsurf, tmp.path())); + install(Runtime::Windsurf, tmp.path()).unwrap(); + assert!(detect_existing(Runtime::Windsurf, tmp.path())); + } + + #[test] + fn detect_existing_generic_keys_on_claude_md_presence() { + let tmp = tempfile::tempdir().unwrap(); + assert!(!detect_existing(Runtime::Generic, tmp.path())); + install(Runtime::Generic, tmp.path()).unwrap(); + assert!(detect_existing(Runtime::Generic, tmp.path())); + } + #[test] fn install_claude_writes_every_skill_and_slash_command() { let tmp = tempfile::tempdir().unwrap(); diff --git a/hew/src/commands/init.rs b/hew/src/commands/init.rs index 704ae76..1ba6b3a 100644 --- a/hew/src/commands/init.rs +++ b/hew/src/commands/init.rs @@ -82,6 +82,46 @@ pub struct Args { /// Accept all defaults non-interactively. #[arg(short, long)] pub yes: bool, + + /// Force the full prompt chain + config overwrite on a re-run, even + /// when an existing hew install is detected. Without this flag the + /// default re-run behaviour is to refresh skill files only. + #[arg(long, action = clap::ArgAction::SetTrue)] + pub reconfigure: bool, +} + +/// What `hew init` should actually do this invocation. Distinct from the +/// runtime resolver: this captures whether a previous install exists at +/// `install_root` and how aggressively to re-run the prompt chain. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum InitMode { + /// No prior install — full prompt chain + install + persist config. + Fresh, + /// Prior install detected — re-lay skill files only. No prompts, + /// no config overwrite, no `bd init` re-prompt. + Refresh, + /// Prior install detected, user (or `--reconfigure`) wants the full + /// prompt chain again. Same as Fresh from the prompt/config side. + Reconfigure, + /// Prior install detected, user cancelled. No bd init, no install. + Cancel, +} + +impl InitMode { + /// Should the prompt block + config overwrite run for this mode? + fn runs_prompts(self) -> bool { + matches!(self, Self::Fresh | Self::Reconfigure) + } + + fn summary_header(self) -> &'static str { + match self { + Self::Fresh => "Setup complete", + Self::Refresh => "Refreshed", + Self::Reconfigure => "Reconfigured", + // Cancel never reaches the summary panel — handled before install. + Self::Cancel => "Cancelled", + } + } } #[derive(Debug, Copy, Clone, PartialEq, Eq, clap::ValueEnum)] @@ -187,6 +227,14 @@ pub fn run(ctx: &Ctx, args: Args) -> miette::Result<()> { let runtimes = resolve_runtimes(&args, detected, ctx.interactive, interactive_runtime_pick)?; let install_root = resolve_install_root(args.scope, &project_root)?; + let mode = detect_init_mode(ctx, &args, &runtimes, &install_root, interactive_rerun_pick)?; + if mode == InitMode::Cancel { + if !ctx.quiet { + println!("Cancelled. No changes."); + } + return Ok(()); + } + let bd = ensure_bd(ctx)?; // Git must be initialised BEFORE bd init: in non-stealth mode bd @@ -207,23 +255,30 @@ pub fn run(ctx: &Ctx, args: Args) -> miette::Result<()> { println!("beads: ✓ task graph initialised in .beads/"); } - let project_type = resolve_project_type(ctx, &args, &project_root); - let branching = resolve_branching(ctx, &args); - let skills = resolve_optional_skills(ctx, &args); - let require_tests = resolve_require_tests(ctx, &args); - let advanced = resolve_advanced(ctx, &args); - - persist_config(ctx, |cfg| { - cfg.git_track = git_track; - cfg.branching.strategy = branching.as_str().to_string(); - cfg.optional_skills.deps = skills.0.into_core(); - cfg.optional_skills.research = skills.1.into_core(); - cfg.optional_skills.security = skills.2.into_core(); - cfg.testing.require = require_tests; - cfg.research.default = advanced.research_default.as_str().to_string(); - cfg.review.after_n_tasks = advanced.review_after_n; - cfg.review.after_epic = advanced.review_after_epic; - }); + // Refresh mode: skip the prompt block + config write. Load the existing + // config (or default) so the summary panel still has something to show + // and skills/branching values reflect what's actually persisted. + let (project_type, branching, skills, require_tests, advanced) = if mode.runs_prompts() { + let project_type = resolve_project_type(ctx, &args, &project_root); + let branching = resolve_branching(ctx, &args); + let skills = resolve_optional_skills(ctx, &args); + let require_tests = resolve_require_tests(ctx, &args); + let advanced = resolve_advanced(ctx, &args); + persist_config(ctx, |cfg| { + cfg.git_track = git_track; + cfg.branching.strategy = branching.as_str().to_string(); + cfg.optional_skills.deps = skills.0.into_core(); + cfg.optional_skills.research = skills.1.into_core(); + cfg.optional_skills.security = skills.2.into_core(); + cfg.testing.require = require_tests; + cfg.research.default = advanced.research_default.as_str().to_string(); + cfg.review.after_n_tasks = advanced.review_after_n; + cfg.review.after_epic = advanced.review_after_epic; + }); + (project_type, branching, skills, require_tests, advanced) + } else { + load_persisted_summary_inputs(&args, &project_root) + }; let mut plans = Vec::with_capacity(runtimes.len()); for rt in &runtimes { @@ -245,6 +300,7 @@ pub fn run(ctx: &Ctx, args: Args) -> miette::Result<()> { if !ctx.quiet { print_summary_panel( + mode, &plans, args.scope, git_track, @@ -258,6 +314,104 @@ pub fn run(ctx: &Ctx, args: Args) -> miette::Result<()> { Ok(()) } +/// Refresh-mode summary inputs come from the on-disk config (or defaults +/// when the file is missing). Project-type is detected from the tree since +/// we don't persist it. +fn load_persisted_summary_inputs( + args: &Args, + project_root: &std::path::Path, +) -> (ProjectTypeArg, BranchingArg, (SkillModeArg, SkillModeArg, SkillModeArg), bool, AdvancedKnobs) +{ + let cfg = config::load().unwrap_or_default(); + let project_type = args.project_type.unwrap_or_else(|| detect_project_type(project_root)); + let branching = match cfg.branching.strategy.as_str() { + "none" => BranchingArg::None, + "always" => BranchingArg::Always, + _ => BranchingArg::Epic, + }; + let skills = ( + skill_mode_to_arg(cfg.optional_skills.deps), + skill_mode_to_arg(cfg.optional_skills.research), + skill_mode_to_arg(cfg.optional_skills.security), + ); + let advanced = AdvancedKnobs { + research_default: match cfg.research.default.as_str() { + "auto-skip" => ResearchDefaultArg::AutoSkip, + "auto-run" => ResearchDefaultArg::AutoRun, + _ => ResearchDefaultArg::Ask, + }, + review_after_n: cfg.review.after_n_tasks, + review_after_epic: cfg.review.after_epic, + }; + (project_type, branching, skills, cfg.testing.require, advanced) +} + +fn skill_mode_to_arg(m: hew_core::config::SkillMode) -> SkillModeArg { + use hew_core::config::SkillMode; + match m { + SkillMode::Yes => SkillModeArg::Yes, + SkillMode::No => SkillModeArg::No, + SkillMode::Ask => SkillModeArg::Ask, + } +} + +/// Resolve which [`InitMode`] applies. Pure (apart from the injected picker); +/// no I/O of its own beyond the marker checks done by `install::detect_existing`. +/// +/// Decision matrix: +/// +/// - Nothing detected for any requested runtime → [`InitMode::Fresh`]. +/// - At least one detected + `--reconfigure` → [`InitMode::Reconfigure`]. +/// - At least one detected + non-interactive (no flag) → [`InitMode::Refresh`]. +/// - At least one detected + interactive (no flag) → invoke picker. +fn detect_init_mode( + ctx: &Ctx, + args: &Args, + runtimes: &[Runtime], + install_root: &std::path::Path, + picker: F, +) -> miette::Result +where + F: FnOnce(&[Runtime]) -> miette::Result, +{ + let detected: Vec = + runtimes.iter().copied().filter(|r| install::detect_existing(*r, install_root)).collect(); + if detected.is_empty() { + return Ok(InitMode::Fresh); + } + if args.reconfigure { + return Ok(InitMode::Reconfigure); + } + if !ctx.interactive { + return Ok(InitMode::Refresh); + } + picker(&detected) +} + +fn interactive_rerun_pick(detected: &[Runtime]) -> miette::Result { + use inquire::Select; + let names: Vec<&'static str> = detected.iter().map(|r| r.as_str()).collect(); + let header = + format!("Existing hew install detected ({}). What would you like to do?", names.join(", ")); + let pick = Select::new( + &header, + vec![ + "refresh — re-lay skill files only (keep config)", + "reconfigure — full prompt chain + overwrite config", + "cancel — no changes", + ], + ) + .with_starting_cursor(0) + .prompt(); + match pick { + Ok(s) if s.starts_with("refresh") => Ok(InitMode::Refresh), + Ok(s) if s.starts_with("reconfigure") => Ok(InitMode::Reconfigure), + Ok(s) if s.starts_with("cancel") => Ok(InitMode::Cancel), + // ESC / Ctrl-C → safe default: cancel. + _ => Ok(InitMode::Cancel), + } +} + /// One-line banner per runtime install: `: ✓ N files into `. /// Called once per element of the runtimes vec; the aggregate panel below /// shows the cross-runtime totals. @@ -293,6 +447,7 @@ fn runtime_artifact_label(rt: Runtime) -> &'static str { #[allow(clippy::too_many_arguments)] // IV.13 refactor will collapse this into a FlowChoices struct. fn print_summary_panel( + mode: InitMode, plans: &[install::InstallPlan], scope: Scope, git_track: bool, @@ -307,7 +462,7 @@ fn print_summary_panel( let total_files: usize = plans.iter().map(|p| p.written.len()).sum(); let root_display = plans.first().map(|p| p.root.display().to_string()).unwrap_or_default(); println!(); - println!("Setup complete"); + println!("{}", mode.summary_header()); println!("{bar}"); println!(" runtime {runtimes_str}"); println!( diff --git a/hew/tests/init_e2e.rs b/hew/tests/init_e2e.rs index e560f8f..6f83ea8 100644 --- a/hew/tests/init_e2e.rs +++ b/hew/tests/init_e2e.rs @@ -743,6 +743,90 @@ fn init_multi_runtime_repeated_flag_form_equivalent_to_csv() { assert!(project.path().join("AGENTS.md").exists()); } +#[test] +fn init_rerun_non_interactive_refreshes_only_keeps_config() { + // hew-0wa: re-running `hew init` in an already-inited dir non-interactively + // must refresh skill files but leave the persisted config untouched. + let stub_dir = tempfile::tempdir().unwrap(); + install_stub(stub_dir.path(), BD_STUB_OK); + let project = tempfile::tempdir().unwrap(); + fs::create_dir(project.path().join(".claude")).unwrap(); + + // First run: persists git_track=true + require_tests=true. + hew_with_stub(project.path(), stub_dir.path()) + .args([ + "init", + "--non-interactive", + "--runtime", + "claude", + "--git-track", + "--require-tests", + ]) + .assert() + .success(); + let cfg_first = fs::read_to_string(project.path().join("hew-test-config.toml")).unwrap(); + assert!(cfg_first.contains("git_track = true")); + assert!(cfg_first.contains("require = true")); + + // Second run: no flags, install detected → Refresh path. Config must not + // be rewritten; the "Refreshed" banner must show. + hew_with_stub(project.path(), stub_dir.path()) + .args(["init", "--non-interactive", "--runtime", "claude"]) + .assert() + .success() + .stdout(contains("Refreshed")); + let cfg_second = fs::read_to_string(project.path().join("hew-test-config.toml")).unwrap(); + assert_eq!(cfg_first, cfg_second, "Refresh mode must not rewrite config"); +} + +#[test] +fn init_rerun_reconfigure_forces_config_overwrite() { + // --reconfigure flips the re-run from Refresh to Reconfigure, taking the + // full prompt chain (here: flag values) and overwriting config. + let stub_dir = tempfile::tempdir().unwrap(); + install_stub(stub_dir.path(), BD_STUB_OK); + let project = tempfile::tempdir().unwrap(); + fs::create_dir(project.path().join(".claude")).unwrap(); + + hew_with_stub(project.path(), stub_dir.path()) + .args(["init", "--non-interactive", "--runtime", "claude", "--require-tests"]) + .assert() + .success(); + let cfg_first = fs::read_to_string(project.path().join("hew-test-config.toml")).unwrap(); + assert!(cfg_first.contains("require = true")); + + // Re-run with --reconfigure and --no-require-tests: config must flip. + hew_with_stub(project.path(), stub_dir.path()) + .args([ + "init", + "--non-interactive", + "--runtime", + "claude", + "--reconfigure", + "--no-require-tests", + ]) + .assert() + .success() + .stdout(contains("Reconfigured")); + let cfg_second = fs::read_to_string(project.path().join("hew-test-config.toml")).unwrap(); + assert!(cfg_second.contains("require = false"), "Reconfigure must overwrite: {cfg_second}"); +} + +#[test] +fn init_fresh_dir_still_runs_full_setup() { + // No prior install → Fresh path → "Setup complete" banner, full config write. + let stub_dir = tempfile::tempdir().unwrap(); + install_stub(stub_dir.path(), BD_STUB_OK); + let project = tempfile::tempdir().unwrap(); + // No .claude/, no .cursorrules, nothing — fully fresh. + + hew_with_stub(project.path(), stub_dir.path()) + .args(["init", "--non-interactive", "--runtime", "claude"]) + .assert() + .success() + .stdout(contains("Setup complete")); +} + #[test] fn init_no_flag_with_multiple_detected_refreshes_all() { // hew-a41: previously errored on multi-detected non-interactive; now refreshes all.