Skip to content
Merged
91 changes: 90 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,93 @@ jobs:
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}

homebrew:
runs-on: ubuntu-latest
needs: goreleaser
steps:
- name: Update Homebrew formula
env:
TAG: ${{ github.ref_name }}
HOMEBREW_TAP_DEPLOY_KEY: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }}
run: |
VERSION="${TAG#v}"
BASE_URL="https://github.com/serpapi/serpapi-cli/releases/download/${TAG}"

# Fetch checksums
CHECKSUMS=$(curl -fsSL "${BASE_URL}/serpapi_${VERSION}_checksums.txt")

get_sha() {
local sha
sha=$(echo "$CHECKSUMS" | grep -F "$1" | awk '{print $1}')
if [ -z "$sha" ]; then
echo "ERROR: checksum not found for $1" >&2
exit 1
fi
echo "$sha"
}

SHA_DARWIN_AMD64=$(get_sha "serpapi_${VERSION}_darwin_amd64.tar.gz")
SHA_DARWIN_ARM64=$(get_sha "serpapi_${VERSION}_darwin_arm64.tar.gz")
SHA_LINUX_AMD64=$(get_sha "serpapi_${VERSION}_linux_amd64.tar.gz")
SHA_LINUX_ARM64=$(get_sha "serpapi_${VERSION}_linux_arm64.tar.gz")

cat > /tmp/serpapi-cli.rb <<FORMULA
class SerpapiCli < Formula
desc "HTTP client for structured web search data via SerpApi"
homepage "https://serpapi.com"
version "${VERSION}"
license "MIT"

on_macos do
if Hardware::CPU.arm?
url "${BASE_URL}/serpapi_${VERSION}_darwin_arm64.tar.gz"
sha256 "${SHA_DARWIN_ARM64}"
else
url "${BASE_URL}/serpapi_${VERSION}_darwin_amd64.tar.gz"
sha256 "${SHA_DARWIN_AMD64}"
end
end

on_linux do
if Hardware::CPU.arm?
url "${BASE_URL}/serpapi_${VERSION}_linux_arm64.tar.gz"
sha256 "${SHA_LINUX_ARM64}"
else
url "${BASE_URL}/serpapi_${VERSION}_linux_amd64.tar.gz"
sha256 "${SHA_LINUX_AMD64}"
end
end

def install
bin.install "serpapi"
end

test do
assert_match version.to_s, shell_output("#{bin}/serpapi --version")
assert_match "search", shell_output("#{bin}/serpapi --help")
assert_match "No API key", shell_output("#{bin}/serpapi account 2>&1", 2)
assert_match "No API key", shell_output("#{bin}/serpapi archive abc123 2>&1", 2)
assert_match "Invalid archive ID", shell_output("#{bin}/serpapi --api-key x archive ../bad 2>&1", 2)
end
end
FORMULA

# Set up SSH with deploy key
mkdir -p ~/.ssh
printf '%s\n' "$HOMEBREW_TAP_DEPLOY_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
printf '%s\n' "github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl" > ~/.ssh/known_hosts
export GIT_SSH_COMMAND="ssh -i ~/.ssh/deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes"

# Clone, update formula, push
git clone git@github.com:serpapi/homebrew-tap.git
cp /tmp/serpapi-cli.rb homebrew-tap/Formula/serpapi-cli.rb
cd homebrew-tap
git config user.email "github-actions[bot]@users.noreply.github.com"
git config user.name "github-actions[bot]"
git add Formula/serpapi-cli.rb
git diff --cached --quiet && echo "Formula already up to date" && exit 0
git commit -m "serpapi-cli ${VERSION}"
git push

20 changes: 2 additions & 18 deletions .goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,21 +44,5 @@ changelog:
- "^docs:"
- "^test:"

brews:
- name: serpapi-cli
repository:
owner: serpapi
name: homebrew-tap
token: "{{ .Env.HOMEBREW_TAP_TOKEN }}"
folder: Formula
homepage: https://serpapi.com
description: "HTTP client for structured web search data via SerpApi"
license: MIT
install: |
bin.install "serpapi"
test: |
assert_match version.to_s, shell_output("#{bin}/serpapi --version")
assert_match "search", shell_output("#{bin}/serpapi --help")
assert_match "No API key", shell_output("#{bin}/serpapi account 2>&1", 2)
assert_match "No API key", shell_output("#{bin}/serpapi archive abc123 2>&1", 2)
assert_match "Invalid archive ID", shell_output("#{bin}/serpapi --api-key x archive ../bad 2>&1", 2)
announce:
skip: true
25 changes: 24 additions & 1 deletion pkg/cmd/account.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package cmd

import (
"encoding/json"
"regexp"

"github.com/spf13/cobra"

"github.com/serpapi/serpapi-cli/pkg/api"
Expand Down Expand Up @@ -32,5 +35,25 @@ func runAccount(cmd *cobra.Command, args []string) error {
return err
}

return handleOutput(result)
return handleOutput(maskAPIKey(result))
}

var reAPIKey = regexp.MustCompile(`("api_key"\s*:\s*")([^"]{9,})(")`)

// maskAPIKey replaces the api_key value in raw JSON with a masked version (first 4 + last 4).
// Operates on raw bytes to preserve original field order.
func maskAPIKey(raw json.RawMessage) json.RawMessage {
return reAPIKey.ReplaceAllFunc(raw, func(match []byte) []byte {
sub := reAPIKey.FindSubmatch(match)
if len(sub) < 4 {
return match
}
key := string(sub[2])
masked := key[:4] + "…" + key[len(key)-4:]
var buf []byte
buf = append(buf, sub[1]...)
buf = append(buf, masked...)
buf = append(buf, sub[3]...)
return buf
})
}
Comment on lines +41 to 59

@ilyazub ilyazub Jul 4, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, SerpApi keys are 64 hex chars; a sub-9-char value isn't a real key.

27 changes: 27 additions & 0 deletions pkg/cmd/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,33 @@ func init() {
}

func runLogin(cmd *cobra.Command, args []string) error {
// Check if already authenticated
if existingKey, ok := config.LoadAPIKey(); ok {
client := api.New(existingKey)
raw, err := client.Account(cmd.Context())
if err == nil {
var account struct {
AccountEmail string `json:"account_email"`
AccountStatus string `json:"account_status"`
}
if json.Unmarshal(raw, &account) == nil && account.AccountEmail != "" {
fmt.Fprintf(os.Stderr, "Already logged in as %s (%s).\n", account.AccountEmail, account.AccountStatus)
if term.IsTerminal(int(os.Stdin.Fd())) {
fmt.Fprint(os.Stderr, "Re-authenticate with a different key? [y/N] ")
reader := bufio.NewReader(os.Stdin)
line, _ := reader.ReadString('\n')
if strings.TrimSpace(strings.ToLower(line)) != "y" {
return nil
}
} else {
// Non-interactive: exit non-zero so CI detects the no-op
return fmt.Errorf("already logged in as %s. Delete %s to re-authenticate", account.AccountEmail, config.Path())
}
}
}
// If API call failed (e.g. expired key), fall through to re-login
}

var apiKey string

if term.IsTerminal(int(os.Stdin.Fd())) {
Expand Down
3 changes: 3 additions & 0 deletions pkg/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ var (
var rootCmd = &cobra.Command{
Use: "serpapi",
Short: "HTTP client for structured web search data via SerpApi",
Example: ` serpapi search engine=google q=coffee
serpapi search engine=google_light q="best pizza NYC" --jq ".organic_results[:3]"
serpapi account`,
Version: version.Version,
SilenceUsage: true,
SilenceErrors: true,
Expand Down
9 changes: 7 additions & 2 deletions pkg/cmd/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,13 @@ const defaultMaxPages = 100
var searchCmd = &cobra.Command{
Use: "search [PARAMS...]",
Short: "Perform a search with any supported SerpApi engine",
Args: cobra.ArbitraryArgs,
RunE: runSearch,
Example: ` serpapi search engine=google q=coffee
serpapi search engine=google_light q="weather in Tokyo"
serpapi search engine=google_maps q="pizza" ll="@40.7455096,-74.0083012,14z"
serpapi search engine=google q=coffee --jq ".organic_results[:3]"
serpapi search engine=google q=coffee --all-pages --max-pages 3`,
Args: cobra.ArbitraryArgs,
RunE: runSearch,
}

func init() {
Expand Down
Loading