From b0c2297bf9c0c4fbc07a63d75990949766393cf4 Mon Sep 17 00:00:00 2001 From: Fahad Date: Fri, 4 Sep 2026 09:10:41 +0200 Subject: [PATCH 1/3] refresh: match the CLI to the current platform Toolchain: Go 1.27.1, dependencies updated, CI tools pinned, GoReleaser v2 archive key (format -> formats) so the next tag builds. Behaviour: - REDPINE_API_KEY / REDPINE_BASE_URL read first, CONNECT_* kept as fallbacks; same order as the SDKs. - Output honours the terminal: JSON when piped, pretty in a TTY, --json and --pretty force either. --pretty was a no-op and piped output was never JSON. - Tool results flagged isError render as the error envelope, exit 1. - HTTP 401 on the MCP handshake exits 2 (auth), not 4 (server). - Transport: 120s timeout, Accept incl. text/event-stream, MCP-Protocol-Version and User-Agent headers. - Update check is a stderr notice, no longer a hard block on every command. - redpine update verifies the archive against the release checksums.txt. - logout resolves the server URL like every other command (env was skipped). Commands: - New: preview, confirm (alias unlock), balance, collections list. - whoami labels sandbox keys; auth status is whoami. - docs opens docs.redpine.ai with aliases for the old slugs. - search --filter help lists the current indexed fields. Docs: README and AGENTS.md describe the binary that ships. Claude-Session: https://claude.ai/code/session_01BJch6rj7Xw6H3AhpcDGPrp --- .github/workflows/ci.yml | 9 +- .github/workflows/release.yml | 2 +- .github/workflows/security.yml | 8 +- .goreleaser.yml | 6 +- AGENTS.md | 95 ++++++--- CONTRIBUTING.md | 4 +- README.md | 84 +++++--- cmd/root.go | 63 +++--- go.mod | 21 +- go.sum | 38 ++-- internal/command/auth/login.go | 14 +- internal/command/auth/logout.go | 6 + internal/command/auth/status.go | 63 +----- internal/command/balance/balance.go | 39 ++++ internal/command/collections/collections.go | 59 +++--- internal/command/confirm/confirm.go | 54 +++++ internal/command/docs/docs.go | 58 +++++- internal/command/docs/docs_test.go | 23 +++ internal/command/preview/preview.go | 72 +++++++ internal/command/search/search.go | 105 ++++------ internal/command/tools/call.go | 6 +- internal/command/tools/info.go | 4 +- internal/command/tools/list.go | 4 +- internal/command/update/update.go | 217 +++++++++++++------- internal/command/update/update_test.go | 33 +++ internal/command/whoami/whoami.go | 131 ++++++------ internal/config/auth.go | 9 +- internal/config/auth_test.go | 39 +++- internal/config/config.go | 24 ++- internal/config/config_test.go | 9 +- internal/config/keyring.go | 4 +- internal/factory/factory.go | 15 +- internal/fuzzy/match.go | 2 +- internal/mcp/client.go | 10 +- internal/mcp/transport.go | 15 +- internal/output/errors.go | 28 +++ internal/output/errors_helpers_test.go | 24 +++ internal/output/mcp_result_test.go | 61 ++++++ internal/output/output.go | 52 +++-- internal/output/output_test.go | 4 +- internal/update/check.go | 18 +- 41 files changed, 1046 insertions(+), 486 deletions(-) create mode 100644 internal/command/balance/balance.go create mode 100644 internal/command/confirm/confirm.go create mode 100644 internal/command/docs/docs_test.go create mode 100644 internal/command/preview/preview.go create mode 100644 internal/command/update/update_test.go create mode 100644 internal/output/errors_helpers_test.go create mode 100644 internal/output/mcp_result_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c2afc3..5a0ce6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest] - go: ["1.26.6"] + go: ["1.27.1"] steps: - uses: actions/checkout@v7 - uses: actions/setup-go@v7 @@ -31,9 +31,10 @@ jobs: - uses: actions/checkout@v7 - uses: actions/setup-go@v7 with: - go-version: "1.26.6" + go-version: "1.27.1" - run: go vet ./... - - run: go install honnef.co/go/tools/cmd/staticcheck@latest && staticcheck ./... + # Pinned: an unpinned @latest lets an upstream release turn main red with no local change. + - run: go install honnef.co/go/tools/cmd/staticcheck@v0.8.1 && staticcheck ./... build: runs-on: ubuntu-latest @@ -41,7 +42,7 @@ jobs: - uses: actions/checkout@v7 - uses: actions/setup-go@v7 with: - go-version: "1.26.6" + go-version: "1.27.1" - run: | GOOS=linux GOARCH=amd64 go build -o /dev/null . GOOS=darwin GOARCH=arm64 go build -o /dev/null . diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6d99131..3ff1aa7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,7 @@ jobs: fetch-depth: 0 - uses: actions/setup-go@v7 with: - go-version: "1.26.6" + go-version: "1.27.1" - uses: goreleaser/goreleaser-action@v7 with: version: "~> v2" diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index b6e3295..53109b8 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -23,8 +23,8 @@ jobs: - uses: actions/checkout@v7 - uses: actions/setup-go@v7 with: - go-version: "1.26.6" - - run: go install golang.org/x/vuln/cmd/govulncheck@latest + go-version: "1.27.1" + - run: go install golang.org/x/vuln/cmd/govulncheck@v1.7.0 - run: govulncheck ./... sast: @@ -34,8 +34,8 @@ jobs: - uses: actions/checkout@v7 - uses: actions/setup-go@v7 with: - go-version: "1.26.6" - - run: go install github.com/securego/gosec/v2/cmd/gosec@latest + go-version: "1.27.1" + - run: go install github.com/securego/gosec/v2/cmd/gosec@v2.29.0 - run: gosec ./... secrets: diff --git a/.goreleaser.yml b/.goreleaser.yml index 499eb0f..d577308 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -21,11 +21,11 @@ builds: goarch: arm64 archives: - - format: tar.gz + - formats: [tar.gz] name_template: "connect-cli_{{ .Version }}_{{ .Os }}_{{ .Arch }}" format_overrides: - goos: windows - format: zip + formats: [zip] checksum: name_template: "checksums.txt" @@ -45,7 +45,7 @@ brews: token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" name: connect-cli homepage: "https://github.com/redpine-ai/connect-cli" - description: "CLI for the Connect platform — MCP client for AI agents" + description: "Redpine CLI — search Redpine Connect and call MCP tools from the terminal" license: "Apache-2.0" install: | bin.install "redpine" diff --git a/AGENTS.md b/AGENTS.md index 5af7f9c..c3dfdef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,24 +1,53 @@ # connect-cli -`redpine` — MCP client CLI for [Redpine Connect](https://app.redpine.ai). Search documents, list -collections, and call upstream MCP tools from the terminal or from an agent. Built for agents -first: human-readable output in a TTY, JSON envelope (`{"status": "ok", "data": ...}` / -`{"status": "error", "error": {...}}`) when piped or with `--json`. +`redpine` — command-line client for [Redpine Connect](https://docs.redpine.ai). Search +licensed collections, preview and unlock results, list collections, check balance, and call +upstream MCP tools from a terminal or from an agent. Agents first: JSON envelope +(`{"status": "ok", "data": ...}` / `{"status": "error", "error": {...}}`) whenever stdout is +not a terminal or `--json` is passed; human-readable in a TTY or with `--pretty`. ## What it does -- `redpine auth login` / `redpine auth set-key` — OAuth browser flow or API key auth - (`internal/config`, `internal/command/auth`) -- `redpine search` — full-text/semantic search against the public Connect API, with a - filter DSL: repeatable `--filter key=value` (comma-separated = any-of, `!=` to exclude, - `>=`/`<=` for ranges) or `--filter-json` for full OR/nesting (`internal/command/search`) -- `redpine collections list` — list searchable collections -- `redpine tools list` / `redpine tools call` — discover and invoke upstream MCP tools, - designed for pipe-chaining between agent tool calls (`internal/mcp`) -- `redpine whoami`, `redpine update` — session/version introspection +- `redpine auth login` / `auth set-key` / `auth logout` / `auth status` — OAuth browser flow + or API key auth (`internal/config`, `internal/command/auth`). `auth status` is `whoami`. +- `redpine search ` — MCP `search` tool, billed per result. Filter DSL: + repeatable `--filter key=value` (comma-separated = any-of, `!=` to exclude, `>=`/`<=` for + ranges) or `--filter-json` for full OR/nesting (`internal/command/search`). +- `redpine preview ` — MCP `preview` tool wrapping the same search + arguments (`search.BuildSearchArgs`): free teasers plus the price, returns a `queryId`. +- `redpine confirm [resultId...]` (alias `unlock`) — MCP `confirm` tool. This is + the call that bills. +- `redpine collections` (also `collections list`) — MCP `list_collections`. +- `redpine balance` — MCP `get_balance`, free. +- `redpine tools list` / `tools info` / `tools call` — MCP `find-tools`, `inspect-tool`, and + a direct `tools/call`; built for pipe-chaining between agent tool calls (`internal/mcp`, + `internal/command/tools`). +- `redpine whoami` — token source and type; labels `sk_test_` keys as sandbox. +- `redpine docs [topic]` — opens `docs.redpine.ai/docs/`; short names such as `auth`, + `sdk`, `preview` are aliased (`internal/command/docs`). +- `redpine update` — self-update from GitHub releases, verified against `checksums.txt`. + A newer release is a one-line stderr notice on other commands, never a block. Commands live under `internal/command/`; the MCP transport/client lives in -`internal/mcp`; auth/session config in `internal/config`. +`internal/mcp`; auth/session config in `internal/config`; the factory that wires them in +`internal/factory`. + +## Wire facts an agent must not break + +- Everything is MCP streamable HTTP: `POST {server}/mcp`, `DELETE {server}/mcp`. No REST. + Protocol version `mcp.ProtocolVersion` (`2025-03-26`), sent in `initialize` and as the + `MCP-Protocol-Version` header. `Accept: application/json, text/event-stream`, + `User-Agent: redpine-cli/`, 120 s request timeout (`internal/mcp/transport.go`). +- Session id round-trips in `Mcp-Session-Id`, cached per server URL under `os.TempDir()`. +- Config resolution order, identical for the token and the server URL: flag, then + `REDPINE_API_KEY` / `REDPINE_BASE_URL`, then the legacy `CONNECT_API_KEY` / + `CONNECT_SERVER_URL`, then keyring / config file. The SDKs use the same names and order; + keep them in step (`internal/config/config.go`, `auth.go`). +- `clientInfo.name` in `initialize` is `connect-cli`. The server keys client detection off + it; do not rename without changing connect-api's `mcp/client_detection.py`. +- `find-tools --format json` output has a balance banner appended after the JSON; the client + decodes with `json.NewDecoder(...).Decode`, which tolerates trailing text. Do not switch to + `json.Unmarshal`. ## Install @@ -26,33 +55,31 @@ Commands live under `internal/command/`; the MCP transport/client lives in brew install redpine-ai/tap/connect-cli ``` -Formula lives in `redpine-ai/homebrew-tap`, published automatically by GoReleaser on -every tagged release (see `.goreleaser.yml`). +Formula lives in `redpine-ai/homebrew-tap`, published automatically by GoReleaser on every +tagged release (see `.goreleaser.yml`; note GoReleaser v2 spells the archive key `formats:`). ## Releases Push a `v*` tag → `.github/workflows/release.yml` runs GoReleaser → builds -linux/darwin/windows (amd64/arm64, no windows/arm64) → GitHub release + Homebrew tap update. -Never hand-edit the tap repo. +linux/darwin/windows (amd64/arm64, no windows/arm64) → GitHub release with `checksums.txt` ++ Homebrew tap update. Never hand-edit the tap repo. `redpine update` refuses a release +that has no `checksums.txt`. ## CI -`.github/workflows/ci.yml` runs `test`/`lint`/`build` jobs. `test` and `build` run on a -2-OS matrix (`ubuntu-latest`, `macos-latest`) against Go `1.26`; `lint` (staticcheck) runs -ubuntu-only. `.github/workflows/security.yml` runs govulncheck, gosec, and gitleaks on every -PR, on push to `main`, and weekly. +`.github/workflows/ci.yml` runs `test`/`lint`/`build`. `test` and `build` run on a 2-OS +matrix (`ubuntu-latest`, `macos-latest`) against Go `1.27.1` (also the `go` directive in +`go.mod`); `lint` (staticcheck, pinned) runs ubuntu-only. `.github/workflows/security.yml` +runs govulncheck, gosec (both pinned) and gitleaks on every PR, on push to `main`, and +weekly. Run `gofmt -l .` before pushing; CI does not, reviewers will. See `CONTRIBUTING.md` for build/test/lint commands and the PR checklist. -## Status (2026-08-18) - -- Module: `github.com/redpine-ai/connect-cli`, binary name `redpine` — confirmed `go.mod:1-3`, - `.goreleaser.yml` (`binary: redpine`). -- Go toolchain pinned at `go 1.26.6` in `go.mod` (bumped from `1.26.4` this session to clear a - govulncheck failure — GO-2026-6218/6090/6089/5972/5026, all fixed in `go1.26.6`). -- `--filter`/`--filter-json` on `redpine search` shipped recently (commit `7444758`, - "feat(search): add --filter and --filter-json to the search command"). -- No `CONTRIBUTING.md` existed before RED-1336 (this session added it alongside this file). -- License file (`LICENSE`) is Apache 2.0, but `.goreleaser.yml`'s Homebrew formula declares - `license: "MIT"` — pre-existing mismatch, not touched by this session; flag before relying - on either for compliance purposes. +## Known gaps + +- `search` and `preview` take one collection. The MCP `search` tool has no `collections` + (multi-collection) field yet, so the CLI cannot offer it until the server does. +- `include_figures` is not exposed: the CLI renders only `text` content blocks, so image + blocks would be silently dropped. +- Assisted search and the quota endpoint are REST-only on the server; the CLI has no MCP + route to them. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 09ed1db..3893f2f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ ## Prerequisites -- Go 1.26.x (see the `go` directive in `go.mod` for the minimum patch version) +- Go 1.27.x (see the `go` directive in `go.mod` for the minimum patch version) - `goreleaser` (only needed if you want to test a release build locally; CI handles real releases) ## Build @@ -26,7 +26,7 @@ go test ./... -race go vet ./... ``` -CI (`ci.yml`) runs these on a 2-OS matrix (`ubuntu-latest`, `macos-latest`) against Go `1.26`. +CI (`ci.yml`) runs these on a 2-OS matrix (`ubuntu-latest`, `macos-latest`) against Go `1.27`. ## Lint diff --git a/README.md b/README.md index 8f22446..fbccbce 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Redpine CLI -MCP client for [Redpine Connect](https://app.redpine.ai). Search documents, list collections, and call upstream MCP tools from the terminal. +`redpine` is the command-line client for [Redpine Connect](https://docs.redpine.ai): search licensed research collections, preview results before paying for them, and call upstream MCP tools from a terminal or an agent. -Built for agents — JSON output by default when piped, human-readable in the terminal. +Built for agents: JSON on a pipe, human-readable in a terminal, one envelope and one set of exit codes everywhere. ## Install @@ -10,16 +10,30 @@ Built for agents — JSON output by default when piped, human-readable in the te brew install redpine-ai/tap/connect-cli ``` -## Usage +Binaries for Linux, macOS and Windows are on the [releases page](https://github.com/redpine-ai/connect-cli/releases). `redpine update` upgrades a release-page install in place and verifies the download against the release checksums. + +## Authenticate + +```bash +redpine auth login # browser-based OAuth +redpine auth set-key sk_live_... # or store an API key +export REDPINE_API_KEY=sk_test_... # or use the environment (sandbox keys work too) +redpine whoami +``` + +A sandbox key (`sk_test_`) returns synthetic fixtures at no cost; `whoami` says so. + +## Search ```bash -# authenticate -redpine auth login # browser-based OAuth -redpine auth set-key sk_live_... # or use an API key +# search a collection (billed per result) +redpine search corpus "how does authentication work" +redpine search corpus "rate limiting" --limit 5 -# search -redpine search "how does authentication work" -redpine search "rate limiting" --collection api-docs --limit 5 +# preview for free, then pay only for what you want +redpine preview corpus "crispr delivery" +redpine confirm qry_a1b2c3d4e5f6 # unlock every previewed result +redpine confirm qry_a1b2c3d4e5f6 abc123 def456 # unlock only these # filter — repeatable; key=value, key!=value to exclude, key>=N for ranges redpine search corpus "crispr" --filter issn=1664-302X @@ -32,49 +46,65 @@ redpine search corpus "crispr" --filter 'journal_metric.2yr_mean_citedness>=5' redpine search corpus "crispr" \ --filter-json '{"or":[{"field":"issn","eq":"1664-302X"},{"field":"issn","eq":"1932-6203"}]}' -# collections -redpine collections list +# what can I search, and what do I have left? +redpine collections +redpine balance +``` + +`preview` takes the same `--limit`, `--filter` and `--filter-json` as `search`. Filters are documented at [docs.redpine.ai/docs/filtering](https://docs.redpine.ai/docs/filtering). -# upstream MCP tools +## MCP tools + +```bash redpine tools list +redpine tools info analytics--run_query redpine tools call analytics--run_query query="SELECT * FROM events" limit=10 # pass JSON input (useful for agents) echo '{"query": "test"}' | redpine tools call analytics--run_query redpine tools call analytics--run_query --input '{"query": "test"}' + +# pipe chaining auto-wires ids between calls +redpine tools call media--create_workspace --input '{"filter":{"query":"X"}}' \ + | redpine tools call media--daily_briefing ``` ## Output -Terminal (TTY) gets human-readable output. Pipes and scripts get JSON. +A terminal gets human-readable output. A pipe or a script gets the JSON envelope. ```bash -# automatic — JSON when piped -redpine search "query" | jq '.data' - -# force JSON in terminal -redpine search "query" --json - -# force human-readable in pipes -redpine search "query" --pretty +redpine search corpus "query" | jq '.data' # JSON, automatically +redpine search corpus "query" --json # force JSON in a terminal +redpine collections --pretty | less # force human-readable in a pipe ``` -JSON responses follow a consistent envelope: - ```json {"status": "ok", "data": { ... }} -{"status": "error", "error": {"code": "...", "message": "...", "suggestions": [...]}} +{"status": "error", "error": {"code": "...", "message": "...", "hint": "...", "suggestions": [...]}} ``` Exit codes: `0` success, `1` error, `2` auth, `3` bad input, `4` server error. +## Other commands + +```bash +redpine docs # open docs.redpine.ai +redpine docs filtering # open a page; short names like auth, sdk, preview work too +redpine completion zsh # shell completion (Homebrew installs it for you) +redpine update # upgrade a release-page install; Homebrew users: brew upgrade connect-cli +``` + ## Environment variables | Variable | Description | |----------|-------------| -| `CONNECT_API_KEY` | API key (skips `redpine auth login`) | -| `CONNECT_SERVER_URL` | Server URL override | -| `NO_COLOR` | Disable colored output | +| `REDPINE_API_KEY` | API key; skips `redpine auth login`. `CONNECT_API_KEY` is still read as a fallback | +| `REDPINE_BASE_URL` | Server URL override. `CONNECT_SERVER_URL` is still read as a fallback | +| `NO_COLOR` | Disable coloured output | +| `CLICOLOR_FORCE` | Force coloured output | + +The same two names configure the [SDKs](https://docs.redpine.ai/docs/sdks), so one shell setup serves both. ## License diff --git a/cmd/root.go b/cmd/root.go index 3cd6e99..f63aa69 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -7,8 +7,11 @@ import ( "strings" "github.com/redpine-ai/connect-cli/internal/command/auth" + "github.com/redpine-ai/connect-cli/internal/command/balance" "github.com/redpine-ai/connect-cli/internal/command/collections" + "github.com/redpine-ai/connect-cli/internal/command/confirm" "github.com/redpine-ai/connect-cli/internal/command/docs" + "github.com/redpine-ai/connect-cli/internal/command/preview" "github.com/redpine-ai/connect-cli/internal/command/search" "github.com/redpine-ai/connect-cli/internal/command/tools" updatecmd "github.com/redpine-ai/connect-cli/internal/command/update" @@ -24,16 +27,22 @@ import ( var ( flagAPIKey string flagServer string - flagJSON string + flagJSON bool flagPretty bool - flagEnv string + flagEnv string ) func NewRootCmd() *cobra.Command { root := &cobra.Command{ Use: "redpine", - Short: "Redpine CLI — MCP client for the Connect platform", - Long: `Redpine CLI — search documents, list collections, and call upstream MCP tools. + Short: "Redpine CLI — search Redpine Connect and call MCP tools from the terminal", + Long: `Redpine CLI — search documents, preview and unlock results, list collections, +and call upstream MCP tools. Built for agents: JSON when piped, readable in a terminal. + +Search: + redpine search "query" Search and pay per result + redpine preview "query" Free teasers plus the price to unlock + redpine confirm [resultId...] Pay for and receive previewed results Agent quick start: 1. redpine tools list --json Get all tools with full input schemas @@ -44,11 +53,11 @@ Pipe chaining (auto-wires IDs between commands): redpine tools call media--create_workspace --input '{"filter":{"query":"X"}}' \ | redpine tools call media--daily_briefing -Use --json on any command for structured JSON output.`, - SilenceUsage: true, - SilenceErrors: true, +Output is JSON when stdout is not a terminal; --json and --pretty force either.`, + SilenceUsage: true, + SilenceErrors: true, SuggestionsMinimumDistance: 2, - Version: version.Full(), + Version: version.Full(), RunE: func(cmd *cobra.Command, args []string) error { // If --env was passed standalone, PersistentPreRunE handles it and exits. // Otherwise show help. @@ -59,12 +68,10 @@ Use --json on any command for structured JSON output.`, root.SetVersionTemplate("{{.Version}}\n") pf := root.PersistentFlags() - pf.StringVar(&flagAPIKey, "api-key", "", "API key for authentication (env: CONNECT_API_KEY)") - pf.StringVar(&flagServer, "server", "", "Server URL (env: CONNECT_SERVER_URL)") - pf.StringVar(&flagJSON, "json", "", "Force JSON output; optionally select comma-separated fields") - pf.BoolVar(&flagPretty, "pretty", false, "Force human-readable output") - - root.PersistentFlags().Lookup("json").NoOptDefVal = "*" + pf.StringVar(&flagAPIKey, "api-key", "", "API key (env: REDPINE_API_KEY, or CONNECT_API_KEY)") + pf.StringVar(&flagServer, "server", "", "Server URL (env: REDPINE_BASE_URL, or CONNECT_SERVER_URL)") + pf.BoolVar(&flagJSON, "json", false, "Force the JSON envelope, even in a terminal") + pf.BoolVar(&flagPretty, "pretty", false, "Force human-readable output, even when piped") // Hidden env switch — not shown in help pf.StringVar(&flagEnv, "env", "", "") @@ -125,22 +132,23 @@ Use --json on any command for structured JSON output.`, return nil } + // A newer release is a notice on stderr, never a block: an outdated + // binary still works against the server, and blocking here broke + // scripts and offline commands like `auth set-key`. cacheDir := filepath.Join(config.ConfigDir(), "cache") - result := update.Check(cacheDir) - if result != nil && result.IsOutdated { - return &output.CLIError{ - Code: "update_required", - Message: result.FormatWarning(), - ExitCode: output.ExitError, - } + if result := update.Check(cacheDir); result != nil && result.IsOutdated { + fmt.Fprintln(os.Stderr, result.FormatWarning()) } return nil } root.AddCommand(auth.NewAuthCmd(f)) - root.AddCommand(collections.NewCollectionsCmd(f)) root.AddCommand(search.NewSearchCmd(f)) + root.AddCommand(preview.NewPreviewCmd(f)) + root.AddCommand(confirm.NewConfirmCmd(f)) + root.AddCommand(collections.NewCollectionsCmd(f)) + root.AddCommand(balance.NewBalanceCmd(f)) root.AddCommand(tools.NewToolsCmd(f)) root.AddCommand(docs.NewDocsCmd(f)) root.AddCommand(updatecmd.NewUpdateCmd(f)) @@ -149,9 +157,9 @@ Use --json on any command for structured JSON output.`, return root } -// shouldSkipUpdateCheck returns true for commands where the update gate -// should not block execution (update, help, completion subcommands, and -// Cobra's internal shell completion hooks like __complete). +// shouldSkipUpdateCheck returns true for commands where the update notice +// is noise (update, help, completion subcommands, and Cobra's internal +// shell completion hooks like __complete). func shouldSkipUpdateCheck(cmd *cobra.Command) bool { for c := cmd; c != nil; c = c.Parent() { name := c.Name() @@ -167,9 +175,8 @@ func Execute() { root := NewRootCmd() if err := root.Execute(); err != nil { ios := output.New() - jsonRequested := flagJSON != "" if cliErr, ok := err.(*output.CLIError); ok { - if jsonRequested { + if ios.OutputMode(flagJSON, flagPretty) == output.ModeJSON { _ = ios.WriteJSON(output.NewErrorEnvelope(cliErr)) } else { cliErr.WritePretty(ios.ErrOut) @@ -189,7 +196,7 @@ func Execute() { // findSuggestions extracts a misspelled command name from the error and // returns suggestion text using Cobra's built-in SuggestionsFor. func findSuggestions(root *cobra.Command, errMsg string) string { - // Cobra errors look like: `unknown command "logie" for "connect"` + // Cobra errors look like: `unknown command "logie" for "redpine"` // Extract the unknown command name const prefix = "unknown command \"" idx := strings.Index(errMsg, prefix) diff --git a/go.mod b/go.mod index cd3d5e2..867690f 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/redpine-ai/connect-cli -go 1.26.6 +go 1.27.1 require ( github.com/agnivade/levenshtein v1.2.1 @@ -12,18 +12,19 @@ require ( require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/clipperhouse/displaywidth v0.10.0 // indirect - github.com/clipperhouse/uax29/v2 v2.6.0 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/danieljoos/wincred v1.2.3 // indirect - github.com/fatih/color v1.18.0 // indirect + github.com/fatih/color v1.19.0 // indirect + github.com/goccy/go-json v0.10.6 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mattn/go-colorable v0.1.15 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/mattn/go-runewidth v0.0.29 // indirect github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect - github.com/olekukonko/errors v1.2.0 // indirect - github.com/olekukonko/ll v0.1.6 // indirect - github.com/spf13/pflag v1.0.9 // indirect + github.com/olekukonko/errors v1.3.0 // indirect + github.com/olekukonko/ll v0.1.8 // indirect + github.com/spf13/pflag v1.0.10 // indirect golang.org/x/sys v0.47.0 // indirect ) diff --git a/go.sum b/go.sum index 5f96952..4e87811 100644 --- a/go.sum +++ b/go.sum @@ -4,10 +4,10 @@ github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g= -github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs= -github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoKXuhpos= -github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= @@ -15,24 +15,26 @@ 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/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo= github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= -github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= -github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -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-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mattn/go-runewidth v0.0.29 h1:3oGF3R/S2N9DQ3ptftzVIvg2eicmojCzlwBEmqEPDfQ= +github.com/mattn/go-runewidth v0.0.29/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc= github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= -github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo= -github.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= -github.com/olekukonko/ll v0.1.6 h1:lGVTHO+Qc4Qm+fce/2h2m5y9LvqaW+DCN7xW9hsU3uA= -github.com/olekukonko/ll v0.1.6/go.mod h1:NVUmjBb/aCtUpjKk75BhWrOlARz3dqsM+OtszpY4o88= +github.com/olekukonko/errors v1.3.0 h1:teJvgLGUEqMzBUms+Dj3/3szNqCG/Jdw9iDbum8fR6U= +github.com/olekukonko/errors v1.3.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= +github.com/olekukonko/ll v0.1.8 h1:ysHCJRGHYKzmBSdz9w5AySztx7lG8SQY+naTGYUbsz8= +github.com/olekukonko/ll v0.1.8/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw= github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I= github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -40,8 +42,9 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= @@ -49,7 +52,6 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= diff --git a/internal/command/auth/login.go b/internal/command/auth/login.go index 25bb89c..ecc2624 100644 --- a/internal/command/auth/login.go +++ b/internal/command/auth/login.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" "html" + "io" "net" "net/http" "net/url" @@ -33,7 +34,7 @@ func NewLoginCmd(f *factory.Factory) *cobra.Command { return &output.CLIError{ Code: "non_interactive", Message: "Cannot use browser login in non-interactive mode", - Hint: "Set CONNECT_API_KEY or use 'redpine auth set-key'", + Hint: "Set REDPINE_API_KEY or use 'redpine auth set-key'", ExitCode: output.ExitAuth, } } @@ -45,7 +46,7 @@ func NewLoginCmd(f *factory.Factory) *cobra.Command { serverURL := f.ServerFlag if serverURL == "" { - serverURL = os.Getenv("CONNECT_SERVER_URL") + serverURL = config.ServerURLFromEnv() } if serverURL == "" { serverURL = cfg.ServerURLForEnv() @@ -217,12 +218,9 @@ func NewLoginCmd(f *factory.Factory) *cobra.Command { } fmt.Fprintln(ios.ErrOut, "Authentication successful!") - if f.JSONFlag != "" { - return ios.WriteJSON(output.NewSuccessEnvelope(map[string]string{ - "message": "Authenticated successfully", - })) - } - return nil + return ios.WriteResult(map[string]string{ + "message": "Authenticated successfully", + }, f.JSONFlag, f.PrettyFlag, func(io.Writer) {}) }, } } diff --git a/internal/command/auth/logout.go b/internal/command/auth/logout.go index 8dd6365..c5bc656 100644 --- a/internal/command/auth/logout.go +++ b/internal/command/auth/logout.go @@ -20,8 +20,14 @@ func NewLogoutCmd(f *factory.Factory) *cobra.Command { token, _ := f.Token(f.APIKeyFlag) if token != "" { client := f.MCPClient(token) + // Same resolution as the factory (flag, env, config), or the + // session-cache key hashes a different URL than the one the + // session was created for and the server session leaks. cfg, _ := f.Config() serverURL := f.ServerFlag + if serverURL == "" { + serverURL = config.ServerURLFromEnv() + } if serverURL == "" { serverURL = cfg.ServerURLForEnv() } diff --git a/internal/command/auth/status.go b/internal/command/auth/status.go index 9610af4..48e5f5b 100644 --- a/internal/command/auth/status.go +++ b/internal/command/auth/status.go @@ -1,73 +1,16 @@ package auth import ( - "fmt" - "io" - "strings" - - "github.com/redpine-ai/connect-cli/internal/config" + "github.com/redpine-ai/connect-cli/internal/command/whoami" "github.com/redpine-ai/connect-cli/internal/factory" - "github.com/redpine-ai/connect-cli/internal/output" "github.com/spf13/cobra" ) +// NewStatusCmd is `redpine whoami` under the auth namespace. func NewStatusCmd(f *factory.Factory) *cobra.Command { return &cobra.Command{ Use: "status", Short: "Show current authentication status", - RunE: func(cmd *cobra.Command, args []string) error { - token, source := f.Token(f.APIKeyFlag) - ios := f.IOStreams() - - if token == "" { - return &output.CLIError{ - Code: "not_authenticated", - Message: "Not authenticated", - Hint: "Run 'redpine auth login' or set CONNECT_API_KEY", - ExitCode: output.ExitAuth, - } - } - - var masked string - if len(token) < 12 { - masked = "***" - } else { - masked = token[:7] + "..." + token[len(token)-4:] - } - - tokenType := "OAuth" - if strings.HasPrefix(token, "sk_live_") || strings.HasPrefix(token, "sk_test_") { - tokenType = "API Key" - } - - hasRefresh := false - if tokenType == "OAuth" { - creds, err := config.LoadCredentialsFrom(config.ConfigDir()) - if err == nil && creds.RefreshToken != "" { - hasRefresh = true - } - } - - result := map[string]interface{}{ - "authenticated": true, - "source": source, - "type": tokenType, - "token": masked, - "refresh_available": hasRefresh, - } - - return ios.WriteResult(result, f.JSONFlag != "", f.PrettyFlag, func(w io.Writer) { - fmt.Fprintf(w, "Authenticated (%s)\n", tokenType) - fmt.Fprintf(w, " Token: %s\n", masked) - fmt.Fprintf(w, " Source: %s\n", source) - if tokenType == "OAuth" { - if hasRefresh { - fmt.Fprintf(w, " Refresh: available\n") - } else { - fmt.Fprintf(w, " Refresh: not available\n") - } - } - }) - }, + RunE: func(cmd *cobra.Command, args []string) error { return whoami.Run(f) }, } } diff --git a/internal/command/balance/balance.go b/internal/command/balance/balance.go new file mode 100644 index 0000000..6e72d9e --- /dev/null +++ b/internal/command/balance/balance.go @@ -0,0 +1,39 @@ +package balance + +import ( + "github.com/redpine-ai/connect-cli/internal/factory" + "github.com/redpine-ai/connect-cli/internal/mcp" + "github.com/redpine-ai/connect-cli/internal/output" + "github.com/spf13/cobra" +) + +// NewBalanceCmd calls the free `get_balance` tool: credit balance, trial +// status, and the dashboard and purchase URLs. +func NewBalanceCmd(f *factory.Factory) *cobra.Command { + return &cobra.Command{ + Use: "balance", + Short: "Show your credit balance and trial status (free)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + token, _ := f.Token(f.APIKeyFlag) + if token == "" { + return output.NotAuthenticated() + } + client, sc, err := f.MCPClientWithSession(token) + if err != nil { + return output.ServerError(err) + } + defer sc.Save(client.SessionID()) + + var result *mcp.ToolCallResult + if err := f.RunWithRefresh(client, sc, func(c *mcp.Client) error { + var callErr error + result, callErr = c.CallTool("get_balance", map[string]interface{}{}) + return callErr + }); err != nil { + return output.ServerError(err) + } + return f.IOStreams().WriteMCPResult(result, f.JSONFlag, f.PrettyFlag) + }, + } +} diff --git a/internal/command/collections/collections.go b/internal/command/collections/collections.go index 574933a..876905e 100644 --- a/internal/command/collections/collections.go +++ b/internal/command/collections/collections.go @@ -7,34 +7,43 @@ import ( "github.com/spf13/cobra" ) +// NewCollectionsCmd lists the collections the key can search. `collections` +// and `collections list` are the same command; the subcommand exists because +// the README and muscle memory both reach for it. func NewCollectionsCmd(f *factory.Factory) *cobra.Command { - return &cobra.Command{ + cmd := &cobra.Command{ Use: "collections", Short: "List available document collections", - RunE: func(cmd *cobra.Command, args []string) error { - token, _ := f.Token(f.APIKeyFlag) - if token == "" { - return &output.CLIError{ - Code: "not_authenticated", Message: "Not authenticated", - Hint: "Run 'redpine auth login' or set CONNECT_API_KEY", ExitCode: output.ExitAuth, - } - } - client, sc, err := f.MCPClientWithSession(token) - if err != nil { - return &output.CLIError{Code: "server_error", Message: err.Error(), ExitCode: output.ExitServer} - } - defer sc.Save(client.SessionID()) + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { return run(f) }, + } + cmd.AddCommand(&cobra.Command{ + Use: "list", + Short: "List available document collections", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { return run(f) }, + }) + return cmd +} + +func run(f *factory.Factory) error { + token, _ := f.Token(f.APIKeyFlag) + if token == "" { + return output.NotAuthenticated() + } + client, sc, err := f.MCPClientWithSession(token) + if err != nil { + return output.ServerError(err) + } + defer sc.Save(client.SessionID()) - var result *mcp.ToolCallResult - if err := f.RunWithRefresh(client, sc, func(c *mcp.Client) error { - var callErr error - result, callErr = c.CallTool("list_collections", map[string]interface{}{}) - return callErr - }); err != nil { - return &output.CLIError{Code: "server_error", Message: err.Error(), ExitCode: output.ExitServer} - } - ios := f.IOStreams() - return ios.WriteMCPResult(result, f.JSONFlag != "", f.PrettyFlag) - }, + var result *mcp.ToolCallResult + if err := f.RunWithRefresh(client, sc, func(c *mcp.Client) error { + var callErr error + result, callErr = c.CallTool("list_collections", map[string]interface{}{}) + return callErr + }); err != nil { + return output.ServerError(err) } + return f.IOStreams().WriteMCPResult(result, f.JSONFlag, f.PrettyFlag) } diff --git a/internal/command/confirm/confirm.go b/internal/command/confirm/confirm.go new file mode 100644 index 0000000..f2cd44a --- /dev/null +++ b/internal/command/confirm/confirm.go @@ -0,0 +1,54 @@ +package confirm + +import ( + "github.com/redpine-ai/connect-cli/internal/factory" + "github.com/redpine-ai/connect-cli/internal/mcp" + "github.com/redpine-ai/connect-cli/internal/output" + "github.com/spf13/cobra" +) + +// NewConfirmCmd unlocks the full results of an earlier `redpine preview` via +// the `confirm` tool. This is the call that bills. +func NewConfirmCmd(f *factory.Factory) *cobra.Command { + return &cobra.Command{ + Use: "confirm [resultId...]", + Aliases: []string{"unlock"}, + Short: "Pay for and receive the full results of a preview", + Long: `Unlocks results from a 'redpine preview'. With only a queryId, every +previewed result is unlocked; with result ids, only those. Re-sending an id +you already paid for costs nothing, so a failed confirm is safe to retry +with the same queryId. A preview stays unlockable for 7 days. + +This command charges your balance. Check the price on the preview first.`, + Example: ` redpine confirm qry_a1b2c3d4e5f6 + redpine confirm qry_a1b2c3d4e5f6 abc123 def456 + redpine unlock qry_a1b2c3d4e5f6 # same command, REST naming`, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + token, _ := f.Token(f.APIKeyFlag) + if token == "" { + return output.NotAuthenticated() + } + client, sc, err := f.MCPClientWithSession(token) + if err != nil { + return output.ServerError(err) + } + defer sc.Save(client.SessionID()) + + confirmArgs := map[string]interface{}{"queryId": args[0]} + if len(args) > 1 { + confirmArgs["result_ids"] = args[1:] + } + + var result *mcp.ToolCallResult + if err := f.RunWithRefresh(client, sc, func(c *mcp.Client) error { + var callErr error + result, callErr = c.CallTool("confirm", confirmArgs) + return callErr + }); err != nil { + return output.ServerError(err) + } + return f.IOStreams().WriteMCPResult(result, f.JSONFlag, f.PrettyFlag) + }, + } +} diff --git a/internal/command/docs/docs.go b/internal/command/docs/docs.go index 8d3a2cd..6e8b45a 100644 --- a/internal/command/docs/docs.go +++ b/internal/command/docs/docs.go @@ -2,33 +2,77 @@ package docs import ( "fmt" + "io" "os/exec" "runtime" + "strings" "github.com/redpine-ai/connect-cli/internal/factory" - "github.com/redpine-ai/connect-cli/internal/output" "github.com/spf13/cobra" ) +// DocsBaseURL is the public documentation site. +const DocsBaseURL = "https://docs.redpine.ai/docs" + +// topicAliases maps the short names this command has always accepted (and +// the slugs of the retired app.redpine.ai/docs site) onto current pages. +var topicAliases = map[string]string{ + "": "", + "overview": "", + "index": "", + "start": "quickstart", + "getting-started": "quickstart", + "auth": "authentication", + "keys": "authentication", + "api-keys": "authentication", + "limits": "rate-limits", + "preview": "preview-unlock", + "unlock": "preview-unlock", + "assisted": "assisted-search", + "endpoints": "search-endpoints", + "sdk": "sdks", + "cli": "mcp", +} + +// URLFor returns the documentation URL for a topic. Unknown topics are passed +// through as slugs so new pages need no CLI release. +func URLFor(topic string) string { + slug := strings.Trim(strings.ToLower(strings.TrimSpace(topic)), "/") + if alias, ok := topicAliases[slug]; ok { + slug = alias + } + if slug == "" { + return DocsBaseURL + } + return DocsBaseURL + "/" + slug +} + func NewDocsCmd(f *factory.Factory) *cobra.Command { return &cobra.Command{ Use: "docs [topic]", - Short: "Open Connect API documentation in browser", + Short: "Open the Redpine Connect documentation in your browser", + Example: ` redpine docs # docs.redpine.ai + redpine docs quickstart + redpine docs auth # alias for authentication + redpine docs filtering + redpine docs sdks`, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ios := f.IOStreams() - url := "https://app.redpine.ai/docs/overview" + topic := "" if len(args) > 0 { - url = "https://app.redpine.ai/docs/" + args[0] + topic = args[0] } + url := URLFor(topic) if ios.IsTTY() { fmt.Fprintf(ios.ErrOut, "Opening %s\n", url) openBrowser(url) } - return ios.WriteJSON(output.NewSuccessEnvelope(map[string]string{ - "url": url, - })) + return ios.WriteResult(map[string]string{"url": url}, f.JSONFlag, f.PrettyFlag, func(w io.Writer) { + fmt.Fprintln(w, url) + }) }, } } diff --git a/internal/command/docs/docs_test.go b/internal/command/docs/docs_test.go new file mode 100644 index 0000000..85cb9bd --- /dev/null +++ b/internal/command/docs/docs_test.go @@ -0,0 +1,23 @@ +package docs + +import "testing" + +func TestURLFor(t *testing.T) { + cases := map[string]string{ + "": DocsBaseURL, + "overview": DocsBaseURL, // retired app.redpine.ai slug + "quickstart": DocsBaseURL + "/quickstart", + "auth": DocsBaseURL + "/authentication", + "Authentication": DocsBaseURL + "/authentication", + "/filtering/": DocsBaseURL + "/filtering", + "preview": DocsBaseURL + "/preview-unlock", + "sdk": DocsBaseURL + "/sdks", + "some-new-page": DocsBaseURL + "/some-new-page", // unknown slugs pass through + "getting-started": DocsBaseURL + "/quickstart", + } + for topic, want := range cases { + if got := URLFor(topic); got != want { + t.Errorf("URLFor(%q) = %q, want %q", topic, got, want) + } + } +} diff --git a/internal/command/preview/preview.go b/internal/command/preview/preview.go new file mode 100644 index 0000000..1bc101b --- /dev/null +++ b/internal/command/preview/preview.go @@ -0,0 +1,72 @@ +package preview + +import ( + "strings" + + "github.com/redpine-ai/connect-cli/internal/command/search" + "github.com/redpine-ai/connect-cli/internal/factory" + "github.com/redpine-ai/connect-cli/internal/mcp" + "github.com/redpine-ai/connect-cli/internal/output" + "github.com/spf13/cobra" +) + +// NewPreviewCmd runs a search through the `preview` tool: teaser rows plus +// the cost to unlock them, free of charge. The returned queryId is what +// `redpine confirm` takes. +func NewPreviewCmd(f *factory.Factory) *cobra.Command { + var limit int + var filters []string + var filterJSON string + var tool string + + cmd := &cobra.Command{ + Use: "preview ", + Short: "Preview a search for free: teaser rows and the cost to unlock them", + Long: `Runs the same search as 'redpine search' but returns teasers and a price +instead of billing you. Nothing is charged and no quota slot is spent. + +The response carries a queryId, valid for 7 days. Pass it to +'redpine confirm' to pay for and receive the full results; pass result ids +to confirm to unlock only some of them.`, + Example: ` redpine preview corpus "crispr delivery" + redpine preview corpus "crispr delivery" --limit 5 --filter issn=1664-302X + redpine preview corpus "crispr delivery" --json | jq '.data'`, + Args: cobra.MinimumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + token, _ := f.Token(f.APIKeyFlag) + if token == "" { + return output.NotAuthenticated() + } + client, sc, err := f.MCPClientWithSession(token) + if err != nil { + return output.ServerError(err) + } + defer sc.Save(client.SessionID()) + + searchArgs, argErr := search.BuildSearchArgs(args[0], strings.Join(args[1:], " "), limit, filters, filterJSON) + if argErr != nil { + return argErr + } + previewArgs := map[string]interface{}{ + "tool_name": tool, + "arguments": searchArgs, + } + + var result *mcp.ToolCallResult + if err := f.RunWithRefresh(client, sc, func(c *mcp.Client) error { + var callErr error + result, callErr = c.CallTool("preview", previewArgs) + return callErr + }); err != nil { + return output.ServerError(err) + } + return f.IOStreams().WriteMCPResult(result, f.JSONFlag, f.PrettyFlag) + }, + } + + cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of results") + cmd.Flags().StringArrayVar(&filters, "filter", nil, "Metadata filter, repeatable; same syntax as 'redpine search --filter'") + cmd.Flags().StringVar(&filterJSON, "filter-json", "", "Raw filter object for OR / nested logic") + cmd.Flags().StringVar(&tool, "tool", "search", "Search tool to preview: 'search' or a 'search-' tool") + return cmd +} diff --git a/internal/command/search/search.go b/internal/command/search/search.go index 609f9d0..8a0bed0 100644 --- a/internal/command/search/search.go +++ b/internal/command/search/search.go @@ -43,7 +43,7 @@ func NewSearchCmd(f *factory.Factory) *cobra.Command { if token == "" { return &output.CLIError{ Code: "not_authenticated", Message: "Not authenticated", - Hint: "Run 'redpine auth login' or set CONNECT_API_KEY", ExitCode: output.ExitAuth, + Hint: "Run 'redpine auth login', or set REDPINE_API_KEY (CONNECT_API_KEY still works)", ExitCode: output.ExitAuth, } } client, sc, err := f.MCPClientWithSession(token) @@ -52,41 +52,9 @@ func NewSearchCmd(f *factory.Factory) *cobra.Command { } defer sc.Save(client.SessionID()) - collection := args[0] - query := strings.Join(args[1:], " ") - - searchArgs := map[string]interface{}{ - "collection": collection, - "query": query, - } - if limit > 0 { - searchArgs["limit"] = limit - } - - // --filter and --filter-json are mutually exclusive: the compact - // form builds a flat object and the JSON form may be a structured - // DSL node, and the two shapes cannot be merged coherently. - if len(filters) > 0 && strings.TrimSpace(filterJSON) != "" { - return &output.CLIError{ - Code: "invalid_input", Message: "Use either --filter or --filter-json, not both", - Hint: "--filter builds a flat filter; --filter-json takes the full DSL", ExitCode: output.ExitInput, - } - } - parsedFilters, err := ParseFilters(filters) - if err != nil { - return &output.CLIError{ - Code: "invalid_input", Message: err.Error(), ExitCode: output.ExitInput, - } - } - if parsedFilters == nil { - if parsedFilters, err = ParseFilterJSON(filterJSON); err != nil { - return &output.CLIError{ - Code: "invalid_input", Message: err.Error(), ExitCode: output.ExitInput, - } - } - } - if parsedFilters != nil { - searchArgs["filters"] = parsedFilters + searchArgs, argErr := BuildSearchArgs(args[0], strings.Join(args[1:], " "), limit, filters, filterJSON) + if argErr != nil { + return argErr } var result *mcp.ToolCallResult if err := f.RunWithRefresh(client, sc, func(c *mcp.Client) error { @@ -97,7 +65,7 @@ func NewSearchCmd(f *factory.Factory) *cobra.Command { return &output.CLIError{Code: "server_error", Message: err.Error(), ExitCode: output.ExitServer} } ios := f.IOStreams() - return ios.WriteMCPResult(result, f.JSONFlag != "", f.PrettyFlag) + return ios.WriteMCPResult(result, f.JSONFlag, f.PrettyFlag) }, ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if len(args) == 0 { @@ -113,42 +81,53 @@ func NewSearchCmd(f *factory.Factory) *cobra.Command { cmd.Flags().StringArrayVar(&filters, "filter", nil, "Metadata filter, repeatable. key=value (comma-separates into any-of), "+ "key!=value to exclude, or key>=N / key<=N for ranges. Indexed fields: "+ - "journal, publisher, keywords, publication_date, doi, issn, doc_id, "+ - "curated_sets, plus journal_metric.2yr_mean_citedness (range only)") + "doc_id, journal, publisher, keywords, publication_date, doi, issn, article_type, "+ + "section, isbn, open_access, license, chapter_number, chapter_title, chapter_authors, "+ + "plus journal_metric.2yr_mean_citedness / h_index / i10_index (range only). "+ + "Other fields work but are matched by scanning") cmd.Flags().StringVar(&filterJSON, "filter-json", "", "Raw filter object for OR / nested logic, e.g. "+ `'{"or":[{"field":"issn","eq":"1664-302X"}]}'`) return cmd } -func completeCollections(f *factory.Factory, prefix string) []string { - // Try to get collection names from tool cache (list_collections result) - // or from a dedicated collection cache - tc := f.ToolCache() - tools, err := tc.Load() - if err != nil { - // Try fetching live - token, _ := f.Token(f.APIKeyFlag) - if token == "" { - return nil - } - client, sc, clientErr := f.MCPClientWithSession(token) - if clientErr != nil { - return nil - } - defer sc.Save(client.SessionID()) +// BuildSearchArgs assembles the argument object for the MCP `search` tool from +// the CLI's flags. Shared with `preview`, which wraps the same arguments. +func BuildSearchArgs(collection, query string, limit int, filters []string, filterJSON string) (map[string]interface{}, *output.CLIError) { + searchArgs := map[string]interface{}{ + "collection": collection, + "query": query, + } + if limit > 0 { + searchArgs["limit"] = limit + } - // Fetch collections - result, callErr := client.CallTool("list_collections", map[string]interface{}{}) - if callErr != nil { - return nil + // --filter and --filter-json are mutually exclusive: the compact form + // builds a flat object and the JSON form may be a structured DSL node, + // and the two shapes cannot be merged coherently. + if len(filters) > 0 && strings.TrimSpace(filterJSON) != "" { + return nil, &output.CLIError{ + Code: "invalid_input", Message: "Use either --filter or --filter-json, not both", + Hint: "--filter builds a flat filter; --filter-json takes the full DSL", ExitCode: output.ExitInput, } - return extractCollectionNames(result, prefix) } + parsed, err := ParseFilters(filters) + if err != nil { + return nil, &output.CLIError{Code: "invalid_input", Message: err.Error(), ExitCode: output.ExitInput} + } + if parsed == nil { + if parsed, err = ParseFilterJSON(filterJSON); err != nil { + return nil, &output.CLIError{Code: "invalid_input", Message: err.Error(), ExitCode: output.ExitInput} + } + } + if parsed != nil { + searchArgs["filters"] = parsed + } + return searchArgs, nil +} - // If we have cached tools, try calling list_collections live for names - // For now, check if there's a collections cache file - _ = tools +// completeCollections fetches collection names live for shell completion. +func completeCollections(f *factory.Factory, prefix string) []string { token, _ := f.Token(f.APIKeyFlag) if token == "" { return nil diff --git a/internal/command/tools/call.go b/internal/command/tools/call.go index 3f434a3..90cf75d 100644 --- a/internal/command/tools/call.go +++ b/internal/command/tools/call.go @@ -41,7 +41,7 @@ func NewCallCmd(f *factory.Factory) *cobra.Command { if token == "" { return &output.CLIError{ Code: "not_authenticated", Message: "Not authenticated", - Hint: "Run 'redpine auth login' or set CONNECT_API_KEY", ExitCode: output.ExitAuth, + Hint: "Run 'redpine auth login', or set REDPINE_API_KEY (CONNECT_API_KEY still works)", ExitCode: output.ExitAuth, } } @@ -99,7 +99,7 @@ func NewCallCmd(f *factory.Factory) *cobra.Command { }, } ios := f.IOStreams() - return ios.WriteResult(payload, f.JSONFlag != "", f.PrettyFlag, func(w io.Writer) { + return ios.WriteResult(payload, f.JSONFlag, f.PrettyFlag, func(w io.Writer) { fmt.Fprintf(w, "Method: tools/call\n") fmt.Fprintf(w, "Tool: %s\n", toolName) fmt.Fprintf(w, "Arguments:\n") @@ -151,7 +151,7 @@ func NewCallCmd(f *factory.Factory) *cobra.Command { } ios := f.IOStreams() - return ios.WriteMCPResult(result, f.JSONFlag != "", f.PrettyFlag) + return ios.WriteMCPResult(result, f.JSONFlag, f.PrettyFlag) }, } diff --git a/internal/command/tools/info.go b/internal/command/tools/info.go index d635a3b..39930ba 100644 --- a/internal/command/tools/info.go +++ b/internal/command/tools/info.go @@ -27,7 +27,7 @@ func NewInfoCmd(f *factory.Factory) *cobra.Command { if token == "" { return &output.CLIError{ Code: "not_authenticated", Message: "Not authenticated", - Hint: "Run 'redpine auth login' or set CONNECT_API_KEY", ExitCode: output.ExitAuth, + Hint: "Run 'redpine auth login', or set REDPINE_API_KEY (CONNECT_API_KEY still works)", ExitCode: output.ExitAuth, } } @@ -64,7 +64,7 @@ func NewInfoCmd(f *factory.Factory) *cobra.Command { } ios := f.IOStreams() - if ios.OutputMode(f.JSONFlag != "", f.PrettyFlag) == output.ModeJSON { + if ios.OutputMode(f.JSONFlag, f.PrettyFlag) == output.ModeJSON { schema := map[string]interface{}{ "name": tool.Name, "description": tool.Description, diff --git a/internal/command/tools/list.go b/internal/command/tools/list.go index b1031ca..784d5c8 100644 --- a/internal/command/tools/list.go +++ b/internal/command/tools/list.go @@ -29,7 +29,7 @@ func NewListCmd(f *factory.Factory) *cobra.Command { if token == "" { return &output.CLIError{ Code: "not_authenticated", Message: "Not authenticated", - Hint: "Run 'redpine auth login' or set CONNECT_API_KEY", ExitCode: output.ExitAuth, + Hint: "Run 'redpine auth login', or set REDPINE_API_KEY (CONNECT_API_KEY still works)", ExitCode: output.ExitAuth, } } client, sc, err := f.MCPClientWithSession(token) @@ -52,7 +52,7 @@ func NewListCmd(f *factory.Factory) *cobra.Command { _ = tc.Save(allTools) ios := f.IOStreams() - if ios.OutputMode(f.JSONFlag != "", f.PrettyFlag) == output.ModePretty { + if ios.OutputMode(f.JSONFlag, f.PrettyFlag) == output.ModePretty { if len(allTools) > 0 { headers := []string{"TOOL", "DESCRIPTION"} var rows [][]string diff --git a/internal/command/update/update.go b/internal/command/update/update.go index 7d4b01f..9ef52dc 100644 --- a/internal/command/update/update.go +++ b/internal/command/update/update.go @@ -5,6 +5,8 @@ import ( "archive/zip" "bytes" "compress/gzip" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "io" @@ -13,6 +15,7 @@ import ( "path/filepath" "runtime" "strings" + "time" "github.com/redpine-ai/connect-cli/internal/factory" "github.com/redpine-ai/connect-cli/internal/output" @@ -20,21 +23,32 @@ import ( "github.com/spf13/cobra" ) -const releasesURL = "https://api.github.com/repos/redpine-ai/connect-cli/releases/latest" +const ( + releasesURL = "https://api.github.com/repos/redpine-ai/connect-cli/releases/latest" + checksumsAsset = "checksums.txt" + apiTimeout = 15 * time.Second + downloadTimeout = 5 * time.Minute +) + +type githubAsset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` +} type githubRelease struct { - TagName string `json:"tag_name"` - Assets []struct { - Name string `json:"name"` - BrowserDownloadURL string `json:"browser_download_url"` - } `json:"assets"` + TagName string `json:"tag_name"` + Assets []githubAsset `json:"assets"` } func NewUpdateCmd(f *factory.Factory) *cobra.Command { var checkOnly bool cmd := &cobra.Command{ Use: "update", - Short: "Update Redpine CLI to the latest version", + Short: "Update the Redpine CLI to the latest release", + Long: `Downloads the latest GitHub release for this platform, verifies it against +the release's checksums.txt, and replaces the running binary in place. +Homebrew installs should use 'brew upgrade connect-cli' instead.`, + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { ios := f.IOStreams() release, err := getLatestRelease() @@ -57,99 +71,62 @@ func NewUpdateCmd(f *factory.Factory) *cobra.Command { if checkOnly { return &output.CLIError{ Code: "update_available", Message: fmt.Sprintf("Update available: v%s → v%s", current, latest), - Hint: "Run 'redpine update' to install", ExitCode: 1, + Hint: "Run 'redpine update' to install", ExitCode: output.ExitError, } } - // Find the right archive assetPrefix := fmt.Sprintf("connect-cli_%s_%s_%s", latest, runtime.GOOS, runtime.GOARCH) - var downloadURL, assetName string - for _, asset := range release.Assets { - if strings.HasPrefix(asset.Name, assetPrefix) { - downloadURL = asset.BrowserDownloadURL - assetName = asset.Name - break + var archive, sums *githubAsset + for i := range release.Assets { + a := &release.Assets[i] + switch { + case strings.HasPrefix(a.Name, assetPrefix) && archive == nil: + archive = a + case a.Name == checksumsAsset: + sums = a } } - if downloadURL == "" { + if archive == nil { return &output.CLIError{ Code: "no_binary", Message: fmt.Sprintf("No binary found for %s/%s", runtime.GOOS, runtime.GOARCH), ExitCode: output.ExitError, } } + if sums == nil { + return &output.CLIError{ + Code: "no_checksums", Message: "Release has no checksums.txt; refusing to install an unverified binary", + ExitCode: output.ExitError, + } + } - // Download fmt.Fprintf(ios.ErrOut, "Downloading v%s...\n", latest) - // #nosec G107 -- downloadURL is built from the trusted GitHub releases API response, not user input. - resp, err := http.Get(downloadURL) + archiveData, err := download(archive.BrowserDownloadURL) if err != nil { return &output.CLIError{Code: "download_error", Message: err.Error(), ExitCode: output.ExitServer} } - defer resp.Body.Close() - if resp.StatusCode != 200 { - return &output.CLIError{ - Code: "download_error", Message: fmt.Sprintf("Download failed (HTTP %d)", resp.StatusCode), - ExitCode: output.ExitServer, - } - } - - archiveData, err := io.ReadAll(resp.Body) + sumsData, err := download(sums.BrowserDownloadURL) if err != nil { return &output.CLIError{Code: "download_error", Message: err.Error(), ExitCode: output.ExitServer} } + if err := verifyChecksum(archive.Name, archiveData, sumsData); err != nil { + return &output.CLIError{Code: "checksum_mismatch", Message: err.Error(), ExitCode: output.ExitError} + } - // Extract binary from archive var binaryData []byte - if strings.HasSuffix(assetName, ".tar.gz") { + switch { + case strings.HasSuffix(archive.Name, ".tar.gz"): binaryData, err = extractFromTarGz(archiveData, "redpine") - } else if strings.HasSuffix(assetName, ".zip") { + case strings.HasSuffix(archive.Name, ".zip"): binaryData, err = extractFromZip(archiveData, "redpine.exe") - } else { - err = fmt.Errorf("unknown archive format: %s", assetName) + default: + err = fmt.Errorf("unknown archive format: %s", archive.Name) } if err != nil { return &output.CLIError{Code: "extract_error", Message: err.Error(), ExitCode: output.ExitError} } - // Replace current binary - execPath, err := os.Executable() - if err != nil { - return &output.CLIError{Code: "update_error", Message: fmt.Sprintf("Cannot find current binary: %s", err), ExitCode: output.ExitError} - } - execPath, err = filepath.EvalSymlinks(execPath) - if err != nil { - return &output.CLIError{Code: "update_error", Message: fmt.Sprintf("Cannot resolve binary path: %s", err), ExitCode: output.ExitError} - } - - // Atomic replace: write temp file next to binary, then rename - dir := filepath.Dir(execPath) - tmp, err := os.CreateTemp(dir, ".connect-update-*") - if err != nil { - return &output.CLIError{ - Code: "update_error", Message: fmt.Sprintf("Cannot write to %s (try with sudo?): %s", dir, err), - ExitCode: output.ExitError, - } - } - tmpName := tmp.Name() - - if _, err := tmp.Write(binaryData); err != nil { - _ = tmp.Close() - _ = os.Remove(tmpName) - return &output.CLIError{Code: "update_error", Message: err.Error(), ExitCode: output.ExitError} - } - if err := tmp.Chmod(0755); err != nil { - _ = tmp.Close() - _ = os.Remove(tmpName) - return &output.CLIError{Code: "update_error", Message: err.Error(), ExitCode: output.ExitError} - } - _ = tmp.Close() - - if err := os.Rename(tmpName, execPath); err != nil { - _ = os.Remove(tmpName) - return &output.CLIError{ - Code: "update_error", Message: fmt.Sprintf("Cannot replace binary (try with sudo?): %s", err), - ExitCode: output.ExitError, - } + if err := replaceExecutable(binaryData); err != nil { + return err } fmt.Fprintf(ios.ErrOut, "Updated: v%s → v%s\n", current, latest) @@ -158,17 +135,27 @@ func NewUpdateCmd(f *factory.Factory) *cobra.Command { })) }, } - cmd.Flags().BoolVar(&checkOnly, "check", false, "Only check if an update is available") + cmd.Flags().BoolVar(&checkOnly, "check", false, "Only check whether an update is available") return cmd } +func httpGet(url string, timeout time.Duration) (*http.Response, error) { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "redpine-cli/"+version.Version) + req.Header.Set("Accept", "application/vnd.github+json, application/octet-stream;q=0.9, */*;q=0.8") + return (&http.Client{Timeout: timeout}).Do(req) +} + func getLatestRelease() (*githubRelease, error) { - resp, err := http.Get(releasesURL) + resp, err := httpGet(releasesURL, apiTimeout) if err != nil { return nil, err } defer resp.Body.Close() - if resp.StatusCode != 200 { + if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("GitHub API returned %d", resp.StatusCode) } var release githubRelease @@ -178,6 +165,86 @@ func getLatestRelease() (*githubRelease, error) { return &release, nil } +// download fetches a release asset. The URL comes from the GitHub releases +// API response, never from user input. +func download(url string) ([]byte, error) { + resp, err := httpGet(url, downloadTimeout) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("download failed (HTTP %d)", resp.StatusCode) + } + return io.ReadAll(resp.Body) +} + +// verifyChecksum checks data against the ` ` line for name in +// a GoReleaser checksums.txt. +func verifyChecksum(name string, data, sums []byte) error { + want := "" + for _, line := range strings.Split(string(sums), "\n") { + fields := strings.Fields(line) + if len(fields) == 2 && fields[1] == name { + want = strings.ToLower(fields[0]) + break + } + } + if want == "" { + return fmt.Errorf("%s has no entry for %s", checksumsAsset, name) + } + sum := sha256.Sum256(data) + got := hex.EncodeToString(sum[:]) + if got != want { + return fmt.Errorf("checksum mismatch for %s: got %s, want %s", name, got, want) + } + return nil +} + +// replaceExecutable writes the new binary next to the running one and renames +// it into place atomically. +func replaceExecutable(binaryData []byte) error { + execPath, err := os.Executable() + if err != nil { + return &output.CLIError{Code: "update_error", Message: fmt.Sprintf("Cannot find current binary: %s", err), ExitCode: output.ExitError} + } + execPath, err = filepath.EvalSymlinks(execPath) + if err != nil { + return &output.CLIError{Code: "update_error", Message: fmt.Sprintf("Cannot resolve binary path: %s", err), ExitCode: output.ExitError} + } + + dir := filepath.Dir(execPath) + tmp, err := os.CreateTemp(dir, ".redpine-update-*") + if err != nil { + return &output.CLIError{ + Code: "update_error", Message: fmt.Sprintf("Cannot write to %s (try with sudo?): %s", dir, err), + ExitCode: output.ExitError, + } + } + tmpName := tmp.Name() + + if _, err := tmp.Write(binaryData); err != nil { + _ = tmp.Close() + _ = os.Remove(tmpName) + return &output.CLIError{Code: "update_error", Message: err.Error(), ExitCode: output.ExitError} + } + if err := tmp.Chmod(0755); err != nil { + _ = tmp.Close() + _ = os.Remove(tmpName) + return &output.CLIError{Code: "update_error", Message: err.Error(), ExitCode: output.ExitError} + } + _ = tmp.Close() + + if err := os.Rename(tmpName, execPath); err != nil { + _ = os.Remove(tmpName) + return &output.CLIError{ + Code: "update_error", Message: fmt.Sprintf("Cannot replace binary (try with sudo?): %s", err), + ExitCode: output.ExitError, + } + } + return nil +} + func extractFromTarGz(data []byte, binaryName string) ([]byte, error) { gz, err := gzip.NewReader(bytes.NewReader(data)) if err != nil { diff --git a/internal/command/update/update_test.go b/internal/command/update/update_test.go new file mode 100644 index 0000000..7307c3c --- /dev/null +++ b/internal/command/update/update_test.go @@ -0,0 +1,33 @@ +package update + +import ( + "crypto/sha256" + "encoding/hex" + "strings" + "testing" +) + +func TestVerifyChecksum(t *testing.T) { + data := []byte("release archive bytes") + sum := sha256.Sum256(data) + good := hex.EncodeToString(sum[:]) + sums := []byte(strings.Join([]string{ + "deadbeef connect-cli_0.1.0_linux_amd64.tar.gz", + good + " connect-cli_0.1.0_darwin_arm64.tar.gz", + "", + }, "\n")) + + if err := verifyChecksum("connect-cli_0.1.0_darwin_arm64.tar.gz", data, sums); err != nil { + t.Fatalf("matching checksum rejected: %v", err) + } + if err := verifyChecksum("connect-cli_0.1.0_linux_amd64.tar.gz", data, sums); err == nil { + t.Fatal("mismatching checksum accepted") + } + if err := verifyChecksum("connect-cli_0.1.0_windows_amd64.zip", data, sums); err == nil { + t.Fatal("asset missing from checksums.txt accepted") + } + upper := []byte(strings.ToUpper(good) + " x.tar.gz\n") + if err := verifyChecksum("x.tar.gz", data, upper); err != nil { + t.Fatalf("upper-case digest rejected: %v", err) + } +} diff --git a/internal/command/whoami/whoami.go b/internal/command/whoami/whoami.go index c49f98f..d2987a7 100644 --- a/internal/command/whoami/whoami.go +++ b/internal/command/whoami/whoami.go @@ -11,74 +11,89 @@ import ( "github.com/spf13/cobra" ) +// SandboxKeyPrefix marks a sandbox API key: same endpoints, canned results, +// no charge. Mirrors the server's SANDBOX_KEY_PREFIX. +const SandboxKeyPrefix = "sk_test_" + +// NewWhoamiCmd reports how the CLI is authenticated. `auth status` is the +// same command under another name. func NewWhoamiCmd(f *factory.Factory) *cobra.Command { return &cobra.Command{ Use: "whoami", Short: "Show who you're authenticated as", - RunE: func(cmd *cobra.Command, args []string) error { - token, source := f.Token(f.APIKeyFlag) - ios := f.IOStreams() + RunE: func(cmd *cobra.Command, args []string) error { return Run(f) }, + } +} - if token == "" { - return &output.CLIError{ - Code: "not_authenticated", - Message: "Not authenticated", - Hint: "Run 'redpine auth login' or set CONNECT_API_KEY", - ExitCode: output.ExitAuth, - } - } +// Run is the shared implementation behind `whoami` and `auth status`. +func Run(f *factory.Factory) error { + token, source := f.Token(f.APIKeyFlag) + ios := f.IOStreams() - var masked string - if len(token) < 12 { - masked = "***" - } else { - masked = token[:7] + "..." + token[len(token)-4:] - } + if token == "" { + return output.NotAuthenticated() + } - tokenType := "OAuth" - if strings.HasPrefix(token, "sk_live_") || strings.HasPrefix(token, "sk_test_") { - tokenType = "API Key" - } + var masked string + if len(token) < 12 { + masked = "***" + } else { + masked = token[:7] + "..." + token[len(token)-4:] + } - hasRefresh := false - if tokenType == "OAuth" { - creds, err := config.LoadCredentialsFrom(config.ConfigDir()) - if err == nil && creds.RefreshToken != "" { - hasRefresh = true - } - } + tokenType := "OAuth" + sandbox := false + switch { + case strings.HasPrefix(token, SandboxKeyPrefix): + tokenType = "API key (sandbox)" + sandbox = true + case strings.HasPrefix(token, "sk_live_"): + tokenType = "API key" + } - result := map[string]interface{}{ - "authenticated": true, - "source": source, - "type": tokenType, - "token": masked, - "refresh_available": hasRefresh, - } + hasRefresh := false + if tokenType == "OAuth" { + creds, err := config.LoadCredentialsFrom(config.ConfigDir()) + if err == nil && creds.RefreshToken != "" { + hasRefresh = true + } + } - // Show environment if not production - cfg, _ := f.Config() - env := cfg.Environment - if env == "" { - env = "production" - } - result["environment"] = env + cfg, _ := f.Config() + env := "" + if cfg != nil { + env = cfg.Environment + } + if env == "" { + env = "production" + } - return ios.WriteResult(result, f.JSONFlag != "", f.PrettyFlag, func(w io.Writer) { - fmt.Fprintf(w, "Authenticated (%s)\n", tokenType) - fmt.Fprintf(w, " Token: %s\n", masked) - fmt.Fprintf(w, " Source: %s\n", source) - if env != "production" { - fmt.Fprintf(w, " Env: %s\n", env) - } - if tokenType == "OAuth" { - if hasRefresh { - fmt.Fprintf(w, " Refresh: available\n") - } else { - fmt.Fprintf(w, " Refresh: not available\n") - } - } - }) - }, + result := map[string]interface{}{ + "authenticated": true, + "source": source, + "type": tokenType, + "sandbox": sandbox, + "token": masked, + "refresh_available": hasRefresh, + "environment": env, } + + return ios.WriteResult(result, f.JSONFlag, f.PrettyFlag, func(w io.Writer) { + fmt.Fprintf(w, "Authenticated (%s)\n", tokenType) + fmt.Fprintf(w, " Token: %s\n", masked) + fmt.Fprintf(w, " Source: %s\n", source) + if env != "production" { + fmt.Fprintf(w, " Env: %s\n", env) + } + if sandbox { + fmt.Fprintf(w, " Mode: sandbox — results are synthetic fixtures, nothing is charged\n") + } + if tokenType == "OAuth" { + if hasRefresh { + fmt.Fprintf(w, " Refresh: available\n") + } else { + fmt.Fprintf(w, " Refresh: not available\n") + } + } + }) } diff --git a/internal/config/auth.go b/internal/config/auth.go index 33292a3..dec4fcc 100644 --- a/internal/config/auth.go +++ b/internal/config/auth.go @@ -115,9 +115,12 @@ func resolveTokenFrom(flagValue string, kr Keyring, configDir string) (token, so if flagValue != "" { return flagValue, "flag" } - // 2. Env var - if env := os.Getenv("CONNECT_API_KEY"); env != "" { - return env, "env" + // 2. Env var — REDPINE_API_KEY first, CONNECT_API_KEY as the legacy + // fallback, same precedence as the SDKs. + for _, name := range []string{EnvAPIKey, EnvAPIKeyFallback} { + if env := os.Getenv(name); env != "" { + return env, "env" + } } // 3. Keyring if kr != nil { diff --git a/internal/config/auth_test.go b/internal/config/auth_test.go index 05f8ce1..e717bd7 100644 --- a/internal/config/auth_test.go +++ b/internal/config/auth_test.go @@ -1,9 +1,6 @@ package config -import ( - "os" - "testing" -) +import "testing" func TestResolveToken_FlagFirst(t *testing.T) { token, source := ResolveToken("sk_flag", nil) @@ -13,8 +10,8 @@ func TestResolveToken_FlagFirst(t *testing.T) { } func TestResolveToken_EnvSecond(t *testing.T) { - os.Setenv("CONNECT_API_KEY", "sk_env") - defer os.Unsetenv("CONNECT_API_KEY") + t.Setenv("REDPINE_API_KEY", "") + t.Setenv("CONNECT_API_KEY", "sk_env") token, source := ResolveToken("", nil) if token != "sk_env" || source != "env" { @@ -22,6 +19,34 @@ func TestResolveToken_EnvSecond(t *testing.T) { } } +// The SDKs read REDPINE_API_KEY first and fall back to CONNECT_API_KEY; the +// CLI must resolve identically or the two disagree on the same shell. +func TestResolveToken_RedpineEnvWinsOverLegacy(t *testing.T) { + t.Setenv("REDPINE_API_KEY", "sk_redpine") + t.Setenv("CONNECT_API_KEY", "sk_legacy") + + token, source := ResolveToken("", nil) + if token != "sk_redpine" || source != "env" { + t.Errorf("got token=%q source=%q, want REDPINE_API_KEY to win", token, source) + } +} + +func TestServerURLFromEnv(t *testing.T) { + t.Setenv("REDPINE_BASE_URL", "") + t.Setenv("CONNECT_SERVER_URL", "") + if got := ServerURLFromEnv(); got != "" { + t.Errorf("unset: got %q", got) + } + t.Setenv("CONNECT_SERVER_URL", "https://legacy.example") + if got := ServerURLFromEnv(); got != "https://legacy.example" { + t.Errorf("legacy only: got %q", got) + } + t.Setenv("REDPINE_BASE_URL", "https://new.example") + if got := ServerURLFromEnv(); got != "https://new.example" { + t.Errorf("both set: got %q, want REDPINE_BASE_URL to win", got) + } +} + func TestResolveToken_CredentialsFallback(t *testing.T) { dir := t.TempDir() creds := &Credentials{Token: "sk_file", Type: "api_key"} @@ -41,6 +66,6 @@ type mockKeyring struct { err error } -func (m *mockKeyring) Get() (string, error) { return m.token, m.err } +func (m *mockKeyring) Get() (string, error) { return m.token, m.err } func (m *mockKeyring) Set(token string) error { return m.err } func (m *mockKeyring) Delete() error { return m.err } diff --git a/internal/config/config.go b/internal/config/config.go index d22239d..c7d2a71 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,6 +9,13 @@ import ( const ( DefaultServerURL = "https://api.redpine.ai" configFileName = "config.json" + + // Environment variables. The REDPINE_* names match the SDKs; the + // CONNECT_* names are the original CLI names and stay as fallbacks. + EnvAPIKey = "REDPINE_API_KEY" + EnvAPIKeyFallback = "CONNECT_API_KEY" + EnvBaseURL = "REDPINE_BASE_URL" + EnvBaseURLFallback = "CONNECT_SERVER_URL" ) // Environment URLs — not exposed in help @@ -17,11 +24,20 @@ var EnvURLs = map[string]string{ "staging": "https://api-staging.redpine.ai", } +// ServerURLFromEnv returns the server URL override from the environment, or +// "" when neither REDPINE_BASE_URL nor CONNECT_SERVER_URL is set. +func ServerURLFromEnv() string { + for _, name := range []string{EnvBaseURL, EnvBaseURLFallback} { + if v := os.Getenv(name); v != "" { + return v + } + } + return "" +} + type Config struct { - ServerURL string `json:"server_url"` - DefaultCollection string `json:"default_collection,omitempty"` - Output string `json:"output,omitempty"` - Environment string `json:"environment,omitempty"` + ServerURL string `json:"server_url"` + Environment string `json:"environment,omitempty"` } // ServerURLForEnv returns the server URL for the current environment. diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 78bfc5c..8654f9a 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -20,9 +20,8 @@ func TestLoadConfig_Default(t *testing.T) { func TestSaveAndLoadConfig(t *testing.T) { dir := t.TempDir() cfg := &Config{ - ServerURL: "https://custom.example.com", - DefaultCollection: "my-docs", - Output: "json", + ServerURL: "https://custom.example.com", + Environment: "staging", } if err := cfg.SaveTo(dir); err != nil { t.Fatal(err) @@ -35,8 +34,8 @@ func TestSaveAndLoadConfig(t *testing.T) { if loaded.ServerURL != "https://custom.example.com" { t.Errorf("got %q", loaded.ServerURL) } - if loaded.DefaultCollection != "my-docs" { - t.Errorf("got %q", loaded.DefaultCollection) + if loaded.Environment != "staging" { + t.Errorf("got %q", loaded.Environment) } } diff --git a/internal/config/keyring.go b/internal/config/keyring.go index 5499999..d2e9a21 100644 --- a/internal/config/keyring.go +++ b/internal/config/keyring.go @@ -47,8 +47,8 @@ func (k *SystemKeyring) Delete() error { type noopKeyring struct{} func (k *noopKeyring) Get() (string, error) { return "", ErrKeyringUnavailable } -func (k *noopKeyring) Set(string) error { return ErrKeyringUnavailable } -func (k *noopKeyring) Delete() error { return ErrKeyringUnavailable } +func (k *noopKeyring) Set(string) error { return ErrKeyringUnavailable } +func (k *noopKeyring) Delete() error { return ErrKeyringUnavailable } func withTimeout(fn func() (string, error)) (string, error) { type result struct { diff --git a/internal/factory/factory.go b/internal/factory/factory.go index a97bf4f..3b19f9c 100644 --- a/internal/factory/factory.go +++ b/internal/factory/factory.go @@ -1,7 +1,6 @@ package factory import ( - "os" "path/filepath" "strings" @@ -13,9 +12,9 @@ import ( type Factory struct { IOStreams func() *output.IOStreams - Config func() (*config.Config, error) - Keyring func() config.Keyring - Token func(flagValue string) (token, source string) + Config func() (*config.Config, error) + Keyring func() config.Keyring + Token func(flagValue string) (token, source string) MCPClient func(token string) *mcp.Client ToolCache func() *cache.ToolCache @@ -23,7 +22,7 @@ type Factory struct { // Global flag values — set by root command APIKeyFlag string ServerFlag string - JSONFlag string + JSONFlag bool PrettyFlag bool } @@ -70,7 +69,7 @@ func New() *Factory { } serverURL := f.ServerFlag if serverURL == "" { - serverURL = os.Getenv("CONNECT_SERVER_URL") + serverURL = config.ServerURLFromEnv() } if serverURL == "" { serverURL = cfg.ServerURLForEnv() @@ -94,7 +93,7 @@ func (f *Factory) MCPClientWithSession(token string) (*mcp.Client, *mcp.SessionC } serverURL := f.ServerFlag if serverURL == "" { - serverURL = os.Getenv("CONNECT_SERVER_URL") + serverURL = config.ServerURLFromEnv() } if serverURL == "" { serverURL = cfg.ServerURLForEnv() @@ -181,7 +180,7 @@ func (f *Factory) RunWithRefresh( } serverURL := f.ServerFlag if serverURL == "" { - serverURL = os.Getenv("CONNECT_SERVER_URL") + serverURL = config.ServerURLFromEnv() } if serverURL == "" { serverURL = cfg.ServerURLForEnv() diff --git a/internal/fuzzy/match.go b/internal/fuzzy/match.go index fb1a719..802e919 100644 --- a/internal/fuzzy/match.go +++ b/internal/fuzzy/match.go @@ -32,7 +32,7 @@ func ClosestMatches(input string, candidates []string, limit int) []string { return []string{c} } d := levenshtein.ComputeDistance(input, c) - if d < maxDistance { + if d < maxDistance { // strictly less: three edits apart is not a suggestion results = append(results, scored{c, d}) } } diff --git a/internal/mcp/client.go b/internal/mcp/client.go index 16340dd..18c6ad2 100644 --- a/internal/mcp/client.go +++ b/internal/mcp/client.go @@ -14,8 +14,12 @@ type Tool struct { InputSchema json.RawMessage `json:"inputSchema"` } +// ToolCallResult is a tools/call result. IsError is the MCP tool-level +// failure flag: the JSON-RPC call succeeded but the tool reports an error in +// its content (expired queryId, insufficient balance, bad arguments). type ToolCallResult struct { Content []ContentBlock `json:"content"` + IsError bool `json:"isError,omitempty"` } type ContentBlock struct { @@ -23,6 +27,10 @@ type ContentBlock struct { Text string `json:"text,omitempty"` } +// ProtocolVersion is the MCP revision this client speaks. It is sent in the +// initialize handshake and as the MCP-Protocol-Version header on every call. +const ProtocolVersion = "2025-03-26" + type Client struct { transport *Transport idCounter int @@ -53,7 +61,7 @@ func (c *Client) Initialize() error { ID: c.nextID(), Method: "initialize", Params: map[string]interface{}{ - "protocolVersion": "2025-03-26", + "protocolVersion": ProtocolVersion, "clientInfo": map[string]string{ "name": "connect-cli", "version": version.Version, diff --git a/internal/mcp/transport.go b/internal/mcp/transport.go index eb5e70d..93b92f6 100644 --- a/internal/mcp/transport.go +++ b/internal/mcp/transport.go @@ -7,6 +7,9 @@ import ( "io" "net/http" "strings" + "time" + + "github.com/redpine-ai/connect-cli/internal/version" ) type RPCRequest struct { @@ -59,13 +62,17 @@ type Transport struct { client *http.Client } +// RequestTimeout bounds every MCP round-trip. Assisted-style tools can run +// for a while, so this is generous; without it a hung server hangs the CLI. +const RequestTimeout = 120 * time.Second + func NewTransport(url, token string) *Transport { url = strings.TrimSuffix(url, "/") url = strings.TrimSuffix(url, "/mcp") return &Transport{ url: url, token: token, - client: &http.Client{}, + client: &http.Client{Timeout: RequestTimeout}, } } @@ -180,7 +187,11 @@ func (t *Transport) doPost(body []byte) ([]byte, error) { func (t *Transport) setHeaders(req *http.Request) { req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") + // Streamable HTTP clients must accept both; the server answers with JSON + // today but a spec-compliant upgrade may 406 a JSON-only Accept. + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("MCP-Protocol-Version", ProtocolVersion) + req.Header.Set("User-Agent", "redpine-cli/"+version.Version) if t.token != "" { req.Header.Set("Authorization", "Bearer "+t.token) } diff --git a/internal/output/errors.go b/internal/output/errors.go index 486409a..01fc912 100644 --- a/internal/output/errors.go +++ b/internal/output/errors.go @@ -28,6 +28,34 @@ func (e *CLIError) Error() string { return e.Message } +// NotAuthenticated is the error every server-backed command returns when no +// token could be resolved. One definition so the hint names the right env var. +func NotAuthenticated() *CLIError { + return &CLIError{ + Code: "not_authenticated", + Message: "Not authenticated", + Hint: "Run 'redpine auth login', or set REDPINE_API_KEY (CONNECT_API_KEY still works)", + ExitCode: ExitAuth, + } +} + +// ServerError wraps a transport or tool failure. A rejected token is reported +// as an auth failure (exit 2) rather than a server error, so scripts that key +// off the exit code re-authenticate instead of retrying. +func ServerError(err error) *CLIError { + msg := err.Error() + lower := strings.ToLower(msg) + if strings.Contains(lower, "http 401") || strings.Contains(lower, "unauthorized") { + return &CLIError{ + Code: "auth_error", + Message: msg, + Hint: "Run 'redpine auth login' again, or set REDPINE_API_KEY to a valid key", + ExitCode: ExitAuth, + } + } + return &CLIError{Code: "server_error", Message: msg, ExitCode: ExitServer} +} + func (e *CLIError) WritePretty(w io.Writer) { fmt.Fprintf(w, "Error: %s\n", e.Message) if len(e.Suggestions) > 0 { diff --git a/internal/output/errors_helpers_test.go b/internal/output/errors_helpers_test.go new file mode 100644 index 0000000..ec26f22 --- /dev/null +++ b/internal/output/errors_helpers_test.go @@ -0,0 +1,24 @@ +package output + +import ( + "errors" + "testing" +) + +func TestServerError_MapsRejectedTokenToAuth(t *testing.T) { + e := ServerError(errors.New(`initialize failed: HTTP 401: {"error":{"code":"UNAUTHORIZED"}}`)) + if e.Code != "auth_error" || e.ExitCode != ExitAuth { + t.Fatalf("401 mapped to %s / exit %d, want auth_error / %d", e.Code, e.ExitCode, ExitAuth) + } + e = ServerError(errors.New("server error (HTTP 503)")) + if e.Code != "server_error" || e.ExitCode != ExitServer { + t.Fatalf("503 mapped to %s / exit %d, want server_error / %d", e.Code, e.ExitCode, ExitServer) + } +} + +func TestNotAuthenticated(t *testing.T) { + e := NotAuthenticated() + if e.ExitCode != ExitAuth || e.Code != "not_authenticated" { + t.Fatalf("got %s / %d", e.Code, e.ExitCode) + } +} diff --git a/internal/output/mcp_result_test.go b/internal/output/mcp_result_test.go new file mode 100644 index 0000000..b8b3c9a --- /dev/null +++ b/internal/output/mcp_result_test.go @@ -0,0 +1,61 @@ +package output + +import ( + "bytes" + "strings" + "testing" +) + +type fakeBlock struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` +} + +type fakeResult struct { + Content []fakeBlock `json:"content"` + IsError bool `json:"isError,omitempty"` +} + +func TestWriteMCPResult_ToolErrorBecomesCLIError(t *testing.T) { + out := &bytes.Buffer{} + ios := &IOStreams{Out: out, ErrOut: &bytes.Buffer{}} + res := fakeResult{Content: []fakeBlock{{Type: "text", Text: "Preview expired"}}, IsError: true} + + err := ios.WriteMCPResult(res, true, false) + cliErr, ok := err.(*CLIError) + if !ok { + t.Fatalf("want *CLIError, got %T (%v)", err, err) + } + if cliErr.Code != "tool_error" || cliErr.ExitCode != ExitError || !strings.Contains(cliErr.Message, "Preview expired") { + t.Fatalf("got %+v", cliErr) + } + if out.Len() != 0 { + t.Fatalf("nothing should be written on a tool error, got %q", out.String()) + } +} + +func TestWriteMCPResult_SuccessJSONWhenPiped(t *testing.T) { + out := &bytes.Buffer{} + ios := &IOStreams{Out: out, ErrOut: &bytes.Buffer{}, tty: false} + res := fakeResult{Content: []fakeBlock{{Type: "text", Text: "hello"}}} + + if err := ios.WriteMCPResult(res, false, false); err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(out.String(), `{"status":"ok"`) { + t.Fatalf("piped output should be the JSON envelope, got %q", out.String()) + } +} + +func TestWriteMCPResult_PrettyInTTY(t *testing.T) { + out := &bytes.Buffer{} + ios := &IOStreams{Out: out, ErrOut: &bytes.Buffer{}, tty: true} + res := fakeResult{Content: []fakeBlock{{Type: "text", Text: "hello **there**"}}} + + if err := ios.WriteMCPResult(res, false, false); err != nil { + t.Fatal(err) + } + if strings.TrimSpace(out.String()) != "hello there" { + t.Fatalf("got %q", out.String()) + } +} diff --git a/internal/output/output.go b/internal/output/output.go index f78d75b..9845b51 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -7,6 +7,7 @@ import ( "io" "os" "regexp" + "strings" "golang.org/x/term" ) @@ -61,13 +62,20 @@ func (ios *IOStreams) IsTTY() bool { return ios.tty } -// OutputMode resolves the effective output mode from flags. -// Pretty (human-readable) is the default. JSON only when --json is explicitly passed. +// OutputMode resolves the effective output mode: --json forces JSON, --pretty +// forces human-readable, otherwise a terminal gets pretty output and a pipe +// or script gets the JSON envelope. func (ios *IOStreams) OutputMode(jsonFlag, prettyFlag bool) OutputMode { - if jsonFlag { + switch { + case jsonFlag: + return ModeJSON + case prettyFlag: + return ModePretty + case ios.tty: + return ModePretty + default: return ModeJSON } - return ModePretty } // Envelope is the standard JSON response wrapper. @@ -106,6 +114,9 @@ func (ios *IOStreams) WriteResult(data interface{}, jsonFlag, prettyFlag bool, p // WriteMCPResult outputs an MCP tool call result. In pretty mode, extracts // text content blocks and prints them directly. In JSON mode, wraps in envelope. +// A result flagged isError by the tool (expired queryId, insufficient balance, +// bad arguments) becomes a CLI error, so it renders as the error envelope and +// exits non-zero instead of masquerading as success. func (ios *IOStreams) WriteMCPResult(result interface{}, jsonFlag, prettyFlag bool) error { type contentBlock struct { Type string `json:"type"` @@ -113,16 +124,33 @@ func (ios *IOStreams) WriteMCPResult(result interface{}, jsonFlag, prettyFlag bo } type toolResult struct { Content []contentBlock `json:"content"` + IsError bool `json:"isError"` } - if ios.OutputMode(jsonFlag, prettyFlag) == ModePretty { - // Marshal and re-parse to extract content blocks generically - data, err := json.Marshal(result) - if err != nil { - return ios.WriteJSON(NewSuccessEnvelope(result)) + // Marshal and re-parse to inspect the result generically + data, err := json.Marshal(result) + if err != nil { + return ios.WriteJSON(NewSuccessEnvelope(result)) + } + var tr toolResult + parsed := json.Unmarshal(data, &tr) == nil + + if parsed && tr.IsError { + var texts []string + for _, block := range tr.Content { + if block.Type == "text" && block.Text != "" { + texts = append(texts, block.Text) + } + } + msg := strings.TrimSpace(strings.Join(texts, "\n")) + if msg == "" { + msg = "tool returned an error" } - var tr toolResult - if err := json.Unmarshal(data, &tr); err == nil && len(tr.Content) > 0 { + return &CLIError{Code: "tool_error", Message: msg, ExitCode: ExitError} + } + + if ios.OutputMode(jsonFlag, prettyFlag) == ModePretty { + if parsed && len(tr.Content) > 0 { for _, block := range tr.Content { if block.Type == "text" && block.Text != "" { text := block.Text @@ -144,5 +172,3 @@ func (ios *IOStreams) WriteMCPResult(result interface{}, jsonFlag, prettyFlag bo } return ios.WriteJSON(NewSuccessEnvelope(result)) } - - diff --git a/internal/output/output_test.go b/internal/output/output_test.go index d21c10d..ef4582f 100644 --- a/internal/output/output_test.go +++ b/internal/output/output_test.go @@ -57,8 +57,8 @@ func TestOutputMode_TTY(t *testing.T) { func TestOutputMode_NonTTY(t *testing.T) { ios := &IOStreams{Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}, tty: false} - if ios.OutputMode(false, false) != ModePretty { - t.Error("non-TTY default should be pretty (human-readable is always default)") + if ios.OutputMode(false, false) != ModeJSON { + t.Error("non-TTY default should be JSON (pipes and scripts get the envelope)") } } diff --git a/internal/update/check.go b/internal/update/check.go index a177c11..b44edd4 100644 --- a/internal/update/check.go +++ b/internal/update/check.go @@ -81,12 +81,22 @@ func saveCachedVersion(cacheDir, ver string) { } func fetchLatestVersion() string { + req, err := http.NewRequest(http.MethodGet, releasesURL, nil) + if err != nil { + return "" + } + // GitHub rate-limits anonymous requests without a User-Agent hard. + req.Header.Set("User-Agent", "redpine-cli/"+version.Version) + req.Header.Set("Accept", "application/vnd.github+json") client := &http.Client{Timeout: 3 * time.Second} - resp, err := client.Get(releasesURL) - if err != nil || resp.StatusCode != 200 { + resp, err := client.Do(req) + if err != nil { return "" } defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "" + } var release struct { TagName string `json:"tag_name"` } @@ -124,7 +134,7 @@ func parseSemver(v string) [3]int { return result } -// FormatWarning returns a stderr message for outdated versions. +// FormatWarning returns the one-line stderr notice for outdated versions. func (r *CheckResult) FormatWarning() string { - return fmt.Sprintf("Update required: v%s → v%s. Run: redpine update", r.Current, r.Latest) + return fmt.Sprintf("A newer redpine is available: v%s → v%s. Run: redpine update", r.Current, r.Latest) } From 67a77d08db45943e635a36ae660eed77f58476bd Mon Sep 17 00:00:00 2001 From: Fahad Date: Fri, 4 Sep 2026 09:14:56 +0200 Subject: [PATCH 2/3] chore: mark env-var names and display labels as non-credentials for gosec Claude-Session: https://claude.ai/code/session_01BJch6rj7Xw6H3AhpcDGPrp --- internal/command/whoami/whoami.go | 4 ++-- internal/config/config.go | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/internal/command/whoami/whoami.go b/internal/command/whoami/whoami.go index d2987a7..14d5003 100644 --- a/internal/command/whoami/whoami.go +++ b/internal/command/whoami/whoami.go @@ -45,10 +45,10 @@ func Run(f *factory.Factory) error { sandbox := false switch { case strings.HasPrefix(token, SandboxKeyPrefix): - tokenType = "API key (sandbox)" + tokenType = "API key (sandbox)" // #nosec G101 -- a display label, not a credential sandbox = true case strings.HasPrefix(token, "sk_live_"): - tokenType = "API key" + tokenType = "API key" // #nosec G101 -- a display label, not a credential } hasRefresh := false diff --git a/internal/config/config.go b/internal/config/config.go index c7d2a71..22668c8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,8 +12,9 @@ const ( // Environment variables. The REDPINE_* names match the SDKs; the // CONNECT_* names are the original CLI names and stay as fallbacks. - EnvAPIKey = "REDPINE_API_KEY" - EnvAPIKeyFallback = "CONNECT_API_KEY" + // #nosec G101 -- these are variable NAMES, not credential values. + EnvAPIKey = "REDPINE_API_KEY" // #nosec G101 + EnvAPIKeyFallback = "CONNECT_API_KEY" // #nosec G101 EnvBaseURL = "REDPINE_BASE_URL" EnvBaseURLFallback = "CONNECT_SERVER_URL" ) From c693c2124f41f6814958aaf16e8159dece6d50c2 Mon Sep 17 00:00:00 2001 From: Fahad Date: Fri, 4 Sep 2026 09:15:19 +0200 Subject: [PATCH 3/3] style: gofmt after gosec annotations Claude-Session: https://claude.ai/code/session_01BJch6rj7Xw6H3AhpcDGPrp --- internal/config/config.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 22668c8..0c9fe1a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,8 +13,8 @@ const ( // Environment variables. The REDPINE_* names match the SDKs; the // CONNECT_* names are the original CLI names and stay as fallbacks. // #nosec G101 -- these are variable NAMES, not credential values. - EnvAPIKey = "REDPINE_API_KEY" // #nosec G101 - EnvAPIKeyFallback = "CONNECT_API_KEY" // #nosec G101 + EnvAPIKey = "REDPINE_API_KEY" // #nosec G101 + EnvAPIKeyFallback = "CONNECT_API_KEY" // #nosec G101 EnvBaseURL = "REDPINE_BASE_URL" EnvBaseURLFallback = "CONNECT_SERVER_URL" )