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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ spur feed download anonymous -o anonymous.json.gz # raw gzip to a file
spur feed download ipgeo -o ipgeo.mmdb # ipgeo defaults to MMDB
```

Useful flags: `--date YYYYMMDD` for a historical release, `--ipv6` and `--realtime` for a feed's variants, `--mmdb`/`--json` to pick the artifact, `--decompress` to expand the gzip when writing to a file. Download progress goes to stderr, so stdout stays a clean data channel.
Useful flags: `--date YYYYMMDD` for a historical release, `--ipv6` and `--realtime` for a feed's variants, `--format json|mmdb` to pick the artifact (`--json` and `--mmdb` remain compatibility aliases), `--decompress` to expand the gzip when writing to a file. Download progress goes to stderr, so stdout stays a clean data channel.

### `spur export` — filtered exports

Expand Down Expand Up @@ -200,7 +200,9 @@ Reports the build you're running — include it in bug reports.

## Output formats

Every read command picks its format by where output is going: **styled text at a terminal, newline-delimited JSON when piped or redirected**. `--format text|json|csv` always overrides the auto-choice.
Structured reports (`context`, `status`, `tag`, `feed list`, and `feed status`) pick their format by where output is going: **styled text at a terminal, newline-delimited JSON when piped or redirected**. `--format text|json|csv` always overrides the auto-choice. Exports use `--format csv|json|mmdb` (default: CSV). Feed downloads use `--format json|mmdb` (default: JSON, except MMDB for `ipgeo`); JSON downloads retain their gzip behavior when saved with `-o`. Across commands, `--output`/`-o` selects the destination file.

Context text reports follow the Spur app’s overview, Proxy & Anonymization Intelligence, Geo Intelligence, and Device Activity sections. All text reports use stacked label/value rows and matching section headings, with long values wrapping within 80 columns or the terminal width. Empty text and lists display `None`; numeric zero values remain `0`. The tunnel badge describes reported tunnel data, not an overall safety verdict.

```bash
spur context 1.2.3.4 # styled report
Expand Down
4 changes: 2 additions & 2 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ func Run(ctx context.Context) error {
if err != nil {
return err
}
// Diagnostics go to stderr so stdout stays a clean data channel (the
// context command prints JSON there).
// Diagnostics go to stderr so stdout contains only the requested command
// output, including machine-readable data when piped.
slog.SetDefault(newLogger(os.Stderr, parseLogLevel(cfg.LogLevel)))

return newRootCmd().ExecuteContext(ctx)
Expand Down
10 changes: 4 additions & 6 deletions internal/app/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,8 @@ import (
"github.com/spurintel/cli/internal/spur"
)

// streamExport carries the Token header, hits /v1/feeds/<slug> with the built
// query, and streams CSV rows through to the destination (AC: "spur export
// anonymous --output csv --limit 5 returns CSV rows").
// streamExport requests /v1/feeds/<slug> with the supplied query and copies CSV
// rows to the destination, as used by `spur export anonymous --format csv`.
func TestStreamExportWritesCSV(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -46,9 +45,8 @@ func TestStreamExportWritesCSV(t *testing.T) {
}
}

// The mmdb artifact streams through untouched to the destination (AC: "--output
// mmdb writes a valid MMDB file via -o" — the CLI streams the server's bytes
// faithfully; producing a valid MMDB is the server's job).
// With --format mmdb, the CLI copies the server's bytes unchanged. This checks
// binary preservation; validating the database itself is the server's job.
func TestStreamExportWritesMMDBBytes(t *testing.T) {
t.Parallel()

Expand Down
58 changes: 37 additions & 21 deletions internal/app/feed.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,22 +98,21 @@ func newFeedStatusCmd(of *outputFlags, cf *configFlags) *cobra.Command {
}
}

// newFeedDownloadCmd builds `spur feed download <type>`: stream a feed's gzip
// data file from feeds.spur.us. Without -o the body is decompressed to
// newline-delimited JSON on stdout (so it pipes straight into a JSON tool);
// with -o the raw gzip is written to the file (add --decompress to expand it).
// The download bypasses the renderer entirely — it is a byte stream, not a
// structured value — so --format and --no-color do not apply.
// newFeedDownloadCmd streams a JSON gzip or MMDB artifact from feeds.spur.us.
// JSON is decompressed on stdout by default; -o preserves the gzip unless
// --decompress is set. MMDB bytes are written unchanged. --format selects the
// artifact; --no-color affects only progress output.
func newFeedDownloadCmd(of *outputFlags, cf *configFlags) *cobra.Command {
var (
date string
ipv6 bool
realtime bool
asMMDB bool
asJSON bool
decompress bool
silent bool
verify bool
date string
ipv6 bool
realtime bool
artifactFormat string
asMMDB bool
asJSON bool
decompress bool
silent bool
verify bool
)
cmd := &cobra.Command{
Use: "download <type>",
Expand All @@ -123,7 +122,7 @@ func newFeedDownloadCmd(of *outputFlags, cf *configFlags) *cobra.Command {
"automatically.\n\n" +
"Feeds are published as newline-delimited JSON (gzip); some are also " +
"published as a MaxMind DB (.mmdb). The JSON gzip is the default, except " +
"for `ipgeo`, which defaults to MMDB. Use --mmdb or --json to choose " +
"for `ipgeo`, which defaults to MMDB. Use --format mmdb or --format json to choose " +
"explicitly; whether a feed offers MMDB is reported by `spur feed " +
"status`.\n\n" +
"For the JSON format the gzip body is decompressed to newline-delimited " +
Expand Down Expand Up @@ -156,7 +155,7 @@ func newFeedDownloadCmd(of *outputFlags, cf *configFlags) *cobra.Command {
if date != "" && strings.HasSuffix(slug, "/realtime") {
return fmt.Errorf("--date is not supported for the realtime feed; realtime history is addressed by minute, which this command does not expose")
}
format, err := resolveDownloadFormat(slug, asMMDB, asJSON)
format, err := resolveDownloadFormat(slug, artifactFormat, asMMDB, asJSON)
if err != nil {
return err
}
Expand Down Expand Up @@ -207,8 +206,9 @@ func newFeedDownloadCmd(of *outputFlags, cf *configFlags) *cobra.Command {
cmd.Flags().StringVar(&date, "date", "", "download a historical release for this date (YYYYMMDD) instead of the latest")
cmd.Flags().BoolVar(&ipv6, "ipv6", false, "select the feed's IPv6 variant (e.g. anonymous -> anonymous-ipv6)")
cmd.Flags().BoolVar(&realtime, "realtime", false, "select the feed's realtime variant (anonymous-residential only)")
cmd.Flags().BoolVar(&asMMDB, "mmdb", false, "download the MaxMind DB (.mmdb) artifact where offered (default for ipgeo)")
cmd.Flags().BoolVar(&asJSON, "json", false, "download the newline-JSON gzip artifact (overrides the ipgeo MMDB default)")
cmd.Flags().StringVar(&artifactFormat, "format", "", "artifact format: json or mmdb (default: json; ipgeo: mmdb)")
cmd.Flags().BoolVar(&asMMDB, "mmdb", false, "alias for --format mmdb")
cmd.Flags().BoolVar(&asJSON, "json", false, "alias for --format json")
cmd.Flags().BoolVar(&decompress, "decompress", false, "decompress the gzip when writing JSON to a file (-o); stdout JSON is always decompressed")
cmd.Flags().BoolVar(&silent, "silent", false, "suppress the download progress indicator")
cmd.Flags().BoolVar(&verify, "verify", false, "hash the downloaded bytes and verify them against the CDN-reported checksum (crc32c/md5); a mismatch fails the command but a partially-written -o file is left in place")
Expand All @@ -228,9 +228,25 @@ func showDownloadProgress(silent, dataToTerminal, stderrIsTerminal bool) bool {
// resolveDownloadFormat picks the artifact format from the explicit flags,
// falling back to a per-feed default: ipgeo is a geolocation database whose
// primary form is MMDB, so it defaults to MMDB; every other feed defaults to the
// newline-JSON gzip. --mmdb and --json are mutually exclusive. Whether the feed
// newline-JSON gzip. Conflicting format selections are rejected. Whether the feed
// actually offers the chosen format is validated later against its metadata.
func resolveDownloadFormat(slug string, mmdb, jsonFmt bool) (spur.FeedFormat, error) {
func resolveDownloadFormat(slug, format string, mmdb, jsonFmt bool) (spur.FeedFormat, error) {
switch format {
case "":
case "json":
if mmdb {
return 0, fmt.Errorf("--format json conflicts with --mmdb")
}
jsonFmt = true
case "mmdb":
if jsonFmt {
return 0, fmt.Errorf("--format mmdb conflicts with --json")
}
mmdb = true
default:
return 0, fmt.Errorf("invalid download format %q; valid formats: json|mmdb", format)
}

switch {
case mmdb && jsonFmt:
return 0, fmt.Errorf("--mmdb and --json are mutually exclusive")
Expand Down Expand Up @@ -304,7 +320,7 @@ func downloadFeed(
return err
}
if md.MMDB == nil {
return fmt.Errorf("the %q feed is not offered in MMDB format; download it as JSON instead (omit --mmdb)", feedType)
return fmt.Errorf("the %q feed is not offered in MMDB format; download it as JSON instead (use --format json)", feedType)
}
}

Expand Down
38 changes: 37 additions & 1 deletion internal/app/feed_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (
"hash/crc32"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"

Expand Down Expand Up @@ -370,12 +372,19 @@ func TestResolveDownloadFormat(t *testing.T) {

tests := []struct {
name string
format string
slug string
mmdb bool
asJSON bool
want spur.FeedFormat
wantErr bool
}{
{name: "format json overrides ipgeo", slug: "ipgeo", format: "json", want: spur.FeedJSONGzip},
{name: "format mmdb", slug: "anonymous", format: "mmdb", want: spur.FeedMMDB},
{name: "matching alias", slug: "anonymous", format: "json", asJSON: true, want: spur.FeedJSONGzip},
{name: "format conflicts mmdb", slug: "anonymous", format: "json", mmdb: true, wantErr: true},
{name: "format conflicts json", slug: "anonymous", format: "mmdb", asJSON: true, wantErr: true},
{name: "unsupported format", slug: "anonymous", format: "text", wantErr: true},
{name: "default json", slug: "anonymous", want: spur.FeedJSONGzip},
{name: "ipgeo defaults mmdb", slug: "ipgeo", want: spur.FeedMMDB},
{name: "force mmdb", slug: "anonymous", mmdb: true, want: spur.FeedMMDB},
Expand All @@ -387,7 +396,7 @@ func TestResolveDownloadFormat(t *testing.T) {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := resolveDownloadFormat(tc.slug, tc.mmdb, tc.asJSON)
got, err := resolveDownloadFormat(tc.slug, tc.format, tc.mmdb, tc.asJSON)
if tc.wantErr {
if err == nil {
t.Fatalf("resolveDownloadFormat(%q, %v, %v) = %v, want error", tc.slug, tc.mmdb, tc.asJSON, got)
Expand Down Expand Up @@ -649,3 +658,30 @@ func TestDownloadFeedNoChecksumWithoutVerifySucceeds(t *testing.T) {
t.Errorf("summary %q should report the size", summary.String())
}
}

func TestFeedDownloadFormatJSONOverridesIPGeoDefault(t *testing.T) {
const payload = "{\"ip\":\"192.0.2.1\"}\n"
compressed := gzipString(t, payload)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v2/ipgeo/latest.json.gz" {
t.Errorf("unexpected request: %s", r.URL.Path)
}
_, _ = w.Write(compressed)
}))
defer server.Close()
t.Setenv("SPUR_TOKEN", "test-token")
path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte("[endpoints]\nfeeds = \""+server.URL+"\"\n"), 0600); err != nil {
t.Fatal(err)
}
root := newRootCmd()
var out bytes.Buffer
root.SetOut(&out)
root.SetArgs([]string{"--config", path, "feed", "download", "ipgeo", "--format", "json", "--silent"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if out.String() != payload {
t.Fatalf("got %q, want %q", out.String(), payload)
}
}
7 changes: 3 additions & 4 deletions internal/app/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,8 @@ KEY SEMANTIC RULES (the tool will reject violations)

// newMCPCmd builds `spur mcp`: a stdio Model Context Protocol server that
// exposes the Spur Context API as MCP tools, resources, and prompts, all backed
// by the same shared client the rest of the CLI uses. MCP clients launch it via
// `claude mcp add spur -e SPUR_TOKEN=… -- spur mcp`, so the token is read from
// the environment.
// by the same shared client the rest of the CLI uses. Token resolution follows
// the same environment → config → keychain precedence as other API commands.
func newMCPCmd(cf *configFlags) *cobra.Command {
return &cobra.Command{
Use: "mcp",
Expand Down Expand Up @@ -77,7 +76,7 @@ func newMCPCmd(cf *configFlags) *cobra.Command {

// runMCPServer constructs the MCP server, registers every Spur tool/resource/
// prompt against client, and serves stdio until the command context is
// cancelled (Ctrl-C or the client closing the transport).
// canceled (Ctrl-C or the client closing the transport).
func runMCPServer(cmd *cobra.Command, client *spur.Client) error {
schemasSub, err := fs.Sub(spur.SchemasFS(), "schemas")
if err != nil {
Expand Down
5 changes: 2 additions & 3 deletions internal/app/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,8 @@ import (
"github.com/spurintel/cli/internal/output"
)

// outputFlags backs the root command's persistent output flags. Every command
// that produces data routes through emit so format, destination, and color are
// resolved the same way everywhere.
// outputFlags backs the root command's persistent output flags. Structured
// reports use emit; feed downloads and exports use sink to stream raw bytes.
type outputFlags struct {
format string // --format; empty means auto-detect from the destination
output string // --output/-o; "-" or empty means stdout
Expand Down
4 changes: 2 additions & 2 deletions internal/auth/keyring.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import (

// Keychain identifiers for the stored token. service is the label the OS
// keychain shows for the item; account distinguishes it within the service.
// A single token is stored per machine (profiles select tokens from the config
// file, not the keychain), so a fixed account is sufficient.
// A single token is stored in the current user's OS keychain. Profiles select
// tokens from the config file, so the keychain uses a fixed account name.
const (
keychainService = "spur"
keychainAccount = "api-token"
Expand Down
103 changes: 103 additions & 0 deletions internal/output/context.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package output

import (
"fmt"
"io"
"strings"

"github.com/charmbracelet/x/ansi"
"github.com/spurintel/cli/internal/spur"
)

// renderIPContext adapts the IP result layout from app.spur.us for a terminal:
// an overview followed by proxy, geo, and device intelligence. History and maps
// are omitted because they require data beyond the context response.
func renderIPContext(w io.Writer, s Styles, width int, c spur.IPContext) error {
width = max(1, min(80, width))
var b strings.Builder
line := func(text string) { fmt.Fprintln(&b, ansi.Wrap(text, width, "")) }
title := func(text string) { line(s.Title.Render(text)) }
row := func(key string, value any) { writeReportRow(&b, s, width, key, value) }
section := func(name string) { fmt.Fprintln(&b); title("■ " + name) }

title(c.IP)
// An empty tunnel list does not establish that an address is safe or that
// all of its traffic is non-anonymous.
anonymous := false
for _, t := range c.Tunnels {
anonymous = anonymous || t.Anonymous
}
if anonymous {
line(s.Warning.Render("[ANONYMOUS TUNNEL]"))
} else {
line(s.Empty.UnsetItalic().Render("[NO ANONYMOUS TUNNEL REPORTED]"))
}
fmt.Fprintln(&b)
associations := len(c.Tunnels) + len(c.Client.Proxies)
asn := ""
if c.AS.Number != 0 {
asn = fmt.Sprintf("AS%d", c.AS.Number)
}
row("Associations", associations)
row("Country", c.Location.Country)
row("Infrastructure", c.Infrastructure)
row("ASN", asn)
row("AS organization", c.AS.Organization)
if c.Organization != "" {
row("Organization", c.Organization)
}
row("Risk indicators", c.Risks)
line(s.Empty.UnsetItalic().Render(strings.Repeat("─", min(56, width))))

section("PROXY & ANONYMIZATION INTELLIGENCE")
if len(c.Tunnels) == 0 {
row("Tunnel operators", "")
}
for i, t := range c.Tunnels {
row(fmt.Sprintf("Tunnel %d", i+1), t.Operator)
row("Type", t.Type)
row("Anonymous", t.Anonymous)
if len(t.Entries) > 0 {
row("Entry IPs", t.Entries)
}
if len(t.Exits) > 0 {
row("Exit IPs", t.Exits)
}
}
row("Client proxies", c.Client.Proxies)
row("Services", c.Services)
section("GEO INTELLIGENCE")
row("IP geolocation", contextLocation(c.Location.City, c.Location.State, c.Location.Country))
if con := c.Client.Concentration; con != nil {
row("Concentration", contextLocation(con.City, con.State, con.Country))
row("Client countries", c.Client.Countries)
row("Density", con.Density)
row("Skew (km)", con.Skew)
} else {
row("Concentration", "")
row("Client countries", c.Client.Countries)
row("Density", "")
row("Skew (km)", "")
}
section("DEVICE ACTIVITY")
row("Devices", c.Client.Count)
row("Behavior signals", c.Client.Behaviors)
row("Device types", c.Client.Types)
row("Client spread", c.Client.Spread)
if c.AI != nil {
row("AI operator", c.AI.Operator)
row("AI activity", c.AI.Types)
}
_, err := io.WriteString(w, b.String())
return err
}

func contextLocation(parts ...string) string {
var nonempty []string
for _, part := range parts {
if part != "" {
nonempty = append(nonempty, part)
}
}
return strings.Join(nonempty, ", ")
}
Loading
Loading