From 090ba5d71905a5825c52db3b5392e4442f6e7200 Mon Sep 17 00:00:00 2001 From: benbenbang Date: Wed, 9 Sep 2026 20:35:35 +0800 Subject: [PATCH 1/2] feat(init)!: install configured directory bindings by default Add `--no-setup-key-binding` to omit the widget while preserving the `cd` wrapper and worktree Tab completion. Retain `--setup-key-binding` as a compatibility no-op and reject conflicting or init-only flag misuse. Update Bash, Zsh, Nushell, and PowerShell completions and document migration. BREAKING CHANGE: `cj init` now installs configured directory bindings by default. Closes #47 Refs #44 #45 #46 --- README.md | 63 ++++++++++++++------ src/cli.rs | 82 +++++++++++++++++++++++--- src/completions.rs | 7 ++- tests/shell_generation.rs | 119 ++++++++++++++++++++++++++++++++++++-- 4 files changed, 238 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index f76d46e..2c265ae 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ For PowerShell 7: ```powershell $CjConfig = Join-Path (Split-Path -Parent $PROFILE) 'cj' -cj init powershell --setup-key-binding -o (Join-Path $CjConfig 'init.ps1') +cj init powershell -o (Join-Path $CjConfig 'init.ps1') cj completions powershell -o (Join-Path $CjConfig 'completions.ps1') # Add these lines to $PROFILE: @@ -89,8 +89,12 @@ $CjConfig = Join-Path (Split-Path -Parent $PROFILE) 'cj' Restart the shell or source its configuration after making the change. `pwsh` is accepted as an input alias for `powershell`; generated source and completion candidates use the canonical `powershell` name. -On PowerShell, the optional picker binding is installed through PSReadLine when -that module is available and is otherwise skipped without changing the profile. +`cj init` installs the configured directory completion binding by default on +Bash, Zsh, Nushell, and PowerShell. The Bash widget requires Bash 4 or newer; +macOS's system Bash 3 still supports the `cd` wrapper and Tab completion. +PowerShell installs the widget when PSReadLine is available. Use +`cj init --no-setup-key-binding` to generate the wrapper and completions +without installing the widget. ## Jumping @@ -173,19 +177,41 @@ inserts its path; press Enter to change directory. Pressing Enter while the jump token is still present shows a reminder to use Tab and leaves the directory unchanged. The standalone `cj -jw` / `cj --jump-worktree` command remains available to print -a path selected through fzf; it also powers the optional worktree key binding. - -To install an fzf worktree jump key together with the `cd` wrapper, add the setup -flag to shell initialization: +a worktree path selected through fzf. + +The key installed by `cj init` completes a directory from history. Its default +chord is Ctrl+O on macOS and Windows, and +Alt+O on Linux. Each OS's `key-bindings` entry has an ordered +`behaviors` list, defaulting to `["zoxide", "cj"]`. `zoxide` opens its interactive +directory picker using fzf. `cj` cycles through cj's browser-style directory +history without external dependencies. Both append a safely quoted directory to +the current command line; press Enter to execute it. The key itself never changes +the current directory. + +Behaviors run from left to right until one supplies a directory. A missing +dependency or empty candidate source falls through to the next behavior. +Cancelling a picker stops the chain and preserves the command line, cursor, and +current directory; other errors stop with a diagnostic. Reorder the list to +change which behavior is tried first, or use a single entry to select one source. + +After a cj history completion, repeated presses cycle from newest to oldest, +then restore the original line and cursor. Editing the line starts a new cycle. +Completion itself does not consume history. Dependency availability is checked +on each invocation, so installing or removing zoxide or fzf needs no regeneration. +The binding preserves zoxide settings such as `_ZO_FZF_OPTS`. + +Regenerate shell integration after upgrading to receive the default binding: ```bash -eval "$(cj init zsh --setup-key-binding)" +cj init zsh -o ~/.config/cj/init.zsh +source ~/.config/cj/init.zsh ``` -The default picker key is Ctrl+O on macOS and Windows, and -Alt+O on Linux. Pressing it invokes the same interactive -worktree picker as `cj -jw` and changes directory to the selected path. The key -binding requires `fzf`; ordinary directory jumps and worktree listing do not. +To keep the `cd` wrapper and Tab completion without the directory widget, add +`--no-setup-key-binding`. The old `--setup-key-binding` flag is still accepted as +a compatibility no-op; existing initialization commands can keep it or remove it. +The two flags cannot be combined. An empty `behaviors` list in the current OS's +`key-bindings` entry disables the widget in configuration. ## Configuration @@ -228,11 +254,12 @@ onedrive-work = { provider = "onedrive", account = "business" } Program values may be executable names found on `PATH` or explicit executable paths. Key binding keys accept `ctrl-o`, `alt-o`, or `none`. Behaviors run from -left to right: `zoxide` uses its interactive picker and `cj` uses cj's picker. -An unavailable picker or an empty result falls through to the next behavior; -cancellation leaves the command line unchanged, and other errors stop the chain. -Use `["cj", "zoxide"]` to prefer cj, `["cj"]` or `["zoxide"]` to use one -picker, or `[]` to disable the binding. Unknown or duplicate behaviors are errors. +left to right: `zoxide` uses its interactive picker and `cj` cycles through +browser-style directory history. Missing dependencies or an empty candidate source +fall through; cancellation preserves the command line, cursor, and directory, +and other errors stop the chain. Use `["cj", "zoxide"]` to prefer cj, +`["cj"]` or `["zoxide"]` for one source, or `[]` to disable the binding. +Unknown or duplicate behaviors are errors. The legacy strings (for example, `linux = "alt-o"`) remain valid and use `["zoxide", "cj"]`; `"none"` disables the binding. Omitted keys retain the platform default, and omitted behaviors retain the default chain. @@ -263,7 +290,7 @@ Use `-C` or `--config` to select a different file. When generating shell setup, the selected config path is embedded safely in the generated wrapper: ```bash -cj -C "$HOME/.config/cj/work.toml" init zsh --setup-key-binding \ +cj -C "$HOME/.config/cj/work.toml" init zsh \ -o "$HOME/.config/cj/init.zsh" ``` diff --git a/src/cli.rs b/src/cli.rs index 82086cc..6a64044 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -10,7 +10,7 @@ Usage: cj [OPTIONS] --jump-worktree cj [OPTIONS] config init [--preamp] cj [OPTIONS] mounts scan [--format table|json] - cj [OPTIONS] init [--setup-key-binding] [-o PATH] + cj [OPTIONS] init [--no-setup-key-binding] [-o PATH] cj [OPTIONS] completions [-o PATH] Options: @@ -23,7 +23,8 @@ Options: -f, --format Output format: table or json [default: table] -R, --relative Render worktree paths relative to the current directory -jw, --jump-worktree Select a worktree with fzf and print its path - --setup-key-binding Include the OS-specific fzf binding in shell setup + --no-setup-key-binding Omit the configured directory binding from init + --setup-key-binding Accepted for compatibility; init installs the binding by default -o, --output Write generated shell source and create parent directories --preamp Add unambiguous mounts to a new config -h, --help Print help @@ -111,6 +112,7 @@ impl Cli { let mut worktree_paths0 = false; let mut key_binding_zoxide = None; let mut setup_key_binding = false; + let mut no_setup_key_binding = false; let mut output = None; let mut preamp = false; let mut targets = Vec::new(); @@ -170,6 +172,8 @@ impl Cli { key_binding_zoxide = Some((PathBuf::from(zoxide), PathBuf::from(fzf))); } else if options && text == Some("--setup-key-binding") { setup_key_binding = true; + } else if options && text == Some("--no-setup-key-binding") { + no_setup_key_binding = true; } else if options && matches!(text, Some("-o" | "--output")) { output = Some(PathBuf::from( args.next().ok_or("--output requires a path")?, @@ -183,6 +187,19 @@ impl Cli { } } + if setup_key_binding && no_setup_key_binding { + return Err("--setup-key-binding and --no-setup-key-binding cannot be used together; omit both for the default binding or use only --no-setup-key-binding to disable it".into()); + } + if (setup_key_binding || no_setup_key_binding) + && (force_resolve + || resolver != ResolverOverride::Configured + || targets.first().and_then(|arg| arg.to_str()) != Some("init")) + { + return Err( + "--setup-key-binding and --no-setup-key-binding can only be used with init".into(), + ); + } + let command = if let Some((zoxide, fzf)) = key_binding_zoxide { if force_resolve || !targets.is_empty() @@ -248,7 +265,7 @@ impl Cli { parse_init( &targets, ShellOptions { - setup_key_binding, + setup_key_binding: !no_setup_key_binding, output: output.clone(), }, worktree, @@ -341,7 +358,7 @@ fn parse_init( } if targets.len() != 2 { return Err( - "usage: cj init [--setup-key-binding] [-o PATH]".into(), + "usage: cj init [--no-setup-key-binding] [-o PATH]".into(), ); } let shell = parse_shell(&targets[1])?; @@ -668,7 +685,7 @@ mod tests { Cli::parse(["init", name].map(Into::into)).unwrap().command, Command::Init { shell, - setup_key_binding: false, + setup_key_binding: true, output: None, } ); @@ -684,10 +701,61 @@ mod tests { } } + #[test] + fn init_binding_defaults_and_compatibility_flags() { + for name in ["bash", "zsh", "nu", "powershell", "pwsh"] { + let default = Cli::parse(["init", name].map(Into::into)).unwrap(); + let legacy = Cli::parse(["init", name, "--setup-key-binding"].map(Into::into)).unwrap(); + assert_eq!(default, legacy); + assert!(matches!( + Cli::parse(["init", name, "--no-setup-key-binding"].map(Into::into)) + .unwrap() + .command, + Command::Init { + setup_key_binding: false, + .. + } + )); + } + for flags in [ + ["--setup-key-binding", "--no-setup-key-binding"], + ["--no-setup-key-binding", "--setup-key-binding"], + ] { + let error = + Cli::parse(["init", "bash", flags[0], flags[1]].map(Into::into)).unwrap_err(); + assert!(error.contains("cannot be used together")); + assert!(error.contains("omit both")); + } + } + + #[test] + fn both_binding_flags_require_init() { + for flag in ["--setup-key-binding", "--no-setup-key-binding"] { + for args in [ + vec![flag], + vec!["target", flag], + vec!["completions", "bash", flag], + vec!["config", "init", flag], + vec!["mounts", "scan", flag], + vec!["--worktree", flag], + vec!["--worktree-paths0", flag], + vec!["-z", "init", flag], + vec![flag, "--", "init", "bash"], + ] { + let error = Cli::parse(args.into_iter().map(Into::into)).unwrap_err(); + assert!(error.contains("can only be used with init"), "{error}"); + } + assert!(matches!( + Cli::parse(["--", flag].map(Into::into)).unwrap().command, + Command::Resolve { targets, .. } if targets == [flag] + )); + } + } + #[test] fn rejects_unsupported_shell_setup_combinations() { assert!(Cli::parse(["init", "fish"].map(Into::into)).is_err()); - assert!(Cli::parse(["init", "nu", "--setup-key-binding"].map(Into::into)).is_ok()); + assert_eq!( Cli::parse(["init", "powershell", "--setup-key-binding"].map(Into::into)), Ok(Cli { @@ -734,7 +802,7 @@ mod tests { parsed, Command::Init { shell: Shell::Zsh, - setup_key_binding: false, + setup_key_binding: true, output: Some("nested/source.zsh".into()), } ); diff --git a/src/completions.rs b/src/completions.rs index 31914a3..3781281 100644 --- a/src/completions.rs +++ b/src/completions.rs @@ -146,7 +146,7 @@ fn bash(destinations: &[String]) -> String { config:init) candidates=(--preamp "${{common_flags[@]}}") ;; mounts:) candidates=(scan "${{common_flags[@]}}") ;; mounts:scan) candidates=(-f --format "${{common_flags[@]}}") ;; - init:) if [[ -n "$command_value" ]]; then candidates=(-o --output --setup-key-binding "${{common_flags[@]}}"); else candidates=("${{shells[@]}}" -o --output --setup-key-binding "${{common_flags[@]}}"); fi ;; + init:) if [[ -n "$command_value" ]]; then candidates=(-o --output --no-setup-key-binding --setup-key-binding "${{common_flags[@]}}"); else candidates=("${{shells[@]}}" -o --output --no-setup-key-binding --setup-key-binding "${{common_flags[@]}}"); fi ;; completions:) if [[ -n "$command_value" ]]; then candidates=(-o --output "${{common_flags[@]}}"); else candidates=("${{shells[@]}}" -o --output "${{common_flags[@]}}"); fi ;; *) candidates=("${{commands[@]}}" "${{destinations[@]}}" "${{flags[@]}}") ;; esac; fi @@ -234,7 +234,7 @@ _cj_complete() {{ config:init) candidates=(--preamp "${{common_flags[@]}}") ;; mounts:) candidates=(scan "${{common_flags[@]}}") ;; mounts:scan) candidates=(-f --format "${{common_flags[@]}}") ;; - init:) if [[ -n "$command_value" ]]; then candidates=(-o --output --setup-key-binding "${{common_flags[@]}}"); else candidates=("${{shells[@]}}" -o --output --setup-key-binding "${{common_flags[@]}}"); fi ;; + init:) if [[ -n "$command_value" ]]; then candidates=(-o --output --no-setup-key-binding --setup-key-binding "${{common_flags[@]}}"); else candidates=("${{shells[@]}}" -o --output --no-setup-key-binding --setup-key-binding "${{common_flags[@]}}"); fi ;; completions:) if [[ -n "$command_value" ]]; then candidates=(-o --output "${{common_flags[@]}}"); else candidates=("${{shells[@]}}" -o --output "${{common_flags[@]}}"); fi ;; *) candidates=("${{commands[@]}}" "${{destinations[@]}}" "${{flags[@]}}") ;; esac; fi @@ -285,6 +285,7 @@ export extern cj [ export extern "cj init" [ shell: string@"nu-complete cj shells" + --no-setup-key-binding --setup-key-binding --output(-o): path --config(-C): path @@ -393,7 +394,7 @@ fn powershell(destinations: &[String]) -> String { 'config:init' {{ @('--preamp') + $commonFlags }} 'mounts:' {{ @('scan') + $commonFlags }} 'mounts:scan' {{ @('-f', '--format') + $commonFlags }} - 'init:' {{ if ($commandValue) {{ @('-o', '--output', '--setup-key-binding') + $commonFlags }} else {{ $shells + @('-o', '--output', '--setup-key-binding') + $commonFlags }} }} + 'init:' {{ if ($commandValue) {{ @('-o', '--output', '--no-setup-key-binding', '--setup-key-binding') + $commonFlags }} else {{ $shells + @('-o', '--output', '--no-setup-key-binding', '--setup-key-binding') + $commonFlags }} }} 'completions:' {{ if ($commandValue) {{ @('-o', '--output') + $commonFlags }} else {{ $shells + @('-o', '--output') + $commonFlags }} }} default {{ $commands + $destinations + $flags }} }} }} diff --git a/tests/shell_generation.rs b/tests/shell_generation.rs index e354f70..845e276 100644 --- a/tests/shell_generation.rs +++ b/tests/shell_generation.rs @@ -68,6 +68,8 @@ fn generation_is_deterministic_and_keeps_source_on_stdout() { "zsh", "nu", "powershell", + "--setup-key-binding", + "--no-setup-key-binding", "repo", "home base", "code", @@ -77,6 +79,97 @@ fn generation_is_deterministic_and_keeps_source_on_stdout() { } } +#[test] +fn init_installs_binding_by_default_and_opt_out_keeps_shell_integration() { + let temp = TempDir::new("default-init-binding"); + let config = temp.path().join("config.toml"); + fs::write(&config, "").expect("write default config"); + + for (shell, binding, wrapper, completion) in [ + ( + "bash", + "_cj_key_widget() {", + "function cd()", + "complete -F _cj_complete_cd cd", + ), + ( + "zsh", + "_cj_key_widget() {", + "function cd()", + "compdef _cj_complete_cd cd", + ), + ( + "powershell", + "Set-PSReadLineKeyHandler -Chord", + "function global:cd", + "function global:TabExpansion2", + ), + ( + "nu", + "name: cj-directory", + "export def --env --wrapped __cj_cd", + "_cj-complete-cd", + ), + ] { + let default = generate(temp.path(), &config, "init", shell); + let legacy = + generate_with_args(temp.path(), &config, ["init", shell, "--setup-key-binding"]); + let disabled = generate_with_args( + temp.path(), + &config, + ["init", shell, "--no-setup-key-binding"], + ); + assert_eq!( + default, legacy, + "{shell}: compatibility flag changed output" + ); + assert!( + disabled.contains(wrapper), + "{shell}: opt-out removed cd wrapper" + ); + assert!( + disabled.contains(completion), + "{shell}: opt-out removed cd completion" + ); + assert!( + !disabled.contains(binding), + "{shell}: opt-out installed binding" + ); + assert!( + default.contains(binding), + "{shell}: default omitted binding" + ); + if shell == "nu" { + assert!(default.contains("_cj-key-widget")); + } + parse_source(temp.path(), "init-no-binding", shell, &disabled); + } +} + +#[test] +fn init_respects_configured_binding_disablement() { + let temp = TempDir::new("disabled-init-binding"); + let config = temp.path().join("config.toml"); + for settings in [ + "[key-bindings]\nmacos = 'none'\nlinux = 'none'\nwindows = 'none'\n", + "[key-bindings]\nmacos = { behaviors = [] }\nlinux = { behaviors = [] }\nwindows = { behaviors = [] }\n", + ] { + fs::write(&config, settings).expect("write disabled binding config"); + for shell in ["bash", "zsh", "nu", "powershell"] { + let default = generate(temp.path(), &config, "init", shell); + let disabled = generate_with_args( + temp.path(), + &config, + ["init", shell, "--no-setup-key-binding"], + ); + assert_eq!( + default, disabled, + "{shell}: disabled config installed binding" + ); + } + } +} + #[test] fn shell_output_creates_parents_and_replaces_the_file() { let temp = TempDir::new("init-output"); @@ -177,11 +270,7 @@ fn powershell_integration_and_completion_work_when_available() { let completions_path = temp.path().join("cj-completions.ps1"); fs::write( &integration_path, - generate_with_args( - temp.path(), - &config, - ["init", "powershell", "--setup-key-binding"], - ), + generate(temp.path(), &config, "init", "powershell"), ) .expect("write PowerShell integration"); fs::write( @@ -254,6 +343,11 @@ fn bash_completion_handles_nested_commands_and_spaced_names() { let forced = bash_complete(&completions, setup); assert!(!forced.lines().any(|value| value == "bash")); assert!(!forced.lines().any(|value| value == "--setup-key-binding")); + assert!( + !forced + .lines() + .any(|value| value == "--no-setup-key-binding") + ); } let completed = bash_complete(&completions, "COMP_WORDS=(cj init bash ''); COMP_CWORD=3"); @@ -263,6 +357,11 @@ fn bash_completion_handles_nested_commands_and_spaced_names() { .lines() .any(|value| value == "--setup-key-binding") ); + assert!( + completed + .lines() + .any(|value| value == "--no-setup-key-binding") + ); assert!(completed.lines().any(|value| value == "-o")); assert!(completed.lines().any(|value| value == "--output")); @@ -272,6 +371,16 @@ fn bash_completion_handles_nested_commands_and_spaced_names() { ); assert!(completion_flags.lines().any(|value| value == "-o")); assert!(completion_flags.lines().any(|value| value == "--output")); + assert!( + !completion_flags + .lines() + .any(|value| value == "--setup-key-binding") + ); + assert!( + !completion_flags + .lines() + .any(|value| value == "--no-setup-key-binding") + ); let worktree = bash_complete(&completions, "COMP_WORDS=(cj -w ''); COMP_CWORD=2"); assert!(worktree.lines().any(|value| value == "--relative")); From 2c777042cb2052207eed79fca2b2e4aecc052479 Mon Sep 17 00:00:00 2001 From: benbenbang Date: Thu, 10 Sep 2026 08:48:59 +0800 Subject: [PATCH 2/2] fix(shell): register Bash key bindings only interactively Guard binding registration so repeated default initialization remains quiet in noninteractive Bash scripts. Add a modern Bash regression for repeated initialization and keep reload registration coverage interactive. Validated 123 local tests with Bash 5, Zsh, Nu, and PowerShell, plus Windows-target Clippy. Refs #47 --- src/key_bindings.rs | 4 +++- tests/key_bindings.rs | 43 ++++++++++++++++++++++++++++++++++++++----- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/src/key_bindings.rs b/src/key_bindings.rs index f98a1ad..0340786 100644 --- a/src/key_bindings.rs +++ b/src/key_bindings.rs @@ -188,7 +188,9 @@ fn render_posix(shell: Shell, chord: KeyBinding, config: &Config, history: bool) }; let binding = format!("{binding}\n_cj_bound_key='{key}'\n_cj_bound_widget=_cj_key_widget"); if shell == Shell::Bash { - format!("{body}\nif (( BASH_VERSINFO[0] >= 4 )); then\n {binding}\nfi") + format!( + "{body}\nif [[ $- == *i* ]] && (( BASH_VERSINFO[0] >= 4 )); then\n {binding}\nfi" + ) } else { format!("{body}\n{binding}") } diff --git a/tests/key_bindings.rs b/tests/key_bindings.rs index 1a63bdf..3434395 100644 --- a/tests/key_bindings.rs +++ b/tests/key_bindings.rs @@ -458,13 +458,42 @@ Invoke-CjKeyWidget } } -#[test] -fn sourcing_again_updates_only_the_binding_owned_by_cj() { - let bash = if Path::new("/opt/homebrew/bin/bash").exists() { +fn bash_program() -> &'static str { + if Path::new("/opt/homebrew/bin/bash").exists() { "/opt/homebrew/bin/bash" } else { "bash" - }; + } +} + +#[test] +fn default_init_is_quiet_in_noninteractive_bash() { + let temp = TempDir::new("noninteractive-init"); + let generated = cj(temp.path(), temp.path()) + .args(["init", "bash"]) + .output() + .unwrap(); + assert_success(&generated); + let source = String::from_utf8(generated.stdout).unwrap(); + let output = Command::new(bash_program()) + .args(["--noprofile", "--norc", "-c"]) + .arg(format!( + "{source}\n{source}\ndeclare -F cd _cj_key_widget >/dev/null && [[ -z ${{_cj_bound_key-}} ]]" + )) + .output() + .unwrap(); + assert_success(&output); + assert!(output.stdout.is_empty()); + assert!( + output.stderr.is_empty(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn sourcing_again_updates_only_the_binding_owned_by_cj() { + let bash = bash_program(); for (shell, executable) in [("bash", bash), ("zsh", "zsh")] { if Command::new(executable).arg("--version").output().is_err() { continue; @@ -499,7 +528,11 @@ fn sourcing_again_updates_only_the_binding_owned_by_cj() { }; let script = fixture.temp.path().join("reload.sh"); fs::write(&script, format!("{first}\n{second}\n{query}\nprintf '\\0'\n{disabled}\n{query}\nprintf '\\0'\n{first}\n{replace}\n{disabled}\n{query_user}\n")).unwrap(); - let output = Command::new(executable).arg(&script).output().unwrap(); + let mut command = Command::new(executable); + if shell == "bash" { + command.args(["--noprofile", "--norc", "-i"]); + } + let output = command.arg(&script).output().unwrap(); assert_success(&output); let sections = fields(&output); assert!(