
Agentmail
- 3 installs
- 35 repo stars
- Updated August 3, 2026
- agentmail-to/agentmail-cli
agentmail (CLI) is a Claude Code skill that drives the agentmail command-line tool to send and receive email programmatically.
About
A Claude Code skill that drives the agentmail command-line tool to send and receive email programmatically. It documents CLI commands for creating inboxes, sending and replying to messages, listing threads, and managing drafts and pods. A developer uses it to script email actions from the shell without writing SDK code.
- Wraps the agentmail CLI to send and receive email from the terminal
- Commands for inboxes, messages, threads, drafts, and pods
- Requires only the AGENTMAIL_API_KEY environment variable
Agentmail by the numbers
- 3 all-time installs (skills.sh)
- Ranked #435 of 550 CLI & Terminal skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
agentmail capabilities & compatibility
Requires AGENTMAIL_API_KEY; AgentMail account needed.
- Capabilities
- email automation · cli tooling
- Works with
- gmail
- Use cases
- Pricing
- Bring your own API key
What agentmail says it does
Use the `agentmail` CLI to send and receive emails programmatically. Requires `AGENTMAIL_API_KEY` environment variable.
Pods group inboxes together.
npx skills add https://github.com/agentmail-to/agentmail-cli --skill agentmailAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3 |
|---|---|
| repo stars | ★ 35 |
| Last updated | August 3, 2026 |
| Repository | agentmail-to/agentmail-cli ↗ |
What it does
Send, read, and reply to email from the terminal using the agentmail CLI.
Who is it for?
Scripting email send/receive from the command line via the agentmail CLI.
Skip if: Building programmatic email agents in application code (use the SDK skill instead).
When should I use this skill?
Sending or receiving email programmatically from the terminal with the agentmail CLI.
What you get
Email sent, read, and replied to from the terminal through the agentmail CLI.
- Terminal email commands
- Inbox and message management from the shell
By the numbers
- Covers 5 command groups: inboxes, messages, threads, drafts, and pods
Files
AgentMail CLI
Use the agentmail CLI to send and receive emails programmatically. Requires AGENTMAIL_API_KEY environment variable.
Install
npm install -g agentmail-cliCore Commands
Inboxes
# Create an inbox
agentmail inboxes create --display-name "My Agent" --username myagent --domain example.com
# List inboxes
agentmail inboxes list
# Get an inbox
agentmail inboxes retrieve --inbox-id <inbox_id>
# Delete an inbox
agentmail inboxes delete --inbox-id <inbox_id>Send Email
# Send a message from an inbox
agentmail inboxes:messages send --inbox-id <inbox_id> \
--to "recipient@example.com" \
--subject "Hello" \
--text "Message body"
# Send with HTML
agentmail inboxes:messages send --inbox-id <inbox_id> \
--to "recipient@example.com" \
--subject "Hello" \
--html "<h1>Hello</h1>"
# Reply to a message
agentmail inboxes:messages reply --inbox-id <inbox_id> --message-id <message_id> \
--text "Reply body"
# Forward a message
agentmail inboxes:messages forward --inbox-id <inbox_id> --message-id <message_id> \
--to "someone@example.com"Read Email
# List messages in an inbox
agentmail inboxes:messages list --inbox-id <inbox_id>
# Get a specific message
agentmail inboxes:messages retrieve --inbox-id <inbox_id> --message-id <message_id>
# List threads
agentmail inboxes:threads list --inbox-id <inbox_id>
# Get a thread
agentmail inboxes:threads retrieve --inbox-id <inbox_id> --thread-id <thread_id>Drafts
# Create a draft
agentmail inboxes:drafts create --inbox-id <inbox_id> \
--to "recipient@example.com" \
--subject "Draft" \
--text "Draft body"
# Send a draft
agentmail inboxes:drafts send --inbox-id <inbox_id> --draft-id <draft_id>Pods
Pods group inboxes together.
# Create a pod
agentmail pods create --name "My Pod"
# Create an inbox in a pod
agentmail pods:inboxes create --pod-id <pod_id> --display-name "Pod Inbox"
# List threads in a pod
agentmail pods:threads list --pod-id <pod_id>Webhooks
# Create a webhook for new messages
agentmail webhooks create --url "https://example.com/webhook" --event-type message.received
# List webhooks
agentmail webhooks listDomains
# Add a custom domain
agentmail domains create --domain example.com --feedback-enabled false
# Verify domain DNS
agentmail domains verify --domain-id <domain_id>
# Get DNS records to configure
agentmail domains get-zone-file --domain-id <domain_id>Global Flags
All commands support: --api-key, --base-url, --environment, --format, --debug.
Output Formats
Use --format to control output: json (default), pretty, yaml, jsonl, raw, explore.
name: CI
on:
push:
branches-ignore:
- 'generated'
- 'codegen/**'
- 'integrated/**'
- 'stl-preview-head/**'
- 'stl-preview-base/**'
pull_request:
branches-ignore:
- 'stl-preview-head/**'
- 'stl-preview-base/**'
jobs:
lint:
timeout-minutes: 10
name: lint
runs-on: ${{ github.repository == 'stainless-sdks/agentmail-cli' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
if: github.event_name == 'push' || github.event.pull_request.head.repo.fork
steps:
- uses: actions/checkout@v6
- name: Setup go
uses: actions/setup-go@v5
with:
go-version-file: ./go.mod
- name: Bootstrap
run: ./scripts/bootstrap
- name: Run lints
run: ./scripts/lint
build:
timeout-minutes: 10
name: build
permissions:
contents: read
id-token: write
runs-on: ${{ github.repository == 'stainless-sdks/agentmail-cli' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
if: github.event_name == 'push' || github.event.pull_request.head.repo.fork
steps:
- uses: actions/checkout@v6
- name: Setup go
uses: actions/setup-go@v5
with:
go-version-file: ./go.mod
- name: Bootstrap
run: ./scripts/bootstrap
- name: Run goreleaser
uses: goreleaser/goreleaser-action@v6.1.0
with:
version: latest
args: release --snapshot --clean --skip=publish
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Get GitHub OIDC Token
if: github.repository == 'stainless-sdks/agentmail-cli'
id: github-oidc
uses: actions/github-script@v8
with:
script: core.setOutput('github_token', await core.getIDToken());
- name: Upload tarball
if: github.repository == 'stainless-sdks/agentmail-cli'
env:
URL: https://pkg.stainless.com/s
AUTH: ${{ steps.github-oidc.outputs.github_token }}
SHA: ${{ github.sha }}
run: ./scripts/utils/upload-artifact.sh
test:
timeout-minutes: 10
name: test
runs-on: ${{ github.repository == 'stainless-sdks/agentmail-cli' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
if: github.event_name == 'push' || github.event.pull_request.head.repo.fork
steps:
- uses: actions/checkout@v6
- name: Setup go
uses: actions/setup-go@v5
with:
go-version-file: ./go.mod
- name: Bootstrap
run: ./scripts/bootstrap
- name: Run tests
run: ./scripts/test
---
name: Publish Release
permissions:
contents: write
concurrency:
group: publish
on:
push:
tags:
- "v*"
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: "go.mod"
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6.1.0
with:
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
publish-npm:
needs: goreleaser
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
registry-url: https://registry.npmjs.org
- name: Update npm package version
working-directory: npm
run: |
VERSION="${GITHUB_REF_NAME#v}"
npm pkg set binaryVersion="$VERSION"
npm version "$VERSION" --no-git-tag-version --allow-same-version
- name: Publish to npm
working-directory: npm
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}name: Release Doctor
on:
pull_request:
branches:
- main
workflow_dispatch:
jobs:
release_doctor:
name: release doctor
runs-on: ubuntu-latest
if: github.repository == 'agentmail-to/agentmail-cli' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-please') || github.head_ref == 'next')
steps:
- uses: actions/checkout@v6
- name: Check release environment
run: |
bash ./bin/check-release-environment
.prism.log
dist/
/agentmail
*.exe
project_name: agentmail
version: 2
before:
hooks:
- mkdir -p completions
- sh -c "go run ./cmd/agentmail/main.go @completion bash > completions/agentmail.bash"
- sh -c "go run ./cmd/agentmail/main.go @completion zsh > completions/agentmail.zsh"
- sh -c "go run ./cmd/agentmail/main.go @completion fish > completions/agentmail.fish"
- sh -c "go run ./cmd/agentmail/main.go @manpages -o man"
builds:
- id: macos
goos: [darwin]
goarch: [amd64, arm64]
binary: '{{ .ProjectName }}'
main: ./cmd/agentmail/main.go
mod_timestamp: '{{ .CommitTimestamp }}'
ldflags:
- '-s -w -X main.version={{.Version}} -X main.commit={{.Commit}}'
- id: linux
goos: [linux]
goarch: ['386', arm, amd64, arm64]
env:
- CGO_ENABLED=0
binary: '{{ .ProjectName }}'
main: ./cmd/agentmail/main.go
mod_timestamp: '{{ .CommitTimestamp }}'
ldflags:
- '-s -w -X main.version={{.Version}} -X main.commit={{.Commit}}'
- id: windows
goos: [windows]
goarch: ['386', amd64, arm64]
binary: '{{ .ProjectName }}'
main: ./cmd/agentmail/main.go
mod_timestamp: '{{ .CommitTimestamp }}'
ldflags:
- '-s -w -X main.version={{.Version}} -X main.commit={{.Commit}}'
archives:
- id: linux-archive
ids: [linux]
name_template: '{{ .ProjectName }}_{{ .Version }}_linux_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}'
formats: [tar.gz]
files:
- completions/*
- man/*/*
- id: macos-archive
ids: [macos]
name_template: '{{ .ProjectName }}_{{ .Version }}_macos_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}'
formats: [zip]
files:
- completions/*
- man/*/*
- id: windows-archive
ids: [windows]
name_template: '{{ .ProjectName }}_{{ .Version }}_windows_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}'
formats: [zip]
files:
- completions/*
- man/*/*
snapshot:
version_template: '{{ .Tag }}-next'
nfpms:
- license: Apache-2.0
maintainer:
bindir: /usr
formats:
- apk
- deb
- rpm
- termux.deb
- archlinux
contents:
- src: man/man1/*.1.gz
dst: /usr/share/man/man1/
homebrew_casks:
- name: agentmail
repository:
owner: agentmail-to
name: homebrew-tap
token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
homepage: https://agentmail.to
description: The official CLI for AgentMail.
license: Apache-2.0
binary: "agentmail"
completions:
bash: "completions/agentmail.bash"
zsh: "completions/agentmail.zsh"
fish: "completions/agentmail.fish"
manpages:
- man/man1/agentmail.1.gz
notarize:
macos:
- enabled: '{{ isEnvSet "MACOS_SIGN_P12" }}'
ids: [macos]
sign:
certificate: "{{.Env.MACOS_SIGN_P12}}"
password: "{{.Env.MACOS_SIGN_PASSWORD}}"
notarize:
issuer_id: "{{.Env.MACOS_NOTARY_ISSUER_ID}}"
key_id: "{{.Env.MACOS_NOTARY_KEY_ID}}"
key: "{{.Env.MACOS_NOTARY_KEY}}"
{
".": "0.7.1"
}configured_endpoints: 62
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/agentmail%2Fagentmail-161e9f5d786cbd431b293059ed3202a30936b4ec47aa100dd2344b6cc4b77ec0.yml
openapi_spec_hash: b4e026c4a83dab7c8766a9179377674d
config_hash: 2595ffb55fb009abe18a6566962fbf0a
#!/usr/bin/env bash
errors=()
lenErrors=${#errors[@]}
if [[ lenErrors -gt 0 ]]; then
echo -e "Found the following errors in the release environment:\n"
for error in "${errors[@]}"; do
echo -e "- $error\n"
done
exit 1
fi
echo "The environment is ready to push releases!"
Changelog
0.7.1 (2026-03-06)
Full Changelog: v0.7.0...v0.7.1
0.7.0 (2026-03-06)
Full Changelog: v0.6.0...v0.7.0
Features
- api: api update (36258c7)
0.6.0 (2026-03-05)
Full Changelog: v0.5.0...v0.6.0
Features
- api: api update (0ba315c)
0.5.0 (2026-03-05)
Full Changelog: v0.4.2...v0.5.0
Features
- api: api update (3a21b41)
Bug Fixes
- avoid printing usage errors twice (e90106c)
0.4.2 (2026-03-04)
Full Changelog: v0.4.1...v0.4.2
0.4.1 (2026-03-03)
Full Changelog: v0.4.0...v0.4.1
0.4.0 (2026-03-02)
Full Changelog: v0.3.0...v0.4.0
Features
- api: api update (76ce4cd)
0.3.0 (2026-03-02)
Full Changelog: v0.2.0...v0.3.0
Features
- api: api update (37faa18)
0.2.0 (2026-03-02)
Full Changelog: v0.1.0...v0.2.0
Features
- api: api update (a599bfb)
0.1.0 (2026-03-02)
Full Changelog: v0.0.2...v0.1.0
Features
0.0.2 (2026-03-02)
Full Changelog: v0.0.1...v0.0.2
Chores
package main
import (
"fmt"
"os"
)
const banner = `
___ __ __ _ __
/ _ |___ ____ ___ ____/ /_/ |/ __(_) /
/ __ / _ ` + "`" + `/ -_) _ \/ __/ __/ /|_/ / _ / /
/_/ |_\_, /\__/_//_/\__/\__/_/ /_/\__/_/
/___/
`
func init() {
if len(os.Args) == 1 {
fmt.Print(banner)
}
}
// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
package main
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"slices"
"github.com/agentmail-to/agentmail-cli/pkg/cmd"
"github.com/agentmail-to/agentmail-go"
"github.com/tidwall/gjson"
"github.com/urfave/cli/v3"
)
func main() {
app := cmd.Command
if slices.Contains(os.Args, "__complete") {
prepareForAutocomplete(app)
}
if err := app.Run(context.Background(), os.Args); err != nil {
exitCode := 1
// Check if error has a custom exit code
if exitErr, ok := err.(cli.ExitCoder); ok {
exitCode = exitErr.ExitCode()
}
var apierr *agentmail.Error
if errors.As(err, &apierr) {
fmt.Fprintf(os.Stderr, "%s %q: %d %s\n", apierr.Request.Method, apierr.Request.URL, apierr.Response.StatusCode, http.StatusText(apierr.Response.StatusCode))
format := app.String("format-error")
json := gjson.Parse(apierr.RawJSON())
show_err := cmd.ShowJSON(os.Stdout, "Error", json, format, app.String("transform-error"))
if show_err != nil {
// Just print the original error:
fmt.Fprintf(os.Stderr, "%s\n", err.Error())
}
} else {
if cmd.CommandErrorBuffer.Len() > 0 {
os.Stderr.Write(cmd.CommandErrorBuffer.Bytes())
} else {
fmt.Fprintf(os.Stderr, "%s\n", err.Error())
}
}
os.Exit(exitCode)
}
}
func prepareForAutocomplete(cmd *cli.Command) {
// urfave/cli does not handle flag completions and will print an error if we inspect a command with invalid flags.
// This skips that sort of validation
cmd.SkipFlagParsing = true
for _, child := range cmd.Commands {
prepareForAutocomplete(child)
}
}
module github.com/agentmail-to/agentmail-cli
go 1.25
require (
github.com/agentmail-to/agentmail-go v0.0.0-20260302203709-5d5f48c3a4bf
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.6
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/x/term v0.2.1
github.com/goccy/go-yaml v1.18.0
github.com/itchyny/json2yaml v0.1.4
github.com/muesli/reflow v0.3.0
github.com/stretchr/testify v1.10.0
github.com/tidwall/gjson v1.18.0
github.com/tidwall/pretty v1.2.1
github.com/urfave/cli-docs/v3 v3.0.0-alpha6
github.com/urfave/cli/v3 v3.3.2
golang.org/x/sys v0.38.0
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.9.3 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.15.0 // indirect
golang.org/x/text v0.3.8 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
github.com/agentmail-to/agentmail-go v0.0.0-20260302203709-5d5f48c3a4bf h1:45RcHNvf2rNcNAhSz48Wy8OsQgxqKvPu1yUAExZs/5o=
github.com/agentmail-to/agentmail-go v0.0.0-20260302203709-5d5f48c3a4bf/go.mod h1:3NrKbeXLQKRgb9gj2bmCoN9WXDTy9y9yacV070xpvDU=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/itchyny/json2yaml v0.1.4 h1:/pErVOXGG5iTyXHi/QKR4y3uzhLjGTEmmJIy97YT+k8=
github.com/itchyny/json2yaml v0.1.4/go.mod h1:6iudhBZdarpjLFRNj+clWLAkGft+9uCcjAZYXUH9eGI=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/urfave/cli-docs/v3 v3.0.0-alpha6 h1:w/l/N0xw1rO/aHRIGXJ0lDwwYFOzilup1qGvIytP3BI=
github.com/urfave/cli-docs/v3 v3.0.0-alpha6/go.mod h1:p7Z4lg8FSTrPB9GTaNyTrK3ygffHZcK3w0cU2VE+mzU=
github.com/urfave/cli/v3 v3.3.2 h1:BYFVnhhZ8RqT38DxEYVFPPmGFTEf7tJwySTXsVRrS/o=
github.com/urfave/cli/v3 v3.3.2/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
package apiform
import (
"fmt"
"io"
"mime/multipart"
"net/textproto"
"path"
"reflect"
"sort"
"strconv"
"strings"
)
// Marshal encodes a value as multipart form data using default settings
func Marshal(value any, writer *multipart.Writer) error {
e := &encoder{
format: FormatRepeat,
}
return e.marshal(value, writer)
}
// MarshalWithSettings encodes a value with custom array format
func MarshalWithSettings(value any, writer *multipart.Writer, arrayFormat FormFormat) error {
e := &encoder{
format: arrayFormat,
}
return e.marshal(value, writer)
}
type encoder struct {
format FormFormat
}
func (e *encoder) marshal(value any, writer *multipart.Writer) error {
val := reflect.ValueOf(value)
if !val.IsValid() {
return nil
}
return e.encodeValue("", val, writer)
}
func (e *encoder) encodeValue(key string, val reflect.Value, writer *multipart.Writer) error {
if !val.IsValid() {
return writer.WriteField(key, "")
}
t := val.Type()
if t.Implements(reflect.TypeOf((*io.Reader)(nil)).Elem()) {
return e.encodeReader(key, val, writer)
}
switch t.Kind() {
case reflect.Pointer:
if val.IsNil() || !val.IsValid() {
return writer.WriteField(key, "")
}
return e.encodeValue(key, val.Elem(), writer)
case reflect.Slice, reflect.Array:
return e.encodeArray(key, val, writer)
case reflect.Map:
return e.encodeMap(key, val, writer)
case reflect.Interface:
if val.IsNil() {
return writer.WriteField(key, "")
}
return e.encodeValue(key, val.Elem(), writer)
case reflect.String:
return writer.WriteField(key, val.String())
case reflect.Bool:
if val.Bool() {
return writer.WriteField(key, "true")
}
return writer.WriteField(key, "false")
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return writer.WriteField(key, strconv.FormatInt(val.Int(), 10))
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return writer.WriteField(key, strconv.FormatUint(val.Uint(), 10))
case reflect.Float32:
return writer.WriteField(key, strconv.FormatFloat(val.Float(), 'f', -1, 32))
case reflect.Float64:
return writer.WriteField(key, strconv.FormatFloat(val.Float(), 'f', -1, 64))
default:
return fmt.Errorf("unknown type: %s", t.String())
}
}
func (e *encoder) encodeArray(key string, val reflect.Value, writer *multipart.Writer) error {
if e.format == FormatComma {
var values []string
for i := 0; i < val.Len(); i++ {
item := val.Index(i)
var strValue string
switch item.Kind() {
case reflect.String:
strValue = item.String()
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
strValue = strconv.FormatInt(item.Int(), 10)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
strValue = strconv.FormatUint(item.Uint(), 10)
case reflect.Float32, reflect.Float64:
strValue = strconv.FormatFloat(item.Float(), 'f', -1, 64)
case reflect.Bool:
strValue = strconv.FormatBool(item.Bool())
default:
return fmt.Errorf("comma format not supported for complex array elements")
}
values = append(values, strValue)
}
return writer.WriteField(key, strings.Join(values, ","))
}
for i := 0; i < val.Len(); i++ {
var formattedKey string
switch e.format {
case FormatRepeat:
formattedKey = key
case FormatBrackets:
formattedKey = key + "[]"
case FormatIndicesDots:
if key == "" {
formattedKey = strconv.Itoa(i)
} else {
formattedKey = key + "." + strconv.Itoa(i)
}
case FormatIndicesBrackets:
if key == "" {
formattedKey = strconv.Itoa(i)
} else {
formattedKey = key + "[" + strconv.Itoa(i) + "]"
}
default:
return fmt.Errorf("apiform: unsupported array format")
}
if err := e.encodeValue(formattedKey, val.Index(i), writer); err != nil {
return err
}
}
return nil
}
var quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"")
func escapeQuotes(s string) string {
return quoteEscaper.Replace(s)
}
func (e *encoder) encodeReader(key string, val reflect.Value, writer *multipart.Writer) error {
reader, ok := val.Convert(reflect.TypeOf((*io.Reader)(nil)).Elem()).Interface().(io.Reader)
if !ok {
return nil
}
// Set defaults
filename := "anonymous_file"
contentType := "application/octet-stream"
// Get filename if available
if named, ok := reader.(interface{ Filename() string }); ok {
filename = named.Filename()
} else if named, ok := reader.(interface{ Name() string }); ok {
filename = path.Base(named.Name())
}
// Get content type if available
if typed, ok := reader.(interface{ ContentType() string }); ok {
contentType = typed.ContentType()
}
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`,
escapeQuotes(key), escapeQuotes(filename)))
h.Set("Content-Type", contentType)
filewriter, err := writer.CreatePart(h)
if err != nil {
return err
}
_, err = io.Copy(filewriter, reader)
return err
}
func (e *encoder) encodeMap(key string, val reflect.Value, writer *multipart.Writer) error {
type mapPair struct {
key string
value reflect.Value
}
if key != "" {
key = key + "."
}
// Collect and sort map entries for deterministic output
pairs := []mapPair{}
iter := val.MapRange()
for iter.Next() {
if iter.Key().Type().Kind() != reflect.String {
return fmt.Errorf("cannot encode a map with a non string key")
}
pairs = append(pairs, mapPair{key: iter.Key().String(), value: iter.Value()})
}
sort.Slice(pairs, func(i, j int) bool {
return pairs[i].key < pairs[j].key
})
// Process sorted pairs
for _, p := range pairs {
if err := e.encodeValue(key+p.key, p.value, writer); err != nil {
return err
}
}
return nil
}
package apiform
import (
"bytes"
"mime/multipart"
"testing"
)
// Define test cases
var tests = map[string]struct {
value any
format FormFormat
expected string
}{
"nil": {
value: nil,
expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n\r\n--xxx--\r\n",
},
"string": {
value: "hello",
expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\nhello\r\n--xxx--\r\n",
},
"int": {
value: 42,
expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n42\r\n--xxx--\r\n",
},
"float": {
value: 3.14,
expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n3.14\r\n--xxx--\r\n",
},
"bool": {
value: true,
expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\ntrue\r\n--xxx--\r\n",
},
"empty slice": {
value: []string{},
expected: "\r\n--xxx--\r\n",
},
"nil slice": {
value: []string(nil),
expected: "\r\n--xxx--\r\n",
},
"slice with dot indices": {
value: []string{"a", "b", "c"},
format: FormatIndicesDots,
expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo.0\"\r\n\r\na\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo.1\"\r\n\r\nb\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo.2\"\r\n\r\nc\r\n--xxx--\r\n",
},
"slice with bracket indices": {
value: []int{10, 20, 30},
format: FormatIndicesBrackets,
expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo[0]\"\r\n\r\n10\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo[1]\"\r\n\r\n20\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo[2]\"\r\n\r\n30\r\n--xxx--\r\n",
},
"slice with repeat": {
value: []int{10, 20, 30},
format: FormatRepeat,
expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n10\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n20\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n30\r\n--xxx--\r\n",
},
"slice with commas": {
value: []int{10, 20, 30},
format: FormatComma,
expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n10,20,30\r\n--xxx--\r\n",
},
"empty map": {
value: map[string]any{},
expected: "\r\n--xxx--\r\n",
},
"nil map": {
value: map[string]any(nil),
expected: "\r\n--xxx--\r\n",
},
"map": {
value: map[string]any{"key1": "value1", "key2": "value2"},
expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo.key1\"\r\n\r\nvalue1\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo.key2\"\r\n\r\nvalue2\r\n--xxx--\r\n",
},
"nested_map": {
value: map[string]any{"outer": map[string]int{"inner1": 10, "inner2": 20}},
format: FormatIndicesDots,
expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo.outer.inner1\"\r\n\r\n10\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo.outer.inner2\"\r\n\r\n20\r\n--xxx--\r\n",
},
"mixed_map": {
value: map[string]any{"name": "John", "ages": []int{25, 30, 35}},
format: FormatIndicesDots,
expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo.ages.0\"\r\n\r\n25\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo.ages.1\"\r\n\r\n30\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo.ages.2\"\r\n\r\n35\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo.name\"\r\n\r\nJohn\r\n--xxx--\r\n",
},
}
func TestEncode(t *testing.T) {
for name, test := range tests {
t.Run(name, func(t *testing.T) {
buf := bytes.NewBuffer(nil)
writer := multipart.NewWriter(buf)
writer.SetBoundary("xxx")
form := map[string]any{"foo": test.value}
err := MarshalWithSettings(form, writer, test.format)
if err != nil {
t.Errorf("serialization of %v failed with error %v", test.value, err)
}
err = writer.Close()
if err != nil {
t.Errorf("serialization of %v failed with error %v", test.value, err)
}
result := buf.String()
if result != test.expected {
t.Errorf("expected %+#v to serialize to:\n\t%q\nbut got:\n\t%q", test.value, test.expected, result)
}
})
}
}
package apiform
type Marshaler interface {
MarshalMultipart() ([]byte, string, error)
}
type FormFormat int
const (
// FormatRepeat represents arrays as repeated keys with the same value
FormatRepeat FormFormat = iota
// Comma-separated values 1,2,3
FormatComma
// FormatBrackets uses the key[] notation for arrays
FormatBrackets
// FormatIndicesDots uses key.0, key.1, etc. notation
FormatIndicesDots
// FormatIndicesBrackets uses key[0], key[1], etc. notation
FormatIndicesBrackets
)
package apiquery
import (
"fmt"
"reflect"
"strconv"
"strings"
)
type encoder struct {
settings QuerySettings
}
type Pair struct {
key string
value string
}
func (e *encoder) Encode(key string, value reflect.Value) ([]Pair, error) {
t := value.Type()
switch t.Kind() {
case reflect.Pointer:
if value.IsNil() || !value.IsValid() {
return []Pair{{key, ""}}, nil
}
return e.Encode(key, value.Elem())
case reflect.Array, reflect.Slice:
return e.encodeArray(key, value)
case reflect.Map:
return e.encodeMap(key, value)
case reflect.Interface:
if !value.Elem().IsValid() {
return []Pair{{key, ""}}, nil
}
return e.Encode(key, value.Elem())
default:
return e.encodePrimitive(key, value)
}
}
func (e *encoder) encodeMap(key string, value reflect.Value) ([]Pair, error) {
var pairs []Pair
iter := value.MapRange()
for iter.Next() {
subkey := iter.Key().String()
keyPath := subkey
if len(key) > 0 {
if e.settings.NestedFormat == NestedQueryFormatDots {
keyPath = fmt.Sprintf("%s.%s", key, subkey)
} else {
keyPath = fmt.Sprintf("%s[%s]", key, subkey)
}
}
subpairs, err := e.Encode(keyPath, iter.Value())
if err != nil {
return nil, err
}
pairs = append(pairs, subpairs...)
}
return pairs, nil
}
func (e *encoder) encodeArray(key string, value reflect.Value) ([]Pair, error) {
switch e.settings.ArrayFormat {
case ArrayQueryFormatComma:
elements := []string{}
for i := 0; i < value.Len(); i++ {
innerPairs, err := e.Encode("", value.Index(i))
if err != nil {
return nil, err
}
for _, pair := range innerPairs {
elements = append(elements, pair.value)
}
}
return []Pair{{key, strings.Join(elements, ",")}}, nil
case ArrayQueryFormatRepeat:
var pairs []Pair
for i := 0; i < value.Len(); i++ {
subpairs, err := e.Encode(key, value.Index(i))
if err != nil {
return nil, err
}
pairs = append(pairs, subpairs...)
}
return pairs, nil
case ArrayQueryFormatIndices:
var pairs []Pair
for i := 0; i < value.Len(); i++ {
subpairs, err := e.Encode(fmt.Sprintf("%s[%d]", key, i), value.Index(i))
if err != nil {
return nil, err
}
pairs = append(pairs, subpairs...)
}
return pairs, nil
case ArrayQueryFormatBrackets:
var pairs []Pair
for i := 0; i < value.Len(); i++ {
subpairs, err := e.Encode(key+"[]", value.Index(i))
if err != nil {
return nil, err
}
pairs = append(pairs, subpairs...)
}
return pairs, nil
default:
panic(fmt.Sprintf("Unknown ArrayFormat value: %d", e.settings.ArrayFormat))
}
}
func (e *encoder) encodePrimitive(key string, value reflect.Value) ([]Pair, error) {
switch value.Kind() {
case reflect.Pointer:
if !value.IsValid() || value.IsNil() {
return nil, nil
}
return e.encodePrimitive(key, value.Elem())
case reflect.String:
return []Pair{{key, value.String()}}, nil
case reflect.Bool:
if value.Bool() {
return []Pair{{key, "true"}}, nil
}
return []Pair{{key, "false"}}, nil
case reflect.Int, reflect.Int16, reflect.Int32, reflect.Int64:
return []Pair{{key, strconv.FormatInt(value.Int(), 10)}}, nil
case reflect.Uint, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return []Pair{{key, strconv.FormatUint(value.Uint(), 10)}}, nil
case reflect.Float32, reflect.Float64:
return []Pair{{key, strconv.FormatFloat(value.Float(), 'f', -1, 64)}}, nil
default:
return nil, nil
}
}
func (e *encoder) encodeField(key string, value reflect.Value) ([]Pair, error) {
present := value.FieldByName("Present")
if !present.Bool() {
return nil, nil
}
null := value.FieldByName("Null")
if null.Bool() {
return nil, fmt.Errorf("apiquery: field cannot be null")
}
raw := value.FieldByName("Raw")
if !raw.IsNil() {
return e.Encode(key, raw)
}
return e.Encode(key, value.FieldByName("Value"))
}
package apiquery
import (
"net/url"
"testing"
)
func TestEncode(t *testing.T) {
tests := map[string]struct {
val any
settings QuerySettings
enc string
}{
"null": {
val: nil,
enc: "query=",
},
"string": {
val: "hello world",
enc: "query=hello world",
},
"int": {
val: 42,
enc: "query=42",
},
"float": {
val: 3.14,
enc: "query=3.14",
},
"bool": {
val: true,
enc: "query=true",
},
"empty_slice": {
val: []any{},
settings: QuerySettings{ArrayFormat: ArrayQueryFormatComma},
enc: "query=",
},
"nil_slice": {
val: []any(nil),
settings: QuerySettings{ArrayFormat: ArrayQueryFormatComma},
enc: "query=",
},
"slice_of_ints": {
val: []any{10, 20, 30},
settings: QuerySettings{ArrayFormat: ArrayQueryFormatComma},
enc: "query=10,20,30",
},
"slice_of_ints_repeat": {
val: []any{10, 20, 30},
settings: QuerySettings{ArrayFormat: ArrayQueryFormatRepeat},
enc: "query=10&query=20&query=30",
},
"slice_of_ints_indices": {
val: []any{10, 20, 30},
settings: QuerySettings{ArrayFormat: ArrayQueryFormatIndices},
enc: "query[0]=10&query[1]=20&query[2]=30",
},
"slice_of_ints_brackets": {
val: []any{10, 20, 30},
settings: QuerySettings{ArrayFormat: ArrayQueryFormatBrackets},
enc: "query[]=10&query[]=20&query[]=30",
},
"slice_of_strings": {
val: []any{"a", "b", "c"},
settings: QuerySettings{},
enc: "query=a,b,c",
},
"empty_map": {
val: map[string]any{},
settings: QuerySettings{NestedFormat: NestedQueryFormatBrackets},
enc: "",
},
"nil_map": {
val: map[string]any(nil),
settings: QuerySettings{NestedFormat: NestedQueryFormatBrackets},
enc: "",
},
"map_string_to_int_brackets": {
val: map[string]any{"one": 1, "two": 2},
settings: QuerySettings{NestedFormat: NestedQueryFormatBrackets},
enc: "query[one]=1&query[two]=2",
},
"map_string_to_int_dots": {
val: map[string]any{"one": 1, "two": 2},
settings: QuerySettings{NestedFormat: NestedQueryFormatDots},
enc: "query.one=1&query.two=2",
},
"map_string_to_slice": {
val: map[string][]any{"nums": {10, 20, 30}},
settings: QuerySettings{},
enc: "query[nums]=10,20,30",
},
"map_string_to_slice_repeat_dots": {
val: map[string][]any{"nums": {10, 20, 30}},
settings: QuerySettings{ArrayFormat: ArrayQueryFormatRepeat, NestedFormat: NestedQueryFormatDots},
enc: "query.nums=10&query.nums=20&query.nums=30",
},
"map_with_empties": {
val: map[string]any{
"empty-array": []any{},
"nil-array": []any(nil),
"null": nil,
},
settings: QuerySettings{ArrayFormat: ArrayQueryFormatComma, NestedFormat: NestedQueryFormatDots},
enc: "query.empty-array=&query.nil-array=&query.null=",
},
"nested_map": {
val: map[string]map[string]any{"outer": {"inner": 42}},
settings: QuerySettings{},
enc: "query[outer][inner]=42",
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
query := map[string]any{"query": test.val}
values, err := MarshalWithSettings(query, test.settings)
if err != nil {
t.Fatalf("failed to marshal url %s", err)
}
str, _ := url.QueryUnescape(values.Encode())
if str != test.enc {
t.Fatalf("expected %+#v to serialize to:\n\t%q\nbut got:\n\t%q", test.val, test.enc, str)
}
})
}
}
package apiquery
import (
"net/url"
"reflect"
)
func MarshalWithSettings(value any, settings QuerySettings) (url.Values, error) {
val := reflect.ValueOf(value)
if !val.IsValid() {
return nil, nil
}
e := encoder{settings}
pairs, err := e.Encode("", val)
if err != nil {
return nil, err
}
kv := url.Values{}
for _, pair := range pairs {
kv.Add(pair.key, pair.value)
}
return kv, nil
}
func Marshal(value any) (url.Values, error) {
return MarshalWithSettings(value, QuerySettings{})
}
type Queryer interface {
URLQuery() (url.Values, error)
}
type NestedQueryFormat int
const (
NestedQueryFormatBrackets NestedQueryFormat = iota
NestedQueryFormatDots
)
type ArrayQueryFormat int
const (
ArrayQueryFormatComma ArrayQueryFormat = iota
ArrayQueryFormatRepeat
ArrayQueryFormatIndices
ArrayQueryFormatBrackets
)
type QuerySettings struct {
NestedFormat NestedQueryFormat
ArrayFormat ArrayQueryFormat
}
package autocomplete
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/urfave/cli/v3"
)
func TestGetCompletions_EmptyArgs(t *testing.T) {
root := &cli.Command{
Commands: []*cli.Command{
{Name: "generate", Usage: "Generate SDK"},
{Name: "test", Usage: "Run tests"},
{Name: "build", Usage: "Build project"},
},
}
result := GetCompletions(CompletionStyleBash, root, []string{})
assert.Equal(t, ShellCompletionBehaviorDefault, result.Behavior)
assert.Len(t, result.Completions, 3)
assert.Contains(t, result.Completions, ShellCompletion{Name: "generate", Usage: "Generate SDK"})
assert.Contains(t, result.Completions, ShellCompletion{Name: "test", Usage: "Run tests"})
assert.Contains(t, result.Completions, ShellCompletion{Name: "build", Usage: "Build project"})
}
func TestGetCompletions_SubcommandPrefix(t *testing.T) {
root := &cli.Command{
Commands: []*cli.Command{
{Name: "generate", Usage: "Generate SDK"},
{Name: "test", Usage: "Run tests"},
{Name: "build", Usage: "Build project"},
},
}
result := GetCompletions(CompletionStyleBash, root, []string{"ge"})
assert.Equal(t, ShellCompletionBehaviorDefault, result.Behavior)
assert.Len(t, result.Completions, 1)
assert.Equal(t, "generate", result.Completions[0].Name)
assert.Equal(t, "Generate SDK", result.Completions[0].Usage)
}
func TestGetCompletions_HiddenCommand(t *testing.T) {
root := &cli.Command{
Commands: []*cli.Command{
{Name: "visible", Usage: "Visible command"},
{Name: "hidden", Usage: "Hidden command", Hidden: true},
},
}
result := GetCompletions(CompletionStyleBash, root, []string{""})
assert.Len(t, result.Completions, 1)
assert.Equal(t, "visible", result.Completions[0].Name)
}
func TestGetCompletions_NestedSubcommand(t *testing.T) {
root := &cli.Command{
Commands: []*cli.Command{
{
Name: "config",
Usage: "Configuration commands",
Commands: []*cli.Command{
{Name: "get", Usage: "Get config value"},
{Name: "set", Usage: "Set config value"},
},
},
},
}
result := GetCompletions(CompletionStyleBash, root, []string{"config", "s"})
assert.Equal(t, ShellCompletionBehaviorDefault, result.Behavior)
assert.Len(t, result.Completions, 1)
assert.Equal(t, "set", result.Completions[0].Name)
assert.Equal(t, "Set config value", result.Completions[0].Usage)
}
func TestGetCompletions_FlagCompletion(t *testing.T) {
root := &cli.Command{
Commands: []*cli.Command{
{
Name: "generate",
Usage: "Generate SDK",
Flags: []cli.Flag{
&cli.StringFlag{Name: "output", Aliases: []string{"o"}, Usage: "Output directory"},
&cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}, Usage: "Verbose output"},
&cli.StringFlag{Name: "format", Usage: "Output format"},
},
},
},
}
result := GetCompletions(CompletionStyleBash, root, []string{"generate", "--o"})
assert.Equal(t, ShellCompletionBehaviorDefault, result.Behavior)
assert.Len(t, result.Completions, 1)
assert.Equal(t, "--output", result.Completions[0].Name)
assert.Equal(t, "Output directory", result.Completions[0].Usage)
}
func TestGetCompletions_ShortFlagCompletion(t *testing.T) {
root := &cli.Command{
Commands: []*cli.Command{
{
Name: "generate",
Usage: "Generate SDK",
Flags: []cli.Flag{
&cli.StringFlag{Name: "output", Aliases: []string{"o"}, Usage: "Output directory"},
&cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}, Usage: "Verbose output"},
},
},
},
}
result := GetCompletions(CompletionStyleBash, root, []string{"generate", "-v"})
assert.Equal(t, ShellCompletionBehaviorDefault, result.Behavior)
assert.Len(t, result.Completions, 1)
assert.Equal(t, "-v", result.Completions[0].Name)
}
func TestGetCompletions_FileFlagBehavior(t *testing.T) {
root := &cli.Command{
Commands: []*cli.Command{
{
Name: "generate",
Usage: "Generate SDK",
Flags: []cli.Flag{
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "Config file", TakesFile: true},
},
},
},
}
result := GetCompletions(CompletionStyleBash, root, []string{"generate", "--config", ""})
assert.EqualValues(t, ShellCompletionBehaviorFile, result.Behavior)
assert.Empty(t, result.Completions)
}
func TestGetCompletions_NonBoolFlagValue(t *testing.T) {
root := &cli.Command{
Commands: []*cli.Command{
{
Name: "generate",
Usage: "Generate SDK",
Flags: []cli.Flag{
&cli.StringFlag{Name: "format", Usage: "Output format"},
},
},
},
}
result := GetCompletions(CompletionStyleBash, root, []string{"generate", "--format", ""})
assert.EqualValues(t, ShellCompletionBehaviorNoComplete, result.Behavior)
assert.Empty(t, result.Completions)
}
func TestGetCompletions_BoolFlagDoesNotBlockCompletion(t *testing.T) {
root := &cli.Command{
Commands: []*cli.Command{
{
Name: "generate",
Usage: "Generate SDK",
Flags: []cli.Flag{
&cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}, Usage: "Verbose output"},
},
Commands: []*cli.Command{
{Name: "typescript", Usage: "Generate TypeScript SDK"},
{Name: "python", Usage: "Generate Python SDK"},
},
},
},
}
result := GetCompletions(CompletionStyleBash, root, []string{"generate", "--verbose", "ty"})
assert.Equal(t, ShellCompletionBehaviorDefault, result.Behavior)
assert.Len(t, result.Completions, 1)
assert.Equal(t, "typescript", result.Completions[0].Name)
}
func TestGetCompletions_ColonCommands_NoColonTyped(t *testing.T) {
root := &cli.Command{
Commands: []*cli.Command{
{Name: "config:get", Usage: "Get config value"},
{Name: "config:set", Usage: "Set config value"},
{Name: "config:list", Usage: "List config values"},
},
}
result := GetCompletions(CompletionStyleBash, root, []string{"co"})
// Should collapse to single "config" entry without usage
assert.Len(t, result.Completions, 1)
assert.Equal(t, "config", result.Completions[0].Name)
assert.Equal(t, "", result.Completions[0].Usage)
}
func TestGetCompletions_ColonCommands_ColonTyped_Bash(t *testing.T) {
root := &cli.Command{
Commands: []*cli.Command{
{Name: "config:get", Usage: "Get config value"},
{Name: "config:set", Usage: "Set config value"},
{Name: "config:list", Usage: "List config values"},
},
}
result := GetCompletions(CompletionStyleBash, root, []string{"config:"})
// For bash, should show suffixes only
assert.Len(t, result.Completions, 3)
names := []string{result.Completions[0].Name, result.Completions[1].Name, result.Completions[2].Name}
assert.Contains(t, names, "get")
assert.Contains(t, names, "set")
assert.Contains(t, names, "list")
}
func TestGetCompletions_ColonCommands_ColonTyped_Zsh(t *testing.T) {
root := &cli.Command{
Commands: []*cli.Command{
{Name: "config:get", Usage: "Get config value"},
{Name: "config:set", Usage: "Set config value"},
{Name: "config:list", Usage: "List config values"},
},
}
result := GetCompletions(CompletionStyleZsh, root, []string{"config:"})
// For zsh, should show full names
assert.Len(t, result.Completions, 3)
names := []string{result.Completions[0].Name, result.Completions[1].Name, result.Completions[2].Name}
assert.Contains(t, names, "config:get")
assert.Contains(t, names, "config:set")
assert.Contains(t, names, "config:list")
}
func TestGetCompletions_BashStyleColonCompletion(t *testing.T) {
root := &cli.Command{
Commands: []*cli.Command{
{Name: "config:get", Usage: "Get config value"},
{Name: "config:set", Usage: "Set config value"},
},
}
result := GetCompletions(CompletionStyleBash, root, []string{"config:g"})
// For bash, should return suffix from after the colon in the input
// Input "config:g" has colon at index 6, so we take name[7:] from matched commands
assert.Len(t, result.Completions, 1)
assert.Equal(t, "get", result.Completions[0].Name)
assert.Equal(t, "Get config value", result.Completions[0].Usage)
}
func TestGetCompletions_BashStyleColonCompletion_NoMatch(t *testing.T) {
root := &cli.Command{
Commands: []*cli.Command{
{Name: "config:get", Usage: "Get config value"},
{Name: "config:set", Usage: "Set config value"},
},
}
result := GetCompletions(CompletionStyleBash, root, []string{"other:g"})
// No matches
assert.Len(t, result.Completions, 0)
}
func TestGetCompletions_ZshStyleColonCompletion(t *testing.T) {
root := &cli.Command{
Commands: []*cli.Command{
{Name: "config:get", Usage: "Get config value"},
{Name: "config:set", Usage: "Set config value"},
},
}
result := GetCompletions(CompletionStyleZsh, root, []string{"config:g"})
// For zsh, should return full name
assert.Len(t, result.Completions, 1)
assert.Equal(t, "config:get", result.Completions[0].Name)
assert.Equal(t, "Get config value", result.Completions[0].Usage)
}
func TestGetCompletions_MixedColonAndRegularCommands(t *testing.T) {
root := &cli.Command{
Commands: []*cli.Command{
{Name: "generate", Usage: "Generate SDK"},
{Name: "config:get", Usage: "Get config value"},
{Name: "config:set", Usage: "Set config value"},
},
}
result := GetCompletions(CompletionStyleBash, root, []string{""})
// Should show "generate" and "config" (collapsed)
assert.Len(t, result.Completions, 2)
names := []string{result.Completions[0].Name, result.Completions[1].Name}
assert.Contains(t, names, "generate")
assert.Contains(t, names, "config")
}
func TestGetCompletions_FlagWithBoolFlagSkipsValue(t *testing.T) {
root := &cli.Command{
Commands: []*cli.Command{
{
Name: "generate",
Usage: "Generate SDK",
Flags: []cli.Flag{
&cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}},
&cli.StringFlag{Name: "output", Aliases: []string{"o"}},
},
Commands: []*cli.Command{
{Name: "typescript", Usage: "TypeScript SDK"},
},
},
},
}
// Bool flag should not consume the next arg as a value
result := GetCompletions(CompletionStyleBash, root, []string{"generate", "-v", "ty"})
assert.Len(t, result.Completions, 1)
assert.Equal(t, "typescript", result.Completions[0].Name)
}
func TestGetCompletions_MultipleFlagsBeforeSubcommand(t *testing.T) {
root := &cli.Command{
Commands: []*cli.Command{
{
Name: "generate",
Usage: "Generate SDK",
Flags: []cli.Flag{
&cli.StringFlag{Name: "config", Aliases: []string{"c"}},
&cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}},
},
Commands: []*cli.Command{
{Name: "typescript", Usage: "TypeScript SDK"},
{Name: "python", Usage: "Python SDK"},
},
},
},
}
result := GetCompletions(CompletionStyleBash, root, []string{"generate", "-c", "config.yml", "-v", "py"})
assert.Len(t, result.Completions, 1)
assert.Equal(t, "python", result.Completions[0].Name)
}
func TestGetCompletions_CommandAliases(t *testing.T) {
root := &cli.Command{
Commands: []*cli.Command{
{Name: "generate", Aliases: []string{"gen", "g"}, Usage: "Generate SDK"},
},
}
result := GetCompletions(CompletionStyleBash, root, []string{"g"})
// Should match all aliases that start with "g"
assert.GreaterOrEqual(t, len(result.Completions), 2) // "generate" and "gen", possibly "g" too
names := []string{}
for _, c := range result.Completions {
names = append(names, c.Name)
}
assert.Contains(t, names, "generate")
assert.Contains(t, names, "gen")
}
func TestGetCompletions_AllFlagsWhenNoPrefix(t *testing.T) {
root := &cli.Command{
Commands: []*cli.Command{
{
Name: "generate",
Usage: "Generate SDK",
Flags: []cli.Flag{
&cli.StringFlag{Name: "output", Aliases: []string{"o"}},
&cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}},
&cli.StringFlag{Name: "format", Aliases: []string{"f"}},
},
},
},
}
result := GetCompletions(CompletionStyleBash, root, []string{"generate", "-"})
// Should show all flag variations
assert.GreaterOrEqual(t, len(result.Completions), 6) // -o, --output, -v, --verbose, -f, --format
}
package autocomplete
import (
"context"
"embed"
"fmt"
"os"
"slices"
"strings"
"github.com/urfave/cli/v3"
)
type CompletionStyle string
const (
CompletionStyleZsh CompletionStyle = "zsh"
CompletionStyleBash CompletionStyle = "bash"
CompletionStylePowershell CompletionStyle = "pwsh"
CompletionStyleFish CompletionStyle = "fish"
)
type renderCompletion func(cmd *cli.Command, appName string) (string, error)
var (
//go:embed shellscripts
autoCompleteFS embed.FS
shellCompletions = map[CompletionStyle]renderCompletion{
"bash": func(c *cli.Command, appName string) (string, error) {
b, err := autoCompleteFS.ReadFile("shellscripts/bash_autocomplete.bash")
return strings.ReplaceAll(string(b), "__APPNAME__", appName), err
},
"fish": func(c *cli.Command, appName string) (string, error) {
b, err := autoCompleteFS.ReadFile("shellscripts/fish_autocomplete.fish")
return strings.ReplaceAll(string(b), "__APPNAME__", appName), err
},
"pwsh": func(c *cli.Command, appName string) (string, error) {
b, err := autoCompleteFS.ReadFile("shellscripts/pwsh_autocomplete.ps1")
return strings.ReplaceAll(string(b), "__APPNAME__", appName), err
},
"zsh": func(c *cli.Command, appName string) (string, error) {
b, err := autoCompleteFS.ReadFile("shellscripts/zsh_autocomplete.zsh")
return strings.ReplaceAll(string(b), "__APPNAME__", appName), err
},
}
)
func OutputCompletionScript(ctx context.Context, cmd *cli.Command) error {
shells := make([]CompletionStyle, 0, len(shellCompletions))
for k := range shellCompletions {
shells = append(shells, k)
}
if cmd.Args().Len() == 0 {
return cli.Exit(fmt.Sprintf("no shell provided for completion command. available shells are %+v", shells), 1)
}
s := CompletionStyle(cmd.Args().First())
renderCompletion, ok := shellCompletions[s]
if !ok {
return cli.Exit(fmt.Sprintf("unknown shell %s, available shells are %+v", s, shells), 1)
}
completionScript, err := renderCompletion(cmd, cmd.Root().Name)
if err != nil {
return cli.Exit(err, 1)
}
_, err = cmd.Writer.Write([]byte(completionScript))
if err != nil {
return cli.Exit(err, 1)
}
return nil
}
type ShellCompletion struct {
Name string
Usage string
}
func NewShellCompletion(name string, usage string) ShellCompletion {
return ShellCompletion{Name: name, Usage: usage}
}
type ShellCompletionBehavior int
const (
ShellCompletionBehaviorDefault ShellCompletionBehavior = iota
ShellCompletionBehaviorFile = 10
ShellCompletionBehaviorNoComplete
)
type CompletionResult struct {
Completions []ShellCompletion
Behavior ShellCompletionBehavior
}
func isFlag(arg string) bool {
return strings.HasPrefix(arg, "-")
}
func findFlag(cmd *cli.Command, arg string) *cli.Flag {
name := strings.TrimLeft(arg, "-")
for _, flag := range cmd.Flags {
if vf, ok := flag.(cli.VisibleFlag); ok && !vf.IsVisible() {
continue
}
if slices.Contains(flag.Names(), name) {
return &flag
}
}
return nil
}
func findChild(cmd *cli.Command, name string) *cli.Command {
for _, c := range cmd.Commands {
if !c.Hidden && c.Name == name {
return c
}
}
return nil
}
type shellCompletionBuilder struct {
completionStyle CompletionStyle
}
func (scb *shellCompletionBuilder) createFromCommand(input string, command *cli.Command, result []ShellCompletion) []ShellCompletion {
matchingNames := make([]string, 0, len(command.Names()))
for _, name := range command.Names() {
if strings.HasPrefix(name, input) {
matchingNames = append(matchingNames, name)
}
}
if scb.completionStyle == CompletionStyleBash {
index := strings.LastIndex(input, ":") + 1
if index > 0 {
for _, name := range matchingNames {
result = append(result, NewShellCompletion(name[index:], command.Usage))
}
return result
}
}
for _, name := range matchingNames {
result = append(result, NewShellCompletion(name, command.Usage))
}
return result
}
func (scb *shellCompletionBuilder) createFromFlag(input string, flag *cli.Flag, result []ShellCompletion) []ShellCompletion {
matchingNames := make([]string, 0, len((*flag).Names()))
for _, name := range (*flag).Names() {
withPrefix := ""
if len(name) == 1 {
withPrefix = "-" + name
} else {
withPrefix = "--" + name
}
if strings.HasPrefix(withPrefix, input) {
matchingNames = append(matchingNames, withPrefix)
}
}
usage := ""
if dgf, ok := (*flag).(cli.DocGenerationFlag); ok {
usage = dgf.GetUsage()
}
for _, name := range matchingNames {
result = append(result, NewShellCompletion(name, usage))
}
return result
}
func GetCompletions(completionStyle CompletionStyle, root *cli.Command, args []string) CompletionResult {
result := getAllPossibleCompletions(completionStyle, root, args)
// If the user has not put in a colon, filter out colon commands
if len(args) > 0 && !strings.Contains(args[len(args)-1], ":") {
// Nothing with anything after a colon. Create a single entry for groups with the same colon subset
foundNames := make([]string, 0, len(result.Completions))
filteredCompletions := make([]ShellCompletion, 0, len(result.Completions))
for _, completion := range result.Completions {
name := completion.Name
firstColonIndex := strings.Index(name, ":")
if firstColonIndex > -1 {
name = name[0:firstColonIndex]
completion.Name = name
completion.Usage = ""
}
if !slices.Contains(foundNames, name) {
foundNames = append(foundNames, name)
filteredCompletions = append(filteredCompletions, completion)
}
}
result.Completions = filteredCompletions
}
return result
}
func getAllPossibleCompletions(completionStyle CompletionStyle, root *cli.Command, args []string) CompletionResult {
builder := shellCompletionBuilder{completionStyle: completionStyle}
completions := make([]ShellCompletion, 0)
if len(args) == 0 {
for _, child := range root.Commands {
completions = builder.createFromCommand("", child, completions)
}
return CompletionResult{Completions: completions, Behavior: ShellCompletionBehaviorDefault}
}
current := args[len(args)-1]
preceding := args[0 : len(args)-1]
cmd := root
i := 0
for i < len(preceding) {
arg := preceding[i]
if isFlag(arg) {
flag := findFlag(cmd, arg)
if flag == nil {
i++
} else if docFlag, ok := (*flag).(cli.DocGenerationFlag); ok && docFlag.TakesValue() {
// All flags except for bool flags take values
i += 2
} else {
i++
}
} else {
child := findChild(cmd, arg)
if child != nil {
cmd = child
}
i++
}
}
// Check if the previous arg was a flag expecting a value
if len(preceding) > 0 {
prev := preceding[len(preceding)-1]
if isFlag(prev) {
flag := findFlag(cmd, prev)
if flag != nil {
if fb, ok := (*flag).(*cli.StringFlag); ok && fb.TakesFile {
return CompletionResult{Completions: completions, Behavior: ShellCompletionBehaviorFile}
} else if docFlag, ok := (*flag).(cli.DocGenerationFlag); ok && docFlag.TakesValue() {
return CompletionResult{Completions: completions, Behavior: ShellCompletionBehaviorNoComplete}
}
}
}
}
// Completing a flag name
if isFlag(current) {
for _, flag := range cmd.Flags {
completions = builder.createFromFlag(current, &flag, completions)
}
}
for _, child := range cmd.Commands {
if !child.Hidden {
completions = builder.createFromCommand(current, child, completions)
}
}
return CompletionResult{
Completions: completions,
Behavior: ShellCompletionBehaviorDefault,
}
}
func ExecuteShellCompletion(ctx context.Context, cmd *cli.Command) error {
root := cmd.Root()
args := rebuildColonSeparatedArgs(root.Args().Slice()[1:])
var completionStyle CompletionStyle
if style, ok := os.LookupEnv("COMPLETION_STYLE"); ok {
switch style {
case "bash":
completionStyle = CompletionStyleBash
case "zsh":
completionStyle = CompletionStyleZsh
case "pwsh":
completionStyle = CompletionStylePowershell
case "fish":
completionStyle = CompletionStyleFish
default:
return cli.Exit("COMPLETION_STYLE must be set to 'bash', 'zsh', 'pwsh', or 'fish'", 1)
}
} else {
return cli.Exit("COMPLETION_STYLE must be set to 'bash', 'zsh', 'pwsh', 'fish'", 1)
}
result := GetCompletions(completionStyle, root, args)
for _, completion := range result.Completions {
name := completion.Name
if completionStyle == CompletionStyleZsh {
name = strings.ReplaceAll(name, ":", "\\:")
}
if completionStyle == CompletionStyleZsh && len(completion.Usage) > 0 {
_, _ = fmt.Fprintf(cmd.Writer, "%s:%s\n", name, completion.Usage)
} else if completionStyle == CompletionStyleFish && len(completion.Usage) > 0 {
_, _ = fmt.Fprintf(cmd.Writer, "%s\t%s\n", name, completion.Usage)
} else {
_, _ = fmt.Fprintf(cmd.Writer, "%s\n", name)
}
}
return cli.Exit("", int(result.Behavior))
}
// When CLI arguments are passed in, they are separated on word barriers.
// Most commonly this is whitespace but in some cases that may also be colons.
// We wish to allow arguments with colons. To handle this, we append/prepend colons to their neighboring
// arguments.
//
// Example: `rebuildColonSeparatedArgs(["a", "b", ":", "c", "d"])` => `["a", "b:c", "d"]`
func rebuildColonSeparatedArgs(args []string) []string {
if len(args) == 0 {
return args
}
result := []string{}
i := 0
for i < len(args) {
current := args[i]
// Keep joining while the next element is ":" or the current element ends with ":"
for i+1 < len(args) && (args[i+1] == ":" || strings.HasSuffix(current, ":")) {
if args[i+1] == ":" {
current += ":"
i++
// Check if there's a following element after the ":"
if i+1 < len(args) && args[i+1] != ":" {
current += args[i+1]
i++
}
} else {
break
}
}
result = append(result, current)
i++
}
return result
}
#!/bin/bash
____APPNAME___bash_autocomplete() {
if [[ "${COMP_WORDS[0]}" != "source" ]]; then
local cur completions exit_code
local IFS=$'\n'
cur="${COMP_WORDS[COMP_CWORD]}"
completions=$(COMPLETION_STYLE=bash "${COMP_WORDS[0]}" __complete -- "${COMP_WORDS[@]:1:$COMP_CWORD-1}" "$cur" 2>/dev/null)
exit_code=$?
local last_token="$cur"
# If the last token has been split apart by a ':', join it back together.
# Ex: 'a:b' will be represented in COMP_WORDS as 'a', ':', 'b'
if [[ $COMP_CWORD -ge 2 ]]; then
local prev2="${COMP_WORDS[COMP_CWORD - 2]}"
local prev1="${COMP_WORDS[COMP_CWORD - 1]}"
if [[ "$prev2" =~ ^@(file|data)$ && "$prev1" == ":" && "$cur" =~ ^// ]]; then
last_token="$prev2:$cur"
fi
fi
# Check for custom file completion patterns
local prefix=""
local file_part="$cur"
local force_file_completion=false
if [[ "$last_token" =~ (.*)@(file://|data://)?(.*)$ ]]; then
local before_at="${BASH_REMATCH[1]}"
local protocol="${BASH_REMATCH[2]}"
file_part="${BASH_REMATCH[3]}"
if [[ "$protocol" == "" ]]; then
prefix="$before_at@"
else
if [[ "$before_at" == "" ]]; then
prefix="//"
else
prefix="$before_at@$protocol"
fi
fi
force_file_completion=true
fi
if [[ "$force_file_completion" == true ]]; then
mapfile -t COMPREPLY < <(compgen -f -- "$file_part" | sed "s|^|$prefix|")
else
case $exit_code in
10) mapfile -t COMPREPLY < <(compgen -f -- "$cur") ;; # file completion
11) COMPREPLY=() ;; # no completion
0) mapfile -t COMPREPLY <<<"$completions" ;; # use returned completions
esac
fi
return 0
fi
}
complete -F ____APPNAME___bash_autocomplete __APPNAME__
#!/usr/bin/env fish
function ____APPNAME___fish_autocomplete
set -l tokens (commandline -xpc)
set -l current (commandline -ct)
set -l cmd $tokens[1]
set -l args $tokens[2..-1]
set -l completions (env COMPLETION_STYLE=fish $cmd __complete -- $args $current 2>>/tmp/fish-debug.log)
set -l exit_code $status
# Check for custom file completion patterns
# Patterns can appear anywhere in the word (e.g., inside quotes: 'my file is @file://path')
set -l prefix ""
set -l file_part "$current"
set -l force_file_completion 0
if string match -gqr '^(?<before>.*)@(?<protocol>file://|data://)?(?<file_part>.*)$' -- $current
if string match -qr '^[\'"]' -- $before
# Ensures we don't insert an extra quote when the user is building an argument in quotes
set before (string sub -s 2 -- $before)
end
set prefix "$before@$protocol"
set force_file_completion 1
end
if test $force_file_completion -eq 1
for path in (__fish_complete_path "$file_part")
echo $prefix$path
end
else
switch $exit_code
case 10
# File completion
__fish_complete_path "$current"
case 11
# No completion
return 0
case 0
# Use returned completions
for completion in $completions
echo $completion
end
end
end
end
complete -c __APPNAME__ -f -a '(____APPNAME___fish_autocomplete)'
Register-ArgumentCompleter -Native -CommandName __APPNAME__ -ScriptBlock {
param($wordToComplete, $commandAst, $cursorPosition)
$elements = $commandAst.CommandElements
$completionArgs = @()
# Extract each of the arguments
for ($i = 0; $i -lt $elements.Count; $i++) {
$completionArgs += $elements[$i].Extent.Text
}
# Add empty string if there's a trailing space (wordToComplete is empty but cursor is after space)
# Necessary for differentiating between getting completions for namespaced commands vs. subcommands
if ($wordToComplete.Length -eq 0 -and $elements.Count -gt 0) {
$completionArgs += ""
}
$output = & {
$env:COMPLETION_STYLE = 'pwsh'
__APPNAME__ __complete @completionArgs 2>&1
}
$exitCode = $LASTEXITCODE
# Check for custom file completion patterns
# Patterns can appear anywhere in the word (e.g., inside quotes: 'my file is @file://path')
$prefix = ""
$filePart = $wordToComplete
$forceFileCompletion = $false
# PowerShell includes quotes in $wordToComplete - strip them for pattern matching
# but preserve them in the prefix for the completion result
$wordContent = $wordToComplete
$leadingQuote = ""
if ($wordToComplete -match '^([''"])(.*)(\1)$') {
# Fully quoted: "content" or 'content'
$leadingQuote = $Matches[1]
$wordContent = $Matches[2]
} elseif ($wordToComplete -match '^([''"])(.*)$') {
# Opening quote only: "content or 'content
$leadingQuote = $Matches[1]
$wordContent = $Matches[2]
}
if ($wordContent -match '^(.*)@(file://|data://)?(.*)$') {
$prefix = $leadingQuote + $Matches[1] + '@' + $Matches[2]
$filePart = $Matches[3]
$forceFileCompletion = $true
}
if ($forceFileCompletion) {
# Handle empty filePart (e.g., "@" or "@file://") by listing current directory
$items = if ([string]::IsNullOrEmpty($filePart)) {
Get-ChildItem -ErrorAction SilentlyContinue
} else {
Get-ChildItem -Path "$filePart*" -ErrorAction SilentlyContinue
}
$items | ForEach-Object {
$completionText = if ($_.PSIsContainer) { $prefix + $_.Name + "/" } else { $prefix + $_.Name }
[System.Management.Automation.CompletionResult]::new(
$completionText,
$completionText,
'ProviderItem',
$completionText
)
}
} else {
switch ($exitCode) {
10 {
# File completion behavior
$items = if ([string]::IsNullOrEmpty($wordToComplete)) {
Get-ChildItem -ErrorAction SilentlyContinue
} else {
Get-ChildItem -Path "$wordToComplete*" -ErrorAction SilentlyContinue
}
$items | ForEach-Object {
$completionText = if ($_.PSIsContainer) { $_.Name + "/" } else { $_.Name }
[System.Management.Automation.CompletionResult]::new(
$completionText,
$completionText,
'ProviderItem',
$completionText
)
}
}
11 {
# No reasonable suggestions
[System.Management.Automation.CompletionResult]::new(' ', ' ', 'ParameterValue', ' ')
}
default {
# Default behavior - show command completions
$output | ForEach-Object {
[System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
}
}
}
}
}
#!/bin/zsh
compdef ____APPNAME___zsh_autocomplete __APPNAME__
____APPNAME___zsh_autocomplete() {
local -a opts
local temp
local exit_code
temp=$(COMPLETION_STYLE=zsh "${words[1]}" __complete "${words[@]:1}")
exit_code=$?
# Check for custom file completion patterns
# Patterns can appear anywhere in the word (e.g., inside quotes: 'my file is @file://path')
local cur="${words[CURRENT]}"
if [[ "$cur" = *'@'* ]]; then
# Extract everything after the last @
local after_last_at="${cur##*@}"
if [[ $after_last_at =~ ^(file://|data://) ]]; then
compset -P "*$MATCH"
_files
else
compset -P '*@'
_files
fi
return
fi
case $exit_code in
10)
# File completion behavior
_files
;;
11)
# No completion behavior - return nothing
return 1
;;
0)
# Default behavior - show command completions
opts=("${(@f)temp}")
_describe 'values' opts
;;
esac
}
package binaryparam
import (
"io"
"os"
"testing"
"github.com/stretchr/testify/require"
)
func TestFileOrStdin(t *testing.T) {
t.Parallel()
const expectedContents = "test file contents"
t.Run("WithFile", func(t *testing.T) {
tempFile := t.TempDir() + "/test_file.txt"
require.NoError(t, os.WriteFile(tempFile, []byte(expectedContents), 0600))
readCloser, stdinInUse, err := FileOrStdin(os.Stdin, tempFile)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, readCloser.Close()) })
actualContents, err := io.ReadAll(readCloser)
require.NoError(t, err)
require.Equal(t, expectedContents, string(actualContents))
require.False(t, stdinInUse)
})
t.Run("WithStdinGlyph", func(t *testing.T) {
tempFile := t.TempDir() + "/test_file.txt"
require.NoError(t, os.WriteFile(tempFile, []byte(expectedContents), 0600))
stubStdin, err := os.Open(tempFile)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, stubStdin.Close()) })
readCloser, stdinInUse, err := FileOrStdin(stubStdin, "-")
require.NoError(t, err)
actualContents, err := io.ReadAll(readCloser)
require.NoError(t, err)
require.Equal(t, expectedContents, string(actualContents))
require.True(t, stdinInUse)
})
t.Run("WithDevFD0File", func(t *testing.T) {
tempFile := t.TempDir() + "/dev_fd_0"
require.NoError(t, os.WriteFile(tempFile, []byte(expectedContents), 0600))
stubStdin, err := os.Open(tempFile)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, stubStdin.Close()) })
readCloser, stdinInUse, err := FileOrStdin(stubStdin, "/dev/fd/0")
require.NoError(t, err)
actualContents, err := io.ReadAll(readCloser)
require.NoError(t, err)
require.Equal(t, expectedContents, string(actualContents))
require.True(t, stdinInUse)
})
t.Run("WithDevStdinFile", func(t *testing.T) {
tempFile := t.TempDir() + "/dev_stdin"
require.NoError(t, os.WriteFile(tempFile, []byte(expectedContents), 0600))
stubStdin, err := os.Open(tempFile)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, stubStdin.Close()) })
readCloser, stdinInUse, err := FileOrStdin(stubStdin, "/dev/stdin")
require.NoError(t, err)
actualContents, err := io.ReadAll(readCloser)
require.NoError(t, err)
require.Equal(t, expectedContents, string(actualContents))
require.True(t, stdinInUse)
})
}
package binaryparam
import (
"io"
"os"
)
const stdinGlyph = "-"
// FileOrStdin opens the file at the given path for reading. If the path is "-", stdin is returned instead.
//
// It's the caller's responsibility to close the returned ReadCloser (usually with `defer`).
//
// Returns a boolean indicating whether stdin is being used. If true, no other components of the calling
// program should attempt to read from stdin for anything else.
func FileOrStdin(stdin io.ReadCloser, path string) (io.ReadCloser, bool, error) {
// When the special glyph "-" is used, read from stdin. Although probably less necessary, also support
// special Unix files that refer to stdin.
switch path {
case stdinGlyph, "/dev/fd/0", "/dev/stdin":
return stdin, true, nil
}
readCloser, err := os.Open(path)
if err != nil {
return nil, false, err
}
return readCloser, false, err
}
package debugmiddleware
import (
"bytes"
"io"
"log"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestDebugMiddleware(t *testing.T) {
t.Parallel()
setup := func() (*RequestLogger, *bytes.Buffer) {
var (
logBuf bytes.Buffer
middleware = NewRequestLogger()
)
middleware.logger = log.New(&logBuf, "", 0)
return middleware, &logBuf
}
t.Run("DoesNotRedactMostHeaders", func(t *testing.T) {
t.Parallel()
middleware, logBuf := setup()
const stainlessUserAgent = "Stainless"
req := httptest.NewRequest("GET", "https://example.com", nil)
req.Header.Set("User-Agent", stainlessUserAgent)
var nextMiddlewareRan bool
middleware.Middleware()(req, func(req *http.Request) (*http.Response, error) {
nextMiddlewareRan = true
// The request sent down through middleware shouldn't be mutated.
require.Equal(t, stainlessUserAgent, req.Header.Get("User-Agent"))
return &http.Response{}, nil
})
require.True(t, nextMiddlewareRan)
require.Contains(t, logBuf.String(), "User-Agent: "+stainlessUserAgent)
})
const secretToken = "secret-token"
t.Run("RedactsAuthorizationHeader", func(t *testing.T) {
t.Parallel()
middleware, logBuf := setup()
req := httptest.NewRequest("GET", "https://example.com", nil)
req.Header.Set("Authorization", secretToken)
var nextMiddlewareRan bool
middleware.Middleware()(req, func(req *http.Request) (*http.Response, error) {
nextMiddlewareRan = true
// The request sent down through middleware shouldn't be mutated.
require.Equal(t, secretToken, req.Header.Get("Authorization"))
return &http.Response{}, nil
})
require.True(t, nextMiddlewareRan)
require.Contains(t, logBuf.String(), "Authorization: "+redactedPlaceholder)
})
t.Run("RedactsOnlySecretInAuthorizationHeader", func(t *testing.T) {
t.Parallel()
middleware, logBuf := setup()
req := httptest.NewRequest("GET", "https://example.com", nil)
req.Header.Set("Authorization", "Bearer "+secretToken)
var nextMiddlewareRan bool
middleware.Middleware()(req, func(req *http.Request) (*http.Response, error) {
nextMiddlewareRan = true
return &http.Response{}, nil
})
require.True(t, nextMiddlewareRan)
require.Contains(t, logBuf.String(), "Authorization: Bearer "+redactedPlaceholder)
})
t.Run("RedactsMultipleAuthorizationHeaders", func(t *testing.T) {
t.Parallel()
middleware, logBuf := setup()
req := httptest.NewRequest("GET", "https://example.com", nil)
req.Header.Add("Authorization", secretToken+"1")
req.Header.Add("Authorization", secretToken+"2")
var nextMiddlewareRan bool
middleware.Middleware()(req, func(req *http.Request) (*http.Response, error) {
nextMiddlewareRan = true
// The request sent down through middleware shouldn't be mutated.
require.Equal(t, []string{secretToken + "1", secretToken + "2"}, req.Header.Values("Authorization"))
return &http.Response{}, nil
})
require.True(t, nextMiddlewareRan)
if strings.Count(logBuf.String(), "Authorization: "+redactedPlaceholder) != 2 {
t.Error("expected exactly two redacted placeholders in authorization headers")
}
})
const customAPIKeyHeader = "X-My-Api-Key"
t.Run("RedactsSensitiveHeaders", func(t *testing.T) {
t.Parallel()
middleware, logBuf := setup()
middleware.sensitiveHeaders = []string{customAPIKeyHeader}
req := httptest.NewRequest("GET", "https://example.com", nil)
req.Header.Set(customAPIKeyHeader, secretToken)
var nextMiddlewareRan bool
middleware.Middleware()(req, func(req *http.Request) (*http.Response, error) {
nextMiddlewareRan = true
// The request sent down through middleware shouldn't be mutated.
require.Equal(t, secretToken, req.Header.Get(customAPIKeyHeader))
return &http.Response{}, nil
})
require.True(t, nextMiddlewareRan)
require.Contains(t, logBuf.String(), customAPIKeyHeader+": "+redactedPlaceholder)
})
t.Run("RedactsMultipleSensitiveHeaders", func(t *testing.T) {
t.Parallel()
middleware, logBuf := setup()
middleware.sensitiveHeaders = []string{customAPIKeyHeader}
req := httptest.NewRequest("GET", "https://example.com", nil)
req.Header.Add(customAPIKeyHeader, secretToken+"1")
req.Header.Add(customAPIKeyHeader, secretToken+"2")
var nextMiddlewareRan bool
middleware.Middleware()(req, func(req *http.Request) (*http.Response, error) {
nextMiddlewareRan = true
// The request sent down through middleware shouldn't be mutated.
require.Equal(t, []string{secretToken + "1", secretToken + "2"}, req.Header.Values(customAPIKeyHeader))
return &http.Response{}, nil
})
require.True(t, nextMiddlewareRan)
require.Equal(t, 2, strings.Count(logBuf.String(), customAPIKeyHeader+": "+redactedPlaceholder))
})
t.Run("DoesNotConsumeRequestBodyWhenIoReader", func(t *testing.T) {
t.Parallel()
middleware, logBuf := setup()
middleware.sensitiveHeaders = []string{customAPIKeyHeader}
const bodyContent = "test request body content"
bodyReader := strings.NewReader(bodyContent)
req := httptest.NewRequest("POST", "https://example.com", bodyReader)
req.Header.Set("Authorization", secretToken)
var nextMiddlewareRan bool
middleware.Middleware()(req, func(req *http.Request) (*http.Response, error) {
nextMiddlewareRan = true
// The request body should still be fully readable after the middleware runs
body, err := io.ReadAll(req.Body)
require.NoError(t, err)
require.Equal(t, bodyContent, string(body))
// The request sent down through middleware shouldn't be mutated.
require.Equal(t, secretToken, req.Header.Get("Authorization"))
return &http.Response{}, nil
})
require.True(t, nextMiddlewareRan)
require.Contains(t, logBuf.String(), "Authorization: "+redactedPlaceholder)
})
}
package debugmiddleware
import (
"bytes"
"io"
"log"
"net/http"
"net/http/httputil"
"reflect"
"strings"
)
// For the time being these type definitions are duplicated here so that we can
// test this file in a non-generated context.
type (
Middleware = func(*http.Request, MiddlewareNext) (*http.Response, error)
MiddlewareNext = func(*http.Request) (*http.Response, error)
)
const redactedPlaceholder = "<REDACTED>"
// Headers known to contain sensitive information like an API key. Note that this exclude `Authorization`,
// which is handled specially in `redactRequest` below.
var sensitiveHeaders = []string{}
// RequestLogger is a middleware that logs HTTP requests and responses.
type RequestLogger struct {
logger interface{ Printf(string, ...any) } // field for testability; usually log.Default()
sensitiveHeaders []string // field for testability; usually sensitiveHeaders
}
// NewRequestLogger returns a new RequestLogger instance with default options.
func NewRequestLogger() *RequestLogger {
return &RequestLogger{
logger: log.Default(),
sensitiveHeaders: sensitiveHeaders,
}
}
func (m *RequestLogger) Middleware() Middleware {
return func(req *http.Request, mn MiddlewareNext) (*http.Response, error) {
redacted, err := m.redactRequest(req)
if err != nil {
return nil, err
}
if reqBytes, err := httputil.DumpRequest(redacted, true); err == nil {
m.logger.Printf("Request Content:\n%s\n", reqBytes)
}
resp, err := mn(req)
if err != nil {
return resp, err
}
if respBytes, err := httputil.DumpResponse(resp, true); err == nil {
m.logger.Printf("Response Content:\n%s\n", respBytes)
}
return resp, err
}
}
// redactRequest redacts sensitive information from the request for logging
// purposes. If redaction is necessary, the request is cloned before mutating
// the original and that clone is returned. As a small optimization, the
// original is request is returned unchanged if no redaction is necessary.
func (m *RequestLogger) redactRequest(req *http.Request) (*http.Request, error) {
redactedHeaders := req.Header.Clone()
// Notably, the clauses below are written so they can redact multiple
// headers of the same name if necessary.
if values := redactedHeaders.Values("Authorization"); len(values) > 0 {
redactedHeaders.Del("Authorization")
for _, value := range values {
// In case we're using something like a bearer token (e.g. `Bearer
// <my_token>`), keep the `Bearer` part for more debugging
// information.
if authKind, _, ok := strings.Cut(value, " "); ok {
redactedHeaders.Add("Authorization", authKind+" "+redactedPlaceholder)
} else {
redactedHeaders.Add("Authorization", redactedPlaceholder)
}
}
}
for _, header := range m.sensitiveHeaders {
values := redactedHeaders.Values(header)
if len(values) == 0 {
continue
}
redactedHeaders.Del(header)
for range values {
redactedHeaders.Add(header, redactedPlaceholder)
}
}
if reflect.DeepEqual(req.Header, redactedHeaders) {
return req, nil
}
redacted := req.Clone(req.Context())
redacted.Header = redactedHeaders
var err error
redacted.Body, req.Body, err = cloneBody(req.Body)
return redacted, err
}
// This function returns two copies of an HTTP request body that can each be
// read independently without affecting the other.
// This logic is taken from `drainBody` in net/http/httputil.
func cloneBody(b io.ReadCloser) (r1, r2 io.ReadCloser, err error) {
if b == nil || b == http.NoBody {
// No copying needed. Preserve the magic sentinel meaning of NoBody.
return http.NoBody, http.NoBody, nil
}
var buf bytes.Buffer
if _, err = buf.ReadFrom(b); err != nil {
return nil, b, err
}
if err = b.Close(); err != nil {
return nil, b, err
}
return io.NopCloser(&buf), io.NopCloser(bytes.NewReader(buf.Bytes())), nil
}
package jsonview
import (
"encoding/json"
"errors"
"fmt"
"math"
"os"
"strings"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/term"
"github.com/muesli/reflow/truncate"
"github.com/muesli/reflow/wordwrap"
"github.com/tidwall/gjson"
)
const (
// UI layout constants
borderPadding = 2
heightOffset = 5
tableMinHeight = 2
titlePaddingLeft = 2
titlePaddingTop = 0
footerPaddingLeft = 1
// Column width constants
defaultColumnWidth = 10
keyColumnWidth = 3
valueColumnWidth = 5
// String formatting constants
maxStringLength = 100
maxPreviewLength = 24
arrayColor = lipgloss.Color("1")
stringColor = lipgloss.Color("5")
objectColor = lipgloss.Color("4")
)
type keyMap struct {
Up key.Binding
Down key.Binding
Enter key.Binding
Back key.Binding
PrintValue key.Binding
Raw key.Binding
Quit key.Binding
}
func (k keyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Quit, k.Up, k.Down, k.Back, k.Enter, k.PrintValue, k.Raw}
}
func (k keyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{k.ShortHelp()}
}
var keys = keyMap{
Up: key.NewBinding(
key.WithKeys("up", "k"),
key.WithHelp("↑/k", "up"),
),
Down: key.NewBinding(
key.WithKeys("down", "j"),
key.WithHelp("↓/j", "down"),
),
Back: key.NewBinding(
key.WithKeys("left", "h", "backspace"),
key.WithHelp("←/h", "go back"),
),
Enter: key.NewBinding(
key.WithKeys("right", "l"),
key.WithHelp("→/l", "expand"),
),
PrintValue: key.NewBinding(
key.WithKeys("p"),
key.WithHelp("p", "print and exit"),
),
Raw: key.NewBinding(
key.WithKeys("r"),
key.WithHelp("r", "toggle raw JSON"),
),
Quit: key.NewBinding(
key.WithKeys("q", "esc", "ctrl+c", "enter"),
key.WithHelp("q/enter", "quit"),
),
}
var (
titleStyle = lipgloss.NewStyle().Bold(true).PaddingLeft(titlePaddingLeft).PaddingTop(titlePaddingTop)
arrayStyle = lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).BorderForeground(arrayColor)
stringStyle = lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).BorderForeground(stringColor)
objectStyle = lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).BorderForeground(objectColor)
stringLiteralStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("2"))
)
type JSONView interface {
GetPath() string
GetData() gjson.Result
Update(tea.Msg, bool) tea.Cmd
View() string
Resize(width, height int)
}
type TableView struct {
width int
height int
path string
data gjson.Result
table table.Model
rowData []gjson.Result
iterator AnyIterator
isLoading bool
columns []table.Column
}
func (tv *TableView) GetPath() string { return tv.path }
func (tv *TableView) GetData() gjson.Result { return tv.data }
func (tv *TableView) View() string { return tv.table.View() }
func (tv *TableView) Update(msg tea.Msg, raw bool) tea.Cmd {
var cmd tea.Cmd
tv.table, cmd = tv.table.Update(msg)
// Check if we need to load more data
if tv.iterator != nil && !tv.isLoading && tv.data.IsArray() {
cursor := tv.table.Cursor()
totalRows := len(tv.table.Rows())
// Load more when we're at the last row
if cursor == totalRows-1 {
tv.isLoading = true
return tv.loadMoreData(raw)
}
}
return cmd
}
func (tv *TableView) loadMoreData(raw bool) tea.Cmd {
return func() tea.Msg {
if tv.iterator == nil {
return nil
}
if !tv.iterator.Next() {
tv.isLoading = false
return tv.iterator.Err()
}
obj := tv.iterator.Current()
var result gjson.Result
if jsonBytes, err := json.Marshal(obj); err != nil {
return err
} else {
result = gjson.ParseBytes(jsonBytes)
}
if !result.Exists() {
tv.isLoading = false
return nil
}
// Add the new item to our data
tv.rowData = append(tv.rowData, result)
// Add new row to the table
newRow := table.Row{formatValue(result, raw)}
// For array of objects, we need to format according to columns
if len(tv.columns) > 1 && result.IsObject() {
newRow = make(table.Row, len(tv.columns))
for i, col := range tv.columns {
newRow[i] = formatValue(result.Get(col.Title), raw)
}
}
rows := tv.table.Rows()
rows = append(rows, newRow)
tv.table.SetRows(rows)
// Resize columns to accommodate the new data
tv.Resize(tv.width, tv.height)
tv.isLoading = false
return nil
}
}
func (tv *TableView) Resize(width, height int) {
tv.width = width
tv.height = height
tv.updateColumnWidths(width)
tv.table.SetHeight(min(height-heightOffset, tableMinHeight+len(tv.table.Rows())))
}
func (tv *TableView) updateColumnWidths(width int) {
columns := tv.table.Columns()
widths := make([]int, len(columns))
// Calculate required widths from headers and content
for i, col := range columns {
widths[i] = lipgloss.Width(col.Title)
}
for _, row := range tv.table.Rows() {
for i, cell := range row {
if i < len(widths) {
widths[i] = max(widths[i], lipgloss.Width(cell))
}
}
}
totalWidth := sum(widths)
available := width - borderPadding*len(columns)
if totalWidth <= available {
for i, w := range widths {
columns[i].Width = w
}
return
}
fairShare := float64(available) / float64(len(columns))
shrinkable := 0.0
for _, w := range widths {
if float64(w) > fairShare {
shrinkable += float64(w) - fairShare
}
}
if shrinkable > 0 {
excess := float64(totalWidth - available)
for i, w := range widths {
if float64(w) > fairShare {
reduction := (float64(w) - fairShare) * (excess / shrinkable)
widths[i] = int(math.Round(float64(w) - reduction))
}
}
}
for i, w := range widths {
columns[i].Width = w
}
tv.table.SetColumns(columns)
}
type TextView struct {
path string
data gjson.Result
viewport viewport.Model
ready bool
}
func (tv *TextView) GetPath() string { return tv.path }
func (tv *TextView) GetData() gjson.Result { return tv.data }
func (tv *TextView) View() string { return tv.viewport.View() }
func (tv *TextView) Update(msg tea.Msg, raw bool) tea.Cmd {
var cmd tea.Cmd
tv.viewport, cmd = tv.viewport.Update(msg)
return cmd
}
func (tv *TextView) Resize(width, height int) {
h := height - heightOffset
if !tv.ready {
tv.viewport = viewport.New(width, h)
tv.viewport.SetContent(wordwrap.String(tv.data.String(), width))
tv.ready = true
return
}
tv.viewport.Width = width
tv.viewport.Height = h
}
type JSONViewer struct {
stack []JSONView
root string
width int
height int
rawMode bool
message string
help help.Model
}
// ExploreJSON explores a single JSON value known ahead of time
func ExploreJSON(title string, json gjson.Result) error {
view, err := newView("", json, false)
if err != nil {
return err
}
viewer := &JSONViewer{stack: []JSONView{view}, root: title, rawMode: false, help: help.New()}
_, err = tea.NewProgram(viewer).Run()
if viewer.message != "" {
_, msgErr := fmt.Println("\n" + viewer.message)
err = errors.Join(err, msgErr)
}
return err
}
// ExploreJSONStream explores JSON data loaded incrementally via an iterator
func ExploreJSONStream[T any](title string, it Iterator[T]) error {
anyIt := genericToAnyIterator(it)
preloadCount := 20
if termHeight, _, err := term.GetSize(os.Stdout.Fd()); err == nil {
preloadCount = termHeight
}
items := make([]any, 0, preloadCount)
for i := 0; i < preloadCount && anyIt.Next(); i++ {
items = append(items, anyIt.Current())
}
if err := anyIt.Err(); err != nil {
return err
}
// Convert items to JSON array
jsonBytes, err := json.Marshal(items)
if err != nil {
return err
}
arrayJSON := gjson.ParseBytes(jsonBytes)
view, err := newTableView("", arrayJSON, false)
if err != nil {
return err
}
// Set iterator if there might be more data
if len(items) == preloadCount {
view.iterator = anyIt
}
viewer := &JSONViewer{stack: []JSONView{view}, root: title, rawMode: false, help: help.New()}
_, err = tea.NewProgram(viewer).Run()
if viewer.message != "" {
_, msgErr := fmt.Println("\n" + viewer.message)
err = errors.Join(err, msgErr)
}
return err
}
func (v *JSONViewer) current() JSONView { return v.stack[len(v.stack)-1] }
func (v *JSONViewer) Init() tea.Cmd { return nil }
func (v *JSONViewer) resize(width, height int) {
v.width, v.height = width, height
v.help.Width = width
for i := range v.stack {
v.stack[i].Resize(width, height)
}
}
func (v *JSONViewer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
v.resize(msg.Width-borderPadding, msg.Height)
return v, nil
case tea.KeyMsg:
switch {
case key.Matches(msg, keys.Quit):
return v, tea.Quit
case key.Matches(msg, keys.Enter):
return v.navigateForward()
case key.Matches(msg, keys.Back):
return v.navigateBack()
case key.Matches(msg, keys.Raw):
return v.toggleRaw()
case key.Matches(msg, keys.PrintValue):
v.message = v.getSelectedContent()
return v, tea.Quit
}
}
return v, v.current().Update(msg, v.rawMode)
}
func (v *JSONViewer) getSelectedContent() string {
tableView, ok := v.current().(*TableView)
if !ok {
return v.current().GetData().Raw
}
selected := tableView.rowData[tableView.table.Cursor()]
if selected.Type == gjson.String {
return selected.String()
}
return selected.Raw
}
func (v *JSONViewer) navigateForward() (tea.Model, tea.Cmd) {
tableView, ok := v.current().(*TableView)
if !ok {
return v, nil
}
cursor := tableView.table.Cursor()
selected := tableView.rowData[cursor]
if !v.canNavigateInto(selected) {
return v, nil
}
path := v.buildNavigationPath(tableView, cursor)
forwardView, err := newView(path, selected, v.rawMode)
if err != nil {
return v, nil
}
v.stack = append(v.stack, forwardView)
v.resize(v.width, v.height)
return v, nil
}
func (v *JSONViewer) buildNavigationPath(tableView *TableView, cursor int) string {
if tableView.data.IsArray() {
return fmt.Sprintf("%s[%d]", tableView.path, cursor)
}
key := tableView.data.Get("@keys").Array()[cursor].Str
return fmt.Sprintf("%s[%s]", tableView.path, quoteString(key))
}
func quoteString(s string) string {
// Replace backslashes and quotes with escaped versions
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, "\"", "\\\"")
return stringLiteralStyle.Render("\"" + s + "\"")
}
func (v *JSONViewer) canNavigateInto(data gjson.Result) bool {
switch {
case data.IsArray():
return len(data.Array()) > 0
case data.IsObject():
return len(data.Map()) > 0
case data.Type == gjson.String:
str := data.String()
return strings.Contains(str, "\n") || lipgloss.Width(str) >= maxStringLength
}
return false
}
func (v *JSONViewer) navigateBack() (tea.Model, tea.Cmd) {
if len(v.stack) > 1 {
v.stack = v.stack[:len(v.stack)-1]
}
return v, nil
}
func (v *JSONViewer) toggleRaw() (tea.Model, tea.Cmd) {
v.rawMode = !v.rawMode
for i, view := range v.stack {
viewWithRaw, err := newView(view.GetPath(), view.GetData(), v.rawMode)
if err != nil {
return v, tea.Printf("Error: %s", err)
}
if newTV, ok := viewWithRaw.(*TableView); ok {
if tv, ok := view.(*TableView); ok && tv.iterator != nil {
newTV.iterator = tv.iterator
}
}
v.stack[i] = viewWithRaw
}
v.resize(v.width, v.height)
return v, nil
}
func (v *JSONViewer) View() string {
view := v.current()
title := v.buildTitle(view)
content := titleStyle.Render(title)
style := v.getStyleForData(view.GetData())
content += "\n" + style.Render(view.View())
content += "\n" + v.help.View(keys)
return content
}
func (v *JSONViewer) buildTitle(view JSONView) string {
title := v.root
if len(view.GetPath()) > 0 {
title += " → " + view.GetPath()
}
if v.rawMode {
title += " (JSON)"
}
return title
}
func (v *JSONViewer) getStyleForData(data gjson.Result) lipgloss.Style {
switch {
case data.Type == gjson.String:
return stringStyle
case data.IsArray():
return arrayStyle
default:
return objectStyle
}
}
func newView(path string, data gjson.Result, raw bool) (JSONView, error) {
if data.Type == gjson.String {
return newTextView(path, data)
}
return newTableView(path, data, raw)
}
func newTextView(path string, data gjson.Result) (*TextView, error) {
if !data.Exists() || data.Type != gjson.String {
return nil, fmt.Errorf("invalid text JSON")
}
return &TextView{path: path, data: data}, nil
}
func newTableView(path string, data gjson.Result, raw bool) (*TableView, error) {
if !data.Exists() || data.Type != gjson.JSON {
return nil, fmt.Errorf("invalid table JSON")
}
switch {
case data.IsArray():
array := data.Array()
if isArrayOfObjects(array) {
return newArrayOfObjectsTableView(path, data, array, raw), nil
} else {
return newArrayTableView(path, data, array, raw), nil
}
case data.IsObject():
return newObjectTableView(path, data, raw), nil
default:
return nil, fmt.Errorf("unsupported JSON type")
}
}
func newArrayTableView(path string, data gjson.Result, array []gjson.Result, raw bool) *TableView {
columns := []table.Column{{Title: "Items", Width: defaultColumnWidth}}
rows := make([]table.Row, 0, len(array))
rowData := make([]gjson.Result, 0, len(array))
for _, item := range array {
rows = append(rows, table.Row{formatValue(item, raw)})
rowData = append(rowData, item)
}
t := createTable(columns, rows, arrayColor)
return &TableView{
path: path,
data: data,
table: t,
rowData: rowData,
columns: columns,
}
}
func newArrayOfObjectsTableView(path string, data gjson.Result, array []gjson.Result, raw bool) *TableView {
// Collect unique keys
keySet := make(map[string]struct{})
var columns []table.Column
for _, item := range array {
for _, key := range item.Get("@keys").Array() {
if _, exists := keySet[key.Str]; !exists {
keySet[key.Str] = struct{}{}
title := key.Str
columns = append(columns, table.Column{Title: title, Width: defaultColumnWidth})
}
}
}
rows := make([]table.Row, 0, len(array))
rowData := make([]gjson.Result, 0, len(array))
for _, item := range array {
row := make(table.Row, len(columns))
for i, col := range columns {
row[i] = formatValue(item.Get(col.Title), raw)
}
rows = append(rows, row)
rowData = append(rowData, item)
}
t := createTable(columns, rows, arrayColor)
return &TableView{
path: path,
data: data,
table: t,
rowData: rowData,
columns: columns,
}
}
func newObjectTableView(path string, data gjson.Result, raw bool) *TableView {
columns := []table.Column{{Title: "Object"}, {}}
keys := data.Get("@keys").Array()
rows := make([]table.Row, 0, len(keys))
rowData := make([]gjson.Result, 0, len(keys))
for _, key := range keys {
value := data.Get(key.Str)
title := key.Str
rows = append(rows, table.Row{title, formatValue(value, raw)})
rowData = append(rowData, value)
}
// Adjust column widths based on content
for _, row := range rows {
for i, cell := range row {
if i < len(columns) {
columns[i].Width = max(columns[i].Width, lipgloss.Width(cell))
}
}
}
t := createTable(columns, rows, objectColor)
return &TableView{
path: path,
data: data,
table: t,
rowData: rowData,
columns: columns,
}
}
func createTable(columns []table.Column, rows []table.Row, bgColor lipgloss.Color) table.Model {
t := table.New(
table.WithColumns(columns),
table.WithRows(rows),
table.WithFocused(true),
)
// Set common table styles
s := table.DefaultStyles()
s.Header = s.Header.
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240")).
BorderBottom(true).
Bold(true)
s.Selected = s.Selected.
Foreground(lipgloss.Color("229")).
Background(bgColor).
Bold(false)
t.SetStyles(s)
return t
}
func formatValue(value gjson.Result, raw bool) string {
if raw {
return value.Get("@ugly").Raw
}
switch {
case value.IsObject():
return formatObject(value)
case value.IsArray():
return formatArray(value)
case value.Type == gjson.String:
return value.Str
default:
return value.Raw
}
}
func formatObject(value gjson.Result) string {
keys := value.Get("@keys").Array()
keyStrs := make([]string, len(keys))
for i, key := range keys {
val := value.Get(key.Str)
keyStrs[i] = formatObjectKey(key.Str, val)
}
return "{" + strings.Join(keyStrs, ", ") + "}"
}
func formatObjectKey(key string, val gjson.Result) string {
switch {
case val.IsObject():
return key + ":{…}"
case val.IsArray():
return key + ":[…]"
case val.Type == gjson.String:
str := val.Str
if lipgloss.Width(str) <= maxPreviewLength {
return fmt.Sprintf(`%s:"%s"`, key, str)
}
return fmt.Sprintf(`%s:"%s…"`, key, truncate.String(str, uint(maxPreviewLength)))
default:
return key + ":" + val.Raw
}
}
func formatArray(value gjson.Result) string {
switch count := len(value.Array()); count {
case 0:
return "[]"
case 1:
return "[...1 item...]"
default:
return fmt.Sprintf("[...%d items...]", count)
}
}
func isArrayOfObjects(array []gjson.Result) bool {
for _, item := range array {
if !item.IsObject() {
return false
}
}
return len(array) > 0
}
func sum(ints []int) int {
total := 0
for _, n := range ints {
total += n
}
return total
}
// An iterator over `any` values
type AnyIterator interface {
Next() bool
Err() error
Current() any
}
// A generic iterator interface that is used by the `genericIterator` struct
// below to convert iterators over specific types to an AnyIterator
type Iterator[T any] interface {
Next() bool
Err() error
Current() T
}
// genericIterator adapts a generic Iterator[T] to an AnyIterator.
type genericIterator[T any] struct {
iterator Iterator[T]
current any
}
func (g *genericIterator[T]) Next() bool {
if !g.iterator.Next() {
return false
}
g.current = g.iterator.Current()
return true
}
func (g *genericIterator[T]) Err() error {
return g.iterator.Err()
}
func (g *genericIterator[T]) Current() any {
return g.current
}
func genericToAnyIterator[T any](it Iterator[T]) AnyIterator {
return &genericIterator[T]{
iterator: it,
}
}
package jsonview
import (
"fmt"
"os"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/term"
"github.com/muesli/reflow/truncate"
"github.com/tidwall/gjson"
)
const (
tabWidth = 2
)
var (
keyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("75")).Bold(false)
stringValueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("113"))
numberValueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("215"))
boolValueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("207"))
nullValueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")).Italic(true)
bulletStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("242"))
containerStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("63")).
Padding(0, 1)
)
func formatJSON(json gjson.Result, width int) string {
if !json.Exists() {
return nullValueStyle.Render("Invalid JSON")
}
return formatResult(json, 0, width)
}
func formatResult(result gjson.Result, indent, width int) string {
switch result.Type {
case gjson.String:
str := result.Str
if str == "" {
return nullValueStyle.Render("(empty)")
}
if lipgloss.Width(str) > width {
str = truncate.String(str, uint(width-1)) + "…"
}
return stringValueStyle.Render(str)
case gjson.Number:
return numberValueStyle.Render(result.Raw)
case gjson.True:
return boolValueStyle.Render("yes")
case gjson.False:
return boolValueStyle.Render("no")
case gjson.Null:
return nullValueStyle.Render("null")
case gjson.JSON:
if result.IsArray() {
return formatJSONArray(result, indent, width)
}
return formatJSONObject(result, indent, width)
default:
return stringValueStyle.Render(result.String())
}
}
func isSingleLine(result gjson.Result, indent int) bool {
return !(result.IsObject() || result.IsArray())
}
func formatJSONArray(result gjson.Result, indent, width int) string {
items := result.Array()
if len(items) == 0 {
return nullValueStyle.Render(" (none)")
}
numberWidth := lipgloss.Width(fmt.Sprintf("%d. ", len(items)))
var formattedItems []string
for i, item := range items {
number := fmt.Sprintf("%d.", i+1)
numbering := getIndent(indent) + bulletStyle.Render(number)
// If the item will be a one-liner, put it inline after the numbering,
// otherwise it starts with a newline and goes below the numbering.
itemWidth := width
if isSingleLine(item, indent+1) {
// Add right-padding:
numbering += strings.Repeat(" ", numberWidth-lipgloss.Width(number))
itemWidth = width - lipgloss.Width(numbering)
}
value := formatResult(item, indent+1, itemWidth)
formattedItems = append(formattedItems, numbering+value)
}
return "\n" + strings.Join(formattedItems, "\n")
}
func formatJSONObject(result gjson.Result, indent, width int) string {
keys := result.Get("@keys").Array()
if len(keys) == 0 {
return nullValueStyle.Render("(empty)")
}
var items []string
for _, key := range keys {
value := result.Get(key.String())
keyStr := getIndent(indent) + keyStyle.Render(key.String()+":")
// If item will be a one-liner, put it inline after the key, otherwise
// it starts with a newline and goes below the key.
itemWidth := width
if isSingleLine(value, indent+1) {
keyStr += " "
itemWidth = width - lipgloss.Width(keyStr)
}
formattedValue := formatResult(value, indent+1, itemWidth)
items = append(items, keyStr+formattedValue)
}
return "\n" + strings.Join(items, "\n")
}
func getIndent(indent int) string {
return strings.Repeat(" ", indent*tabWidth)
}
func RenderJSON(title string, json gjson.Result) string {
width, _, err := term.GetSize(os.Stdout.Fd())
if err != nil {
width = 80
}
width -= containerStyle.GetBorderLeftSize() + containerStyle.GetBorderRightSize() +
containerStyle.GetPaddingLeft() + containerStyle.GetPaddingRight()
content := strings.TrimLeft(formatJSON(json, width), "\n")
return titleStyle.Render(title) + "\n" + containerStyle.Render(content)
}
package mocktest
import (
"context"
"fmt"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
)
var mockServerURL *url.URL
func init() {
mockServerURL, _ = url.Parse("http://localhost:4010")
if testURL := os.Getenv("TEST_API_BASE_URL"); testURL != "" {
if parsed, err := url.Parse(testURL); err == nil {
mockServerURL = parsed
}
}
}
// OnlyMockServerDialer only allows network connections to the mock server
type OnlyMockServerDialer struct{}
func (d *OnlyMockServerDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
if address == mockServerURL.Host {
return (&net.Dialer{}).DialContext(ctx, network, address)
}
return nil, fmt.Errorf("BLOCKED: connection to %s not allowed (only allowed: %s)", address, mockServerURL.Host)
}
func blockNetworkExceptMockServer() (http.RoundTripper, http.RoundTripper) {
restricted := &http.Transport{
DialContext: (&OnlyMockServerDialer{}).DialContext,
}
origClient, origDefault := http.DefaultClient.Transport, http.DefaultTransport
http.DefaultClient.Transport, http.DefaultTransport = restricted, restricted
return origClient, origDefault
}
func restoreNetwork(origClient, origDefault http.RoundTripper) {
http.DefaultClient.Transport, http.DefaultTransport = origClient, origDefault
}
// TestRunMockTestWithFlags runs a test against a mock server with the provided
// CLI flags and ensures it succeeds
func TestRunMockTestWithFlags(t *testing.T, flags ...string) {
origClient, origDefault := blockNetworkExceptMockServer()
defer restoreNetwork(origClient, origDefault)
// Check if mock server is running
conn, err := net.DialTimeout("tcp", mockServerURL.Host, 2*time.Second)
if err != nil {
require.Fail(t, "Mock server is not running on "+mockServerURL.Host+". Please start the mock server before running tests.")
} else {
conn.Close()
}
// Get the path to the main command
_, filename, _, ok := runtime.Caller(0)
require.True(t, ok, "Could not get current file path")
dirPath := filepath.Dir(filename)
project := filepath.Join(dirPath, "..", "..", "cmd", "...")
args := []string{"run", project, "--base-url", mockServerURL.String()}
args = append(args, flags...)
t.Logf("Testing command: agentmail %s", strings.Join(args[4:], " "))
cliCmd := exec.Command("go", args...)
// Pipe the CLI tool's output into `head` so it doesn't hang when simulating
// paginated or streamed endpoints. 100 lines of output should be enough to
// test that the API endpoint worked, or report back a meaningful amount of
// data if something went wrong.
headCmd := exec.Command("head", "-n", "100")
pipe, err := cliCmd.StdoutPipe()
require.NoError(t, err, "Failed to create pipe for CLI command")
headCmd.Stdin = pipe
// Capture `head` output and CLI command stderr outputs:
var output strings.Builder
headCmd.Stdout = &output
headCmd.Stderr = &output
cliCmd.Stderr = &output
// First start `head`, so it's ready for data to come in:
err = headCmd.Start()
require.NoError(t, err, "Failed to start `head` command")
// Next start the CLI command so it can pipe data to `head` without
// buffering any data in advance:
err = cliCmd.Start()
require.NoError(t, err, "Failed to start CLI command")
// Ensure that the stdout pipe is closed as soon as `head` exits, to let the
// CLI tool know that no more output is needed and it can stop streaming
// test data for streaming/paginated endpoints. This needs to happen before
// calling `cliCmd.Wait()`, otherwise there will be a deadlock.
err = headCmd.Wait()
pipe.Close()
require.NoError(t, err, "`head` command finished with an error")
// Finally, wait for the CLI tool to finish up:
err = cliCmd.Wait()
require.NoError(t, err, "CLI command failed\n%s", output.String())
t.Logf("Test passed successfully\nOutput:\n%s", output.String())
}
func TestFile(t *testing.T, contents string) string {
tmpDir := t.TempDir()
filename := filepath.Join(tmpDir, "file.txt")
require.NoError(t, os.WriteFile(filename, []byte(contents), 0644))
return filename
}
package requestflag
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/urfave/cli/v3"
)
func TestInnerFlagSet(t *testing.T) {
tests := []struct {
name string
flagType string
inputVal string
expected any
expectErr bool
}{
{"string", "string", "hello", "hello", false},
{"int64", "int64", "42", int64(42), false},
{"float64", "float64", "3.14", float64(3.14), false},
{"bool", "bool", "true", true, false},
{"invalid int", "int64", "not-a-number", nil, true},
{"invalid float", "float64", "not-a-float", nil, true},
{"invalid bool", "bool", "not-a-bool", nil, true},
{"yaml map", "map", "key: value", map[string]any{"key": "value"}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
outerFlag := &Flag[map[string]any]{
Name: "test-flag",
}
var innerFlag cli.Flag
switch tt.flagType {
case "string":
innerFlag = &InnerFlag[string]{
Name: "test-flag.test-field",
OuterFlag: outerFlag,
InnerField: "test_field",
}
case "int64":
innerFlag = &InnerFlag[int64]{
Name: "test-flag.test-field",
OuterFlag: outerFlag,
InnerField: "test_field",
}
case "float64":
innerFlag = &InnerFlag[float64]{
Name: "test-flag.test-field",
OuterFlag: outerFlag,
InnerField: "test_field",
}
case "bool":
innerFlag = &InnerFlag[bool]{
Name: "test-flag.test-field",
OuterFlag: outerFlag,
InnerField: "test_field",
}
case "map":
innerFlag = &InnerFlag[map[string]any]{
Name: "test-flag.test-field",
OuterFlag: outerFlag,
InnerField: "test_field",
}
}
err := innerFlag.Set(innerFlag.Names()[0], tt.inputVal)
if tt.expectErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
actual, ok := outerFlag.Get().(map[string]any)["test_field"]
assert.True(t, ok, "Field 'test_field' should exist in the map")
assert.Equal(t, tt.expected, actual, "Expected %v (%T), got %v (%T)", tt.expected, tt.expected, actual, actual)
})
}
}
func TestInnerFlagValidator(t *testing.T) {
outerFlag := &Flag[map[string]any]{Name: "test-flag"}
innerFlag := &InnerFlag[int64]{
Name: "test-flag.test-field",
OuterFlag: outerFlag,
InnerField: "test_field",
Validator: func(val int64) error {
if val < 0 {
return cli.Exit("Value must be non-negative", 1)
}
return nil
},
}
// Valid case
err := innerFlag.Set(innerFlag.Name, "42")
assert.NoError(t, err, "Expected no error for valid value, got: %v", err)
// Should trigger validator error
err = innerFlag.Set(innerFlag.Name, "-5")
assert.Error(t, err, "Expected error for invalid value, got none")
}
func TestWithInnerFlags(t *testing.T) {
outerFlag := &Flag[map[string]any]{Name: "outer"}
innerFlag := &InnerFlag[string]{
Name: "outer.baz",
InnerField: "baz",
}
cmd := WithInnerFlags(cli.Command{
Name: "test-command",
Flags: []cli.Flag{outerFlag},
}, map[string][]HasOuterFlag{
"outer": {innerFlag},
})
// Verify that the command now has both the original flag and inner flag
assert.Len(t, cmd.Flags, 2, "Expected 2 flags, got %d", len(cmd.Flags))
assert.Equal(t, outerFlag, cmd.Flags[0], "First flag should be outerFlag")
assert.Equal(t, innerFlag, cmd.Flags[1], "Second flag should be innerFlag")
assert.Same(t, outerFlag, innerFlag.OuterFlag, "innerFlag.OuterFlag should point to outerFlag")
}
func TestInnerFlagTypeNames(t *testing.T) {
tests := []struct {
name string
flag cli.DocGenerationFlag
expected string
}{
{"string", &InnerFlag[string]{}, "string"},
{"int64", &InnerFlag[int64]{}, "int"},
{"float64", &InnerFlag[float64]{}, "float"},
{"bool", &InnerFlag[bool]{}, "boolean"},
{"string slice", &InnerFlag[[]string]{}, "string"},
{"date", &InnerFlag[DateValue]{}, "date"},
{"datetime", &InnerFlag[DateTimeValue]{}, "datetime"},
{"time", &InnerFlag[TimeValue]{}, "time"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
typeName := tt.flag.TypeName()
assert.Equal(t, tt.expected, typeName, "Expected type name %q, got %q", tt.expected, typeName)
})
}
}
func TestInnerYamlHandling(t *testing.T) {
// Test with map value
t.Run("Parse YAML to map", func(t *testing.T) {
outerFlag := &Flag[map[string]any]{Name: "outer"}
innerFlag := &InnerFlag[map[string]any]{
Name: "outer.baz",
OuterFlag: outerFlag,
InnerField: "baz",
}
err := innerFlag.Set(innerFlag.Name, "{name: test, value: 42}")
assert.NoError(t, err)
// Retrieve and check the parsed YAML map
result, ok := outerFlag.Get().(map[string]any)
assert.True(t, ok, "Expected map[string]any from outerFlag.Get()")
yamlField, ok := result["baz"].(map[string]any)
assert.True(t, ok, "Expected map[string]any, got %T", result["baz"])
val := yamlField
if ok {
assert.Equal(t, map[string]any{"name": "test", "value": uint64(42)}, val)
}
})
// Test with invalid YAML
t.Run("Parse invalid YAML", func(t *testing.T) {
outerFlag := &Flag[map[string]any]{Name: "outer"}
innerFlag := &InnerFlag[map[string]any]{
Name: "outer.baz",
OuterFlag: outerFlag,
InnerField: "baz",
}
invalidYaml := `[not closed`
err := innerFlag.Set(innerFlag.Name, invalidYaml)
assert.Error(t, err)
})
// Test setting inner flags on a map multiple times
t.Run("Set inner flags on map multiple times", func(t *testing.T) {
outerFlag := &Flag[map[string]any]{Name: "outer"}
// Set first inner flag
firstInnerFlag := &InnerFlag[string]{
Name: "outer.first-flag",
OuterFlag: outerFlag,
InnerField: "first_field",
}
err := firstInnerFlag.Set(firstInnerFlag.Name, "first-value")
assert.NoError(t, err)
// Set second inner flag
secondInnerFlag := &InnerFlag[int64]{
Name: "outer.second-flag",
OuterFlag: outerFlag,
InnerField: "second_field",
}
err = secondInnerFlag.Set(secondInnerFlag.Name, "42")
assert.NoError(t, err)
// Verify both fields are set correctly
result := outerFlag.Get().(map[string]any)
assert.Equal(t, map[string]any{"first_field": "first-value", "second_field": int64(42)}, result)
})
// Test setting YAML and then an inner flag
t.Run("Set YAML and then inner flag", func(t *testing.T) {
outerFlag := &Flag[map[string]any]{Name: "outer"}
// First set the outer flag with YAML
err := outerFlag.Set(outerFlag.Name, `{existing: value, another: field}`)
assert.NoError(t, err)
// Then set an inner flag
innerFlag := &InnerFlag[string]{
Name: "outer.inner-flag",
OuterFlag: outerFlag,
InnerField: "new_field",
}
err = innerFlag.Set(innerFlag.Name, "inner-value")
assert.NoError(t, err)
// Verify both the YAML content and inner flag value
result := outerFlag.Get().(map[string]any)
assert.Equal(t, map[string]any{
"existing": "value",
"another": "field",
"new_field": "inner-value",
}, result)
})
}
func TestInnerFlagWithSliceType(t *testing.T) {
t.Run("Setting inner flags on slice of maps", func(t *testing.T) {
outerFlag := &Flag[[]map[string]any]{Name: "outer"}
// Set first inner flag (should create first item)
firstInnerFlag := &InnerFlag[string]{
Name: "outer.name-flag",
OuterFlag: outerFlag,
InnerField: "name",
}
err := firstInnerFlag.Set(firstInnerFlag.Name, "item1")
assert.NoError(t, err)
// Set second inner flag (should modify first item)
secondInnerFlag := &InnerFlag[int64]{
Name: "outer.count-flag",
OuterFlag: outerFlag,
InnerField: "count",
}
err = secondInnerFlag.Set(secondInnerFlag.Name, "42")
assert.NoError(t, err)
// Set name flag again (should create second item)
err = firstInnerFlag.Set(firstInnerFlag.Name, "item2")
assert.NoError(t, err)
// Verify the slice has two items with correct values
result := outerFlag.Get().([]map[string]any)
assert.Equal(t, []map[string]any{
{"name": "item1", "count": int64(42)},
{"name": "item2"},
}, result)
assert.Nil(t, result[1]["count"], "Second item should not have count field")
})
t.Run("Appending to existing slice", func(t *testing.T) {
// Initialize with existing items
outerFlag := &Flag[[]map[string]any]{Name: "outer"}
err := outerFlag.Set(outerFlag.Name, `{name: initial}`)
assert.NoError(t, err)
// Set inner flag to modify existing item
modifyFlag := &InnerFlag[string]{
Name: "outer.value-flag",
OuterFlag: outerFlag,
InnerField: "value",
}
err = modifyFlag.Set(modifyFlag.Name, "updated")
assert.NoError(t, err)
// Set inner flag to create new item
newItemFlag := &InnerFlag[string]{
Name: "outer.name-flag",
OuterFlag: outerFlag,
InnerField: "name",
}
err = newItemFlag.Set(newItemFlag.Name, "second")
assert.NoError(t, err)
// Verify both items
result := outerFlag.Get().([]map[string]any)
assert.Equal(t, []map[string]any{
{"name": "initial", "value": "updated"},
{"name": "second"},
}, result)
})
}
package requestflag
import (
"fmt"
"reflect"
"strings"
"github.com/urfave/cli/v3"
)
// InnerFlag[T] represents a CLI flag for the urfave/cli package that allows setting
// nested fields within other flags. For example, using `--foo.baz` will set the "baz"
// field on a parent flag named `--foo`.
type InnerFlag[
T []any | []map[string]any | []DateTimeValue | []DateValue | []TimeValue | []string |
[]float64 | []int64 | []bool | any | map[string]any | DateTimeValue | DateValue | TimeValue |
string | float64 | int64 | bool,
] struct {
Name string // name of the flag
DefaultText string // default text of the flag for usage purposes
Usage string // usage string for help output
Aliases []string // aliases that are allowed for this flag
Validator func(T) error // custom function to validate this flag value
OuterFlag cli.Flag // The flag on which this inner flag will set values
InnerField string // The inner field which this flag will set
}
type HasOuterFlag interface {
cli.Flag
SetOuterFlag(cli.Flag)
GetOuterFlag() cli.Flag
}
func (f *InnerFlag[T]) SetOuterFlag(flag cli.Flag) {
f.OuterFlag = flag
}
func (f *InnerFlag[T]) GetOuterFlag() cli.Flag {
return f.OuterFlag
}
// Implementation of the cli.Flag interface
var _ cli.Flag = (*InnerFlag[any])(nil) // Type assertion to ensure interface compliance
func (f *InnerFlag[T]) PreParse() error {
return nil
}
func (f *InnerFlag[T]) PostParse() error {
return nil
}
func (f *InnerFlag[T]) Set(name string, rawVal string) error {
if parsedValue, err := parseCLIArg[T](rawVal); err != nil {
return err
} else {
if f.Validator != nil {
if err := f.Validator(parsedValue); err != nil {
return err
}
}
if settableInnerField, ok := f.OuterFlag.(SettableInnerField); ok {
settableInnerField.SetInnerField(f.InnerField, parsedValue)
} else {
return fmt.Errorf("Cannot set inner field on %v", f.OuterFlag)
}
return nil
}
}
func (f *InnerFlag[T]) Get() any {
var zeroValue T
return zeroValue
}
func (f *InnerFlag[T]) String() string {
return cli.FlagStringer(f)
}
func (f *InnerFlag[T]) IsSet() bool {
return false
}
func (f *InnerFlag[T]) Names() []string {
return cli.FlagNames(f.Name, f.Aliases)
}
// Implementation for the cli.DocGenerationFlag interface
var _ cli.DocGenerationFlag = (*InnerFlag[any])(nil) // Type assertion to ensure interface compliance
func (f *InnerFlag[T]) TakesValue() bool {
var t T
return reflect.TypeOf(t) == nil || reflect.TypeOf(t).Kind() != reflect.Bool
}
func (f *InnerFlag[T]) GetUsage() string {
return f.Usage
}
func (f *InnerFlag[T]) GetValue() string {
return ""
}
func (f *InnerFlag[T]) GetDefaultText() string {
return f.DefaultText
}
func (f *InnerFlag[T]) GetEnvVars() []string {
return nil
}
func (f *InnerFlag[T]) IsDefaultVisible() bool {
return false
}
func (f *InnerFlag[T]) TypeName() string {
var zeroValue T
ty := reflect.TypeOf(zeroValue)
if ty == nil {
return ""
}
// Get base type name with special handling for built-in types
getTypeName := func(t reflect.Type) string {
switch t.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return "int"
case reflect.Float32, reflect.Float64:
return "float"
case reflect.Bool:
return "boolean"
case reflect.String:
switch t.Name() {
case "DateTimeValue":
return "datetime"
case "DateValue":
return "date"
case "TimeValue":
return "time"
default:
return "string"
}
default:
if t.Name() == "" {
return "any"
}
return strings.ToLower(t.Name())
}
}
switch ty.Kind() {
case reflect.Slice:
elemType := ty.Elem()
return getTypeName(elemType)
case reflect.Map:
keyType := ty.Key()
valueType := ty.Elem()
return fmt.Sprintf("%s=%s", getTypeName(keyType), getTypeName(valueType))
default:
return getTypeName(ty)
}
}
// Implementation for the cli.DocGenerationMultiValueFlag interface
var _ cli.DocGenerationMultiValueFlag = (*InnerFlag[any])(nil) // Type assertion to ensure interface compliance
func (f *InnerFlag[T]) IsMultiValueFlag() bool {
return false
}
func (f *InnerFlag[T]) IsBoolFlag() bool {
var zeroValue T
_, isBool := any(zeroValue).(bool)
return isBool
}
// WithInnerFlags takes a command and a map of flag names to inner flags,
// and returns a modified command with the appropriate inner flags set.
func WithInnerFlags(cmd cli.Command, innerFlagMap map[string][]HasOuterFlag) cli.Command {
if len(innerFlagMap) == 0 {
return cmd
}
// If any keys are unused by the end, we know that they were not valid
unusedInnerFlagKeys := make(map[string]struct{})
for name := range innerFlagMap {
unusedInnerFlagKeys[name] = struct{}{}
}
updatedFlags := make([]cli.Flag, 0, len(cmd.Flags))
for _, flag := range cmd.Flags {
updatedFlags = append(updatedFlags, flag)
for _, name := range flag.Names() {
// Check if this flag has inner flags in our map
innerFlags, hasInnerFlags := innerFlagMap[name]
if !hasInnerFlags {
continue
}
// Mark this inner flag key as used
delete(unusedInnerFlagKeys, name)
for _, innerFlag := range innerFlags {
innerFlag.SetOuterFlag(flag)
updatedFlags = append(updatedFlags, innerFlag)
}
}
}
// If there are inner flags that don't correspond to any valid outer flag
// names, then panic because the user probably made a typo or forgot to
// delete inner flags that correspond to missing outer flags.
if len(unusedInnerFlagKeys) > 0 {
unusedKeys := make([]string, 0, len(unusedInnerFlagKeys))
for key := range unusedInnerFlagKeys {
unusedKeys = append(unusedKeys, key)
}
panic(fmt.Sprintf("Missing outer flags to use with inner flags: %v", unusedKeys))
}
result := cmd
result.Flags = updatedFlags
return result
}
// Helper function to verify that all inner flags have an outer flag set and
// follow the --foo.baz prefix format
func CheckInnerFlags(cmd cli.Command) error {
var errors []string
for _, flag := range cmd.Flags {
if innerFlag, ok := flag.(HasOuterFlag); ok {
outerFlag := innerFlag.GetOuterFlag()
if outerFlag == nil {
errors = append(errors, fmt.Sprintf("inner flag %s is missing an outer flag", flag.Names()))
continue
}
innerFlagName := flag.Names()[0]
valid := false
for _, outerName := range outerFlag.Names() {
if strings.HasPrefix(innerFlagName, outerName+".") {
valid = true
break
}
}
if !valid {
errors = append(errors, fmt.Sprintf("inner flag %s must start with one of its outer flag's names followed by a dot", innerFlagName))
}
}
}
if len(errors) > 0 {
return fmt.Errorf("%s", strings.Join(errors, "; "))
}
return nil
}
bin/.agentmail
bin/.agentmail.exe
.tmp/
node_modules/
#!/usr/bin/env node
const { execFileSync } = require("child_process");
const path = require("path");
const ext = process.platform === "win32" ? ".exe" : "";
const binary = path.join(__dirname, `.agentmail${ext}`);
try {
execFileSync(binary, process.argv.slice(2), { stdio: "inherit" });
} catch (err) {
if (err.status != null) {
process.exit(err.status);
}
console.error(`Failed to run agentmail: ${err.message}`);
console.error("Try reinstalling: npm install -g @agentmail/cli");
process.exit(1);
}
//go:build windows
package cmd
import "os"
func streamOutputOSSpecific(label string, generateOutput func(w *os.File) error) error {
// We have a trick with sockets that we use when possible on Unix-like systems. Those APIs aren't
// available on Windows, so we fall back to using pipes.
return streamToPagerWithPipe(label, generateOutput)
}
// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
package cmd
const Version = "0.7.1" // x-release-please-version
Related skills
FAQ
How is the CLI installed?
Via npm install -g agentmail-cli, per the skill's install step.
What credential does it need?
The AGENTMAIL_API_KEY environment variable.