From 0cb1d5f16f88098d759d7699a916ca8e2c8b3f86 Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 13 Oct 2026 22:17:17 +0000 Subject: [PATCH] Run $EDITOR through the shell in config edit (closes #315) config edit used the whole of $EDITOR as the program name, so a value with arguments such as "code --wait" or "emacsclient -t" failed with "executable file not found". It now runs $EDITOR the way git does: sh -c ' "$@"' with the config path as a separate argument, so the editor's own arguments and quoting work and a path with spaces reaches it whole. A test runs config edit with an $EDITOR carrying an argument on a config path that contains a space. Model: opus-5-5 --- internal/cli/config.go | 9 ++++++++- internal/cli/config_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/internal/cli/config.go b/internal/cli/config.go index 5a00a2f..468d031 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -333,8 +333,15 @@ func newConfigEditCommand() *cobra.Command { editor = "vi" } + // Run $EDITOR through the shell, as git does, so a value + // with arguments such as "code --wait" works. The path + // goes in as an argument ("$@"), not into the command + // text, so a path with spaces or quotes arrives whole. + // sh -c takes the word after the command as $0, which + // is why editor appears twice. //nolint:gosec // G204: launching the operator's own $EDITOR is the point - ed := exec.CommandContext(cmd.Context(), editor, path) + ed := exec.CommandContext(cmd.Context(), + "sh", "-c", editor+` "$@"`, editor, path) ed.Stdin = os.Stdin ed.Stdout = os.Stdout ed.Stderr = os.Stderr diff --git a/internal/cli/config_test.go b/internal/cli/config_test.go index 58ce82c..7dccfa0 100644 --- a/internal/cli/config_test.go +++ b/internal/cli/config_test.go @@ -647,6 +647,30 @@ func TestWriteConfigSetTightensMode(t *testing.T) { } } +// TestConfigEditEditorWithArgument checks that an $EDITOR carrying an +// argument works and that a config path with a space reaches it whole. +// The "editor" is cp with the replacement file as its argument, so the +// config ends up with the replacement's content only if both arrived. +func TestConfigEditEditorWithArgument(t *testing.T) { + dir := t.TempDir() + path := seedFile(t, dir, "my config.yml", "compression_level: 3\n") + edited := seedFile(t, dir, "edited.yml", "compression_level: 9\n") + + setRootFlags(t, RootFlags{ConfigPath: path}) + t.Setenv("EDITOR", "cp '"+edited+"'") + + mustExecute(t, newConfigEditCommand()) + + data, err := os.ReadFile(path) //nolint:gosec // G304: test-controlled path + if err != nil { + t.Fatalf("reading config back: %v", err) + } + + if string(data) != "compression_level: 9\n" { + t.Errorf("config after edit = %q, want the edited content", data) + } +} + func splitPath(s string) []string { return strings.Split(s, ".") } -- 2.54.0