Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ jobs:
uses: ./.github/actions/setup-goreleaser

- name: Verify local and historical release preflight
run: go test ./scripts -run '^Test(Local|Historical)ReleasePreflight$' -count=1
run: go test ./scripts -run '^Test((Local|Historical)ReleasePreflight|LinuxFishPickerPackageArtifacts)$' -count=1
env:
GORELEASER_EXECUTABLE: ${{ steps.goreleaser.outputs.executable }}

Expand All @@ -127,12 +127,14 @@ jobs:
go run ./cmd/openai/main.go @completion bash > completions/openai.bash
go run ./cmd/openai/main.go @completion zsh > completions/openai.zsh
go run ./cmd/openai/main.go @completion fish > completions/openai.fish
mkdir -p completions/picker
go run ./scripts/render-linux-picker > completions/picker/openai.fish
go run ./cmd/openai/main.go @manpages -o man

for directory in completions man man/man1; do
for directory in completions completions/picker man man/man1; do
[[ -d "$directory" && ! -L "$directory" ]] || exit 1
done
for path in completions/openai.bash completions/openai.zsh completions/openai.fish man/man1/openai.1.gz; do
for path in completions/openai.bash completions/openai.zsh completions/openai.fish completions/picker/openai.fish man/man1/openai.1.gz; do
[[ -f "$path" && ! -L "$path" ]] || exit 1
size="$(wc -c < "$path")"
[[ "$size" -gt 0 && "$size" -le 10485760 ]] || exit 1
Expand All @@ -143,6 +145,7 @@ jobs:
'/completions/openai.bash' \
'/completions/openai.zsh' \
'/completions/openai.fish' \
'/completions/picker/openai.fish' \
'/man/man1/openai.1.gz' \
>> "$exclude_file"

Expand Down
33 changes: 25 additions & 8 deletions .github/workflows/publish-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ jobs:
go run ./cmd/openai/main.go @completion bash > completions/openai.bash
go run ./cmd/openai/main.go @completion zsh > completions/openai.zsh
go run ./cmd/openai/main.go @completion fish > completions/openai.fish
# Historical tags did not ship the Linux fish picker asset.
if grep -Fq 'src: completions/picker/openai.fish' .goreleaser.yml; then
mkdir -p completions/picker
go run ./scripts/render-linux-picker > completions/picker/openai.fish
fi
go run ./cmd/openai/main.go @manpages -o man
- name: Stage isolated release inputs
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
Expand All @@ -85,6 +90,7 @@ jobs:
completions/openai.bash
completions/openai.zsh
completions/openai.fish
completions/picker/openai.fish
man/man1/openai.1.gz
if-no-files-found: error
retention-days: 1
Expand Down Expand Up @@ -169,12 +175,27 @@ jobs:
exit 1
fi

inputs=(completions/openai.bash completions/openai.zsh completions/openai.fish man/man1/openai.1.gz)
package_picker=false
if grep -Fq 'src: completions/picker/openai.fish' .goreleaser.yml; then
package_picker=true
inputs+=(completions/picker/openai.fish)
fi

while IFS= read -r -d '' path; do
relative="${path#"$RELEASE_INPUT_DIR"/}"
case "$relative" in
completions|man|man/man1)
[[ -d "$path" && ! -L "$path" ]] || exit 1
;;
completions/picker)
[[ "$package_picker" == true && -d "$path" && ! -L "$path" ]] || exit 1
;;
completions/picker/openai.fish)
[[ "$package_picker" == true && -f "$path" && ! -L "$path" ]] || exit 1
size="$(wc -c < "$path")"
[[ "$size" -gt 0 && "$size" -le 10485760 ]] || exit 1
;;
completions/openai.bash|completions/openai.zsh|completions/openai.fish|man/man1/openai.1.gz)
[[ -f "$path" && ! -L "$path" ]] || exit 1
size="$(wc -c < "$path")"
Expand All @@ -190,22 +211,18 @@ jobs:
esac
done < <(find "$RELEASE_INPUT_DIR" -mindepth 1 -print0)

for path in completions/openai.bash completions/openai.zsh completions/openai.fish man/man1/openai.1.gz; do
for path in "${inputs[@]}"; do
[[ -f "$RELEASE_INPUT_DIR/$path" && ! -L "$RELEASE_INPUT_DIR/$path" ]] || exit 1
done

mkdir -p completions man/man1
for path in completions/openai.bash completions/openai.zsh completions/openai.fish man/man1/openai.1.gz; do
if [[ "$package_picker" == true ]]; then mkdir -p completions/picker; fi
for path in "${inputs[@]}"; do
install -m 0644 "$RELEASE_INPUT_DIR/$path" "$path"
done

exclude_file="$(git rev-parse --git-path info/exclude)"
printf '%s\n' \
'/completions/openai.bash' \
'/completions/openai.zsh' \
'/completions/openai.fish' \
'/man/man1/openai.1.gz' \
>> "$exclude_file"
printf '/%s\n' "${inputs[@]}" >> "$exclude_file"
- name: Create release app token for this repo
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ dist/
/completions/openai.bash
/completions/openai.zsh
/completions/openai.fish
/completions/picker/openai.fish
/man/man1/openai.1.gz
/openai
*.exe
Expand Down
4 changes: 4 additions & 0 deletions .goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ nfpms:
contents:
- src: man/man1/*.1.gz
dst: /usr/share/man/man1/
- src: completions/picker/openai.fish
dst: /usr/share/fish/vendor_conf.d/openai-picker.fish
file_info:
mode: 0644
homebrew_casks:
- name: openai
repository:
Expand Down
14 changes: 14 additions & 0 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,20 @@ The reviewed checksums cover Linux and macOS hosts on x86_64 and arm64. The
host architecture does not limit the Linux, macOS, and Windows release targets
configured in `.goreleaser.yml`.

Linux packages install one fish startup file at
`/usr/share/fish/vendor_conf.d/openai-picker.fish`. It activates at the first
prompt after user configuration, respects opt-out and custom bindings, and only
runs when `/usr/bin/openai` is the selected executable, including after PATH
changes. Ordinary completion remains under the user's existing shell setup.
The package manager owns and removes the file; installation does not edit user
profiles. Bash and zsh retain their existing first-run setup. See
[shell shortcuts](image-picker-shell-setup.md).

`go run ./scripts/render-linux-picker` generates the fish asset at build time.
Release inputs include it only when the checked-out tag's package configuration
references it, preserving older tags' original inputs. The configured
`termux.deb` format still requires an Android build.

Shell completions and man pages are generated in a separate, read-only job before
the protected publishing job receives repository-write tokens or signing and
notarization credentials. GoReleaser packages those prebuilt files without
Expand Down
25 changes: 25 additions & 0 deletions internal/autocomplete/picker_fish_package.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package autocomplete

import "strings"

// FishPackagePickerScript renders the Linux package's vendor_conf.d script.
// It checks consent at activation and the selected executable at every Tab press.
func FishPackagePickerScript() ([]byte, error) {
return fishPackagePickerScript("/usr/bin/openai")
}

func fishPackagePickerScript(executable string) ([]byte, error) {
guard, err := autoCompleteFS.ReadFile("shellscripts/fish_package_guard.fish")
if err != nil {
return nil, err
}
binary := quotePickerPath(CompletionStyleFish, executable)
check := strings.ReplaceAll(string(guard), "__OPENAI_PACKAGE_EXECUTABLE__", binary)
// The system test supports -ef on older fish versions too. Its absolute
// path keeps automatic activation independent of other executables on PATH.
picker, err := renderPickerCompletionWithGuard(CompletionStyleFish, "openai", "test -x /usr/bin/test; and command /usr/bin/test (command -s openai) -ef "+binary+" 2>/dev/null")
if err != nil {
return nil, err
}
return renderPickerStartup(PickerInstallation{Shell: CompletionStyleFish}, "", picker, check), nil
}
191 changes: 191 additions & 0 deletions internal/autocomplete/picker_fish_package_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
package autocomplete

import (
"context"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"

"github.com/stretchr/testify/require"
)

func TestFishPackagePickerFirstPrompt(t *testing.T) {
fish, err := exec.LookPath("fish")
if err != nil {
if strings.Contains(os.Getenv("OPENAI_CLI_REQUIRE_NATIVE_SHELLS"), "fish") {
t.Fatal(err)
}
t.Skip("fish is unavailable")
}
expect, err := exec.LookPath("expect")
if err != nil {
if strings.Contains(os.Getenv("OPENAI_CLI_REQUIRE_NATIVE_SHELLS"), "fish") {
t.Fatal(err)
}
t.Skip("expect is required for native fish startup")
}
for _, scenario := range []struct {
name, setup string
disabled bool
}{
{"enabled", "", false},
{"stable opt-out", `touch "$HOME/.openai/shell/image-picker.tab-off-fish"`, true},
{"stable dangling opt-out", `ln -s "$HOME/missing" "$HOME/.openai/shell/image-picker.tab-off-fish"`, true},
{"legacy opt-out", `touch "$HOME/.config/openai/image-picker.json.tab-off-fish"`, true},
{"legacy dangling opt-out", `ln -s "$HOME/missing" "$HOME/.config/openai/image-picker.json.tab-off-fish"`, true},
{"late XDG opt-out", "set -gx XDG_CONFIG_HOME $HOME/alternate\ntouch \"$XDG_CONFIG_HOME/openai/image-picker.json.tab-off-fish\"", true},
{"stable opt-out after XDG change", "touch \"$HOME/.openai/shell/image-picker.tab-off-fish\"\nset -gx XDG_CONFIG_HOME $HOME/alternate", true},
{"disabled in config", "openai_picker_disable", true},
{"different executable", "set -gx PATH $HOME/other $PATH", true},
{"different executable after prompt", "", false},
{"function shadows executable", "function openai; end", true},
{"PATH-selected test is ignored", "", false},
{"relative home", "set -gx HOME relative", true},
{"same executable through symlink", "rm \"$HOME/other/openai\"\nln -s \"$HOME/openai\" \"$HOME/other/openai\"\nset -gx PATH $HOME/other $PATH", false},
{"repeated source", `source "$PICKER_TEST_PACKAGE"`, false},
{"personal then package", "source \"$PICKER_TEST_PERSONAL\"\nsource \"$PICKER_TEST_PACKAGE\"", false},
{"package then personal", `source "$PICKER_TEST_PERSONAL"`, false},
} {
t.Run(scenario.name, func(t *testing.T) {
home := t.TempDir()
for _, directory := range []string{".openai/shell", ".config/openai", "alternate/openai", "other"} {
require.NoError(t, os.MkdirAll(filepath.Join(home, directory), 0700))
}
fixture := "#!/bin/sh\nif [ \"$1\" = __complete ]; then printf 'generate\\n'; exit 0; fi\nprintf 'launch\\n' >>\"$PICKER_TEST_RESULT\"\nprintf '\\nPICKER_LAUNCHED\\n'\n"
binary := filepath.Join(home, "openai")
require.NoError(t, os.WriteFile(binary, []byte(fixture), 0700))
require.NoError(t, os.WriteFile(filepath.Join(home, "other", "openai"), []byte("#!/bin/sh\nexit 99\n"), 0700))
if scenario.name == "PATH-selected test is ignored" {
fixture := "#!/bin/sh\nprintf 'unexpected\\n' >>\"$HOME/identity-calls\"\nexit 99\n"
require.NoError(t, os.WriteFile(filepath.Join(home, "test"), []byte(fixture), 0700))
}
packageScript, err := fishPackagePickerScript(binary)
require.NoError(t, err)
if runtime.GOOS == "darwin" {
// The asset targets Linux; macOS keeps its system test in /bin.
packageScript = []byte(strings.ReplaceAll(string(packageScript), "/usr/bin/test", "/bin/test"))
}
personalScript, err := renderInstalledPicker(PickerInstallation{Shell: CompletionStyleFish, OptOutPath: filepath.Join(home, ".openai", "shell", "image-picker.tab-off-fish")})
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(home, "package.fish"), packageScript, 0600))
require.NoError(t, os.WriteFile(filepath.Join(home, "personal.fish"), personalScript, 0600))
startup := `source "$PICKER_TEST_PACKAGE"
if set -q __openai_picker_modes; printf 'early\n' >>"$PICKER_TEST_RESULT"; end
fish_default_key_bindings
function custom_tab; printf 'fallback\n' >>"$PICKER_TEST_RESULT"; end
bind \t custom_tab
function fish_prompt
if not set -q __package_test_prompt_seen
set -g __package_test_prompt_seen 1
if set -q __openai_picker_modes[1]; printf 'active\n'; else; printf 'off\n'; end >>"$PICKER_TEST_RESULT"
if functions -q __openai_picker_install_on_prompt; printf 'pending\n' >>"$PICKER_TEST_RESULT"; end
end
printf 'PICKER_TEST> '
end
` + scenario.setup + "\n"
require.NoError(t, os.WriteFile(filepath.Join(home, "startup.fish"), []byte(startup), 0600))
driver := `set timeout 8
spawn -noecho $env(PICKER_TEST_SHELL) --no-config --interactive --init-command {source "$PICKER_TEST_STARTUP"}
expect_before {
-exact "\033\1330c" {send -- "\033\133?1;2c"; exp_continue}
-exact "\033\1336n" {send -- "\033\1331;1R"; exp_continue}
}
expect_after timeout { puts stderr "fish package probe timed out"; exit 1 }
expect -exact "PICKER_TEST> "
`
// Exercise Tab at the very first prompt: a nested prompt callback
// would leave this command on the ordinary custom-binding path.
changedExecutable := scenario.name == "different executable after prompt"
if changedExecutable {
driver += `send -- {set -gx PATH "$HOME/other" $PATH}
send -- "\r"
expect -exact "PICKER_TEST> "
`
}
if !scenario.disabled && !changedExecutable {
driver += `send -- "openai images generate\t"
expect -exact "PICKER_LAUNCHED\r\n"
expect -exact "PICKER_TEST> "
send -- "\025other\t\025printf 'FALLBACK_DONE\\n'\r"
expect -exact "FALLBACK_DONE\r\n"
expect -exact "PICKER_TEST> "
send -- "openai_picker_disable; printf 'DISABLED\\n'\r"
expect -exact "DISABLED\r\n"
expect -exact "PICKER_TEST> "
`
}
driver += `send -- "openai images generate\t\025printf 'RESTORED\\n'\r"
expect -exact "RESTORED\r\n"
expect -exact "PICKER_TEST> "
`
if changedExecutable {
driver += `send -- "openai_picker_disable\r"
expect -exact "PICKER_TEST> "
`
}
driver += `send -- {if set -q __openai_picker_modes[1]; printf 'reactivated\n' >>"$PICKER_TEST_RESULT"; end; if functions -q __openai_picker_install_on_prompt; printf 'pending\n' >>"$PICKER_TEST_RESULT"; end}
send -- "\r"
expect -exact "PICKER_TEST> "
send -- "exit\r"
expect eof
`
require.NoError(t, os.WriteFile(filepath.Join(home, "driver.exp"), []byte(driver), 0600))
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
defer cancel()
command := exec.CommandContext(ctx, expect, filepath.Join(home, "driver.exp"))
command.Dir = home
command.Env = []string{
"HOME=" + home, "PATH=" + home + ":" + os.Getenv("PATH"), "TERM=xterm-256color", "LC_ALL=C",
"PICKER_TEST_SHELL=" + fish, "PICKER_TEST_STARTUP=" + filepath.Join(home, "startup.fish"),
"PICKER_TEST_PACKAGE=" + filepath.Join(home, "package.fish"), "PICKER_TEST_PERSONAL=" + filepath.Join(home, "personal.fish"),
"PICKER_TEST_RESULT=" + filepath.Join(home, "result"),
}
output, err := command.CombinedOutput()
require.NoError(t, err, string(output))
result, err := os.ReadFile(filepath.Join(home, "result"))
require.NoError(t, err)
want := "active\nlaunch\nfallback\nfallback\n"
if scenario.disabled {
want = "off\nfallback\n"
} else if changedExecutable {
want = "active\nfallback\n"
}
require.Equal(t, want, string(result), string(output))
if scenario.name == "PATH-selected test is ignored" {
_, err := os.Stat(filepath.Join(home, "identity-calls"))
require.ErrorIs(t, err, os.ErrNotExist, "startup and Tab must ignore a PATH-selected test executable")
}
})
}
}

func TestFishPackagePreservesOrdinaryCompletions(t *testing.T) {
fish, err := exec.LookPath("fish")
if err != nil {
if strings.Contains(os.Getenv("OPENAI_CLI_REQUIRE_NATIVE_SHELLS"), "fish") {
t.Fatal(err)
}
t.Skip("fish is unavailable")
}
home := t.TempDir()
script, err := fishPackagePickerScript(filepath.Join(home, "package-openai"))
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(home, "package.fish"), script, 0600))
fixture := "#!/bin/sh\nprintf '%s\\n' \"$*\" >>\"$PICKER_TEST_CALLS\"\nprintf 'unrelated-completion\\n'\n"
require.NoError(t, os.WriteFile(filepath.Join(home, "openai"), []byte(fixture), 0700))
command := exec.CommandContext(t.Context(), fish, "--no-config", "-c", `
complete -c openai -f -a preserved-completion
source "$HOME/package.fish"
complete -C 'openai '
`)
command.Env = []string{"HOME=" + home, "PATH=" + home + ":" + os.Getenv("PATH"), "PICKER_TEST_CALLS=" + filepath.Join(home, "calls")}
output, err := command.CombinedOutput()
require.NoError(t, err, string(output))
require.Equal(t, "preserved-completion\n", string(output))
_, err = os.Stat(filepath.Join(home, "calls"))
require.ErrorIs(t, err, os.ErrNotExist, "package sourcing must not route ordinary completion to another executable")
}
10 changes: 8 additions & 2 deletions internal/autocomplete/picker_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,12 @@ func renderInstalledPicker(options PickerInstallation) ([]byte, error) {
if err != nil {
return nil, err
}
return renderPickerStartup(options, completion, picker, ""), nil
}

// Package startup leaves ordinary completion to the user's existing setup.
// Both personal and package scripts share activation and disable handling.
func renderPickerStartup(options PickerInstallation, completion, picker, fishPromptGuard string) []byte {
preamble, marker := "", ""
switch options.Shell {
case CompletionStyleZsh:
Expand Down Expand Up @@ -539,12 +545,12 @@ function openai_picker_disable
end
` + declined + `function __openai_picker_install_on_prompt --on-event fish_prompt
functions --erase __openai_picker_install_on_prompt
` + declined + picker + `
` + declined + fishPromptGuard + picker + `
if set -q __openai_picker_modes[1]; set -gx OPENAI_PICKER_INTEGRATION fish; end
end
`
}
return []byte(pickerScriptHeader + preamble + completion + "\n" + picker + marker), nil
return []byte(pickerScriptHeader + preamble + completion + "\n" + picker + marker)
}

func openPickerDirectory(ctx context.Context, path string, create bool) (*os.Root, error) {
Expand Down
Loading
Loading