Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
779e3e5
Fix Codex marketplace detection and fallback in agent setup (#2034)
markguan-stripe Sep 17, 2026
8f31f37
Fix deprecation message version for removed commands (#2049)
yahanxing-stripe Sep 17, 2026
c856aba
Fix the listen canary for the new event flags (#2015)
yahanxing-stripe Sep 17, 2026
63df639
Check for a plugin upgrade at most once every few hours (#2047)
vzhang-stripe Sep 17, 2026
1ed0980
Don't auto-upgrade a plugin to answer `--help` (#2050)
vzhang-stripe Sep 18, 2026
e9b8f05
Record the v2 layout when creating a new config file (#2054)
yahanxing-stripe Sep 18, 2026
77d4ccf
Migrate profiles whose names collide with CLI settings (#2053)
yahanxing-stripe Sep 18, 2026
a107a50
Print plugin failures that happened before the plugin ran (#2055)
yahanxing-stripe Sep 18, 2026
1233985
Stop narrating the config migration (#2056)
yahanxing-stripe Sep 18, 2026
370846b
Don't auto-upgrade a plugin in a directory the user pointed us at (#2…
vzhang-stripe Sep 18, 2026
afbfd85
Retry winget install test on a fresh runner instead of in-place (#2060)
vcheung-stripe Sep 19, 2026
d9aaa68
Expose resumable, non-blocking OAuth login RPCs to plugins (#2058)
vcheung-stripe Sep 21, 2026
856788a
Notify CLI reviewers of ready pull requests (#2068)
yahanxing-stripe Sep 22, 2026
39798ca
Add automatic updates for CLIs installed with the install script (#2067)
yahanxing-stripe Sep 22, 2026
7802e70
Confirm the auto-update setting after changing it (#2071)
vzhang-stripe Sep 22, 2026
2c6138e
add grok provider
adelechen2-stripe Sep 17, 2026
e885792
detect grok agent if active
adelechen2-stripe Sep 17, 2026
b11750e
resolve comments and update host detection
adelechen2-stripe Sep 18, 2026
0ec29ae
resolve copilot comments
adelechen2-stripe Sep 18, 2026
6af5b1d
lint
adelechen2-stripe Sep 18, 2026
d9d059e
fix status message
adelechen2-stripe Sep 18, 2026
f23b046
fix test
adelechen2-stripe Sep 18, 2026
676ce9e
add hermes detection from pr 2030
adelechen2-stripe Sep 21, 2026
883de2f
fmt
adelechen2-stripe Sep 21, 2026
4b37a53
clear env vars after a test
adelechen2-stripe Sep 22, 2026
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
379 changes: 368 additions & 11 deletions .github/workflows/autoupgrade-test.yml

Large diffs are not rendered by default.

57 changes: 56 additions & 1 deletion .github/workflows/install-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,44 @@ jobs:
- id: install_test
shell: powershell
run: bash scripts/install-test.sh scoop
winget:
# winget install failures are often specific to the runner's local WinGet
# source/cache state, so a failure retries on a brand-new windows-latest
# runner rather than just rerunning the install inside the same VM.
winget-attempt-1:
runs-on: windows-latest
outputs:
install_test_result: ${{ steps.install_test.outcome }}
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
sparse-checkout: |
scripts/install-test.sh
sparse-checkout-cone-mode: false
persist-credentials: false
- id: install_test
shell: powershell
run: bash scripts/install-test.sh winget

winget-attempt-2:
needs: winget-attempt-1
if: needs.winget-attempt-1.outputs.install_test_result == 'failure'
runs-on: windows-latest
outputs:
install_test_result: ${{ steps.install_test.outcome }}
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
sparse-checkout: |
scripts/install-test.sh
sparse-checkout-cone-mode: false
persist-credentials: false
- id: install_test
shell: powershell
run: bash scripts/install-test.sh winget

winget-attempt-3:
needs: winget-attempt-2
if: always() && needs.winget-attempt-2.outputs.install_test_result == 'failure'
runs-on: windows-latest
outputs:
install_test_result: ${{ steps.install_test.outcome }}
Expand All @@ -105,6 +142,24 @@ jobs:
- id: install_test
shell: powershell
run: bash scripts/install-test.sh winget

winget:
needs: [winget-attempt-1, winget-attempt-2, winget-attempt-3]
if: always()
runs-on: ubuntu-latest
outputs:
install_test_result: ${{ steps.result.outputs.result }}
steps:
- id: result
run: |
if [ "${{ needs.winget-attempt-1.outputs.install_test_result }}" = "success" ] || \
[ "${{ needs.winget-attempt-2.outputs.install_test_result }}" = "success" ] || \
[ "${{ needs.winget-attempt-3.outputs.install_test_result }}" = "success" ]; then
echo "result=success" >> "$GITHUB_OUTPUT"
else
echo "result=failure" >> "$GITHUB_OUTPUT"
fi

docker:
runs-on: ubuntu-latest
# Deliberately unpinned: this canary exists to verify that the *currently
Expand Down
57 changes: 57 additions & 0 deletions .github/workflows/notify-developer-products-review.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: Notify developer-products review

on:
pull_request_target:
types: [review_requested]

permissions: {}

jobs:
notify:
if: >-
github.event.requested_team.slug == 'developer-products' &&
github.event.pull_request.draft == false
runs-on: ubuntu-latest
steps:
- name: Notify Slack
env:
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_URL: ${{ github.event.pull_request.html_url }}
REPOSITORY: ${{ github.repository }}
SLACK_GROUP_ID: ${{ vars.SLACK_STRIPE_CLI_ENG_GROUP_ID }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_STRIPE_CLI_PRS_WEBHOOK_URL }}
shell: bash
run: |
payload="$(
jq --null-input \
--arg author "$PR_AUTHOR" \
--arg group_id "$SLACK_GROUP_ID" \
--arg number "$PR_NUMBER" \
--arg repository "$REPOSITORY" \
--arg title "$PR_TITLE" \
--arg url "$PR_URL" \
'
def slack_text:
gsub("&"; "&")
| gsub("<"; "&lt;")
| gsub(">"; "&gt;")
| gsub("[\\r\\n]+"; " ");

{
text: (
"<!subteam^" + $group_id + "> Developer Products review requested.\n" +
"*" + ($repository | slack_text) + "#" + $number + "* — " +
"<" + $url + "|" + ($title | slack_text) + ">\n" +
"Author: `" + ($author | slack_text) + "`"
)
}
'
)"

curl --fail-with-body --silent --show-error \
--request POST \
--header 'Content-Type: application/json' \
--data "$payload" \
"$SLACK_WEBHOOK_URL"
4 changes: 3 additions & 1 deletion canary/listen_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,9 @@ func TestAPIListenForwardTo(t *testing.T) {
"STRIPE_API_KEY": testutil.GetAPIKey(),
}))

listen, err := runner.RunBackground("listen", "--forward-to", server.URL)
// --forward-to needs an explicit subscription; --all-snapshot is what
// forwarding every snapshot event is spelled as now.
listen, err := runner.RunBackground("listen", "--all-snapshot", "--forward-to", server.URL)
if err != nil {
fatalf(t, "Failed to start listen: %v", err)
}
Expand Down
11 changes: 11 additions & 0 deletions cmd/stripe/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

goversion "github.com/hashicorp/go-version"

"github.com/stripe/stripe-cli/pkg/autoupdate"
"github.com/stripe/stripe-cli/pkg/cmd"
"github.com/stripe/stripe-cli/pkg/reporting"
"github.com/stripe/stripe-cli/pkg/stripe"
Expand All @@ -18,6 +19,16 @@ import (
const sentryDSN = "https://0e1c83fa780a5946e14bfc0f6d0a7ddd@errors.stripe.com/11762"

func main() {
// Apply a pending update before anything else: this re-execs the new binary,
// so the command the user typed runs on the version they are being moved to.
autoupdate.ApplyIfPending()

// Check for the next update after the command has run, so the network call
// never delays it. Deferred rather than called at the end of main because
// every branch below returns early, and the check has to happen on all of
// them. This still does not cover cmd.Execute's os.Exit on command failure.
defer autoupdate.CheckForUpdate()

ctx := context.Background()

if stripe.TelemetryOptedOut(os.Getenv("STRIPE_CLI_TELEMETRY_OPTOUT")) || stripe.TelemetryOptedOut(os.Getenv("DO_NOT_TRACK")) {
Expand Down
88 changes: 66 additions & 22 deletions pkg/agentsetup/codex.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,27 @@ import (
)

const (
ClientCodex = "codex"
CodexBinaryName = "codex"
CodexPluginName = "stripe"
CodexMarketplace = "openai-curated"
TargetCodexPlugin = "stripe@openai-curated"
CodexDisplayName = "Codex CLI"
ClientCodex = "codex"
CodexBinaryName = "codex"
CodexPluginName = "stripe"
CodexDisplayName = "Codex CLI"

codexListTimeout = 5 * time.Second
)

// Official Codex marketplaces for ChatGPT and API-key users, respectively:
// https://github.com/openai/plugins/blob/main/.agents/plugins/marketplace.json#L2
// https://github.com/openai/plugins/blob/main/.agents/plugins/api_marketplace.json
var codexMarketplaces = [...]string{"openai-curated", "openai-api-curated"}

// RunOutputFunc runs a command and returns its standard output. It exists so
// Codex detection (which shells out to `codex plugin list --json`) is testable.
type RunOutputFunc func(context.Context, string, ...string) ([]byte, error)

// CodexProvider detects and installs the Stripe plugin for Codex CLI.
//
// Codex has a real plugin CLI, so detection runs `codex plugin list --json` and
// installation runs `codex plugin add stripe@openai-curated`.
// Detection selects the first available supported marketplace, and installation
// runs `codex plugin add stripe@<marketplace>` using that selection.
type CodexProvider struct {
Scanner Scanner
RunCommand RunCommandFunc
Expand Down Expand Up @@ -67,17 +70,24 @@ func (p CodexProvider) Detect() Status {
status.ExecutablePath = binPath
status.Status = StatusMissing

marketplace, err := p.marketplace(context.Background())
if err != nil {
status.Status = StatusError
status.Error = err.Error()
return status
}
status.Plugin.ID = CodexPluginName + "@" + marketplace

ctx, cancel := context.WithTimeout(context.Background(), codexListTimeout)
defer cancel()

version, ok, supportsPlugins := p.stripePluginStatus(ctx)
version, ok, supportsPlugins := p.stripePluginStatus(ctx, marketplace)
if !supportsPlugins {
status.Error = "upgrade Codex to enable plugin support"
return status
}
if ok {
status.Plugin.Installed = true
status.Plugin.ID = TargetCodexPlugin
status.Plugin.Version = version
status.Plugin.Scope = "user"
status.Status = StatusInstalled
Expand All @@ -86,25 +96,57 @@ func (p CodexProvider) Detect() Status {
return status
}

// marketplace selects the first available supported marketplace.
func (p CodexProvider) marketplace(ctx context.Context) (string, error) {
ctx, cancel := context.WithTimeout(ctx, codexListTimeout)
defer cancel()
runOutput := p.RunOutput
if runOutput == nil {
runOutput = runCommandOutput
}
out, err := runOutput(ctx, CodexBinaryName, "plugin", "marketplace", "list", "--json")
var list struct {
Marketplaces []struct {
Name string `json:"name"`
} `json:"marketplaces"`
}
if err == nil {
err = json.Unmarshal(out, &list)
}
if err != nil {
return "", errorcategory.Errorf(errorcategory.Internal, "listing Codex marketplaces: %w", err)
}

for _, marketplace := range codexMarketplaces {
for _, available := range list.Marketplaces {
if available.Name == marketplace {
return marketplace, nil
}
}
}
return "", errorcategory.Errorf(errorcategory.Internal, "no supported Codex marketplace is available; expected %s", strings.Join(codexMarketplaces[:], " or "))
}

// stripePluginStatus runs `codex plugin list --json` and reports whether (1)
// the command is supported (supportsPlugins), and if so (2) whether the Stripe
// plugin is installed and its version. When the command fails (e.g. old Codex
// version without plugin support), supportsPlugins is false.
func (p CodexProvider) stripePluginStatus(ctx context.Context) (version string, installed bool, supportsPlugins bool) {
func (p CodexProvider) stripePluginStatus(ctx context.Context, marketplace string) (version string, installed bool, supportsPlugins bool) {
runOutput := p.RunOutput
if runOutput == nil {
runOutput = runCommandOutput
}
out, err := runOutput(ctx, CodexBinaryName, "plugin", "list", "--json")
// The unfiltered list can omit locally installed curated plugins.
out, err := runOutput(ctx, CodexBinaryName, "plugin", "list", "--marketplace", marketplace, "--json")
if err != nil {
return "", false, false
}
v, ok := findCodexStripePlugin(out)
v, ok := findCodexStripePlugin(out, marketplace)
return v, ok, true
}

func (p CodexProvider) Plan(status Status, force bool) Plan {
command := []string{CodexBinaryName, "plugin", "add", TargetCodexPlugin}
command := []string{CodexBinaryName, "plugin", "add", status.Plugin.ID}

switch {
case status.Status == StatusError:
Expand All @@ -131,16 +173,18 @@ func (p CodexProvider) Apply(ctx context.Context, _ io.Writer, plan Plan) error
if runCommand == nil {
runCommand = RunCommand
}
pluginID := plan.Command[len(plan.Command)-1]
_, marketplace, _ := strings.Cut(pluginID, "@")
if err := runCommand(ctx, plan.Command[0], plan.Command[1:]...); err != nil {
return err
return errorcategory.Errorf(errorcategory.Internal, "could not install the Stripe plugin from %s: %w", marketplace, err)
}

// `codex plugin add` exits 0 even when it fails (e.g. the marketplace is not
// configured), so the exit code cannot be trusted. Confirm the plugin is
// actually installed before reporting success.
if _, installed, _ := p.stripePluginStatus(ctx); !installed {
if _, installed, _ := p.stripePluginStatus(ctx, marketplace); !installed {
return errorcategory.Errorf(errorcategory.Internal, "codex reported success but %s is not installed; run `%s` to see the underlying error",
TargetCodexPlugin, strings.Join(plan.Command, " "))
pluginID, strings.Join(plan.Command, " "))
}
return nil
}
Expand All @@ -163,26 +207,26 @@ type codexInstalledPlugin struct {

// findCodexStripePlugin reports whether the Stripe plugin appears in the
// installed list and returns its version when available.
func findCodexStripePlugin(listJSON []byte) (string, bool) {
func findCodexStripePlugin(listJSON []byte, marketplace string) (string, bool) {
var list codexPluginList
if err := json.Unmarshal(listJSON, &list); err != nil {
return "", false
}

for _, plugin := range list.Installed {
if codexPluginIsStripe(plugin) {
if codexPluginIsStripe(plugin, marketplace) {
return plugin.Version, true
}
}
return "", false
}

func codexPluginIsStripe(plugin codexInstalledPlugin) bool {
if strings.EqualFold(plugin.PluginID, TargetCodexPlugin) {
func codexPluginIsStripe(plugin codexInstalledPlugin, marketplace string) bool {
if strings.EqualFold(plugin.PluginID, CodexPluginName+"@"+marketplace) {
return true
}
return strings.EqualFold(plugin.Name, CodexPluginName) &&
strings.EqualFold(plugin.Marketplace, CodexMarketplace)
strings.EqualFold(plugin.Marketplace, marketplace)
}

func runCommandOutput(ctx context.Context, name string, args ...string) ([]byte, error) {
Expand Down
Loading
Loading