From 88d15adc565073fbc86ad8038db850e3c5565a55 Mon Sep 17 00:00:00 2001 From: Joshua Junqueira Date: Fri, 2 Oct 2026 11:38:32 -0600 Subject: [PATCH] feat: improve CLI formatting and consistency --- README.md | 6 +- internal/app/app.go | 4 +- internal/app/export_test.go | 10 +- internal/app/feed.go | 58 +++++---- internal/app/feed_test.go | 38 +++++- internal/app/mcp.go | 7 +- internal/app/output.go | 5 +- internal/auth/keyring.go | 4 +- internal/output/context.go | 103 ++++++++++++++++ internal/output/context_test.go | 71 +++++++++++ internal/output/output.go | 7 +- internal/output/output_test.go | 12 +- internal/output/styles.go | 22 ++-- internal/output/text.go | 188 +++++++++-------------------- internal/output/ui_test.go | 60 +++++++-- internal/spur/client.go | 4 +- internal/spur/enumdict/enumdict.go | 2 +- internal/spur/exports.go | 2 +- internal/spur/types.go | 10 +- internal/tools/feeds.go | 4 +- internal/tools/feeds_test.go | 2 +- 21 files changed, 407 insertions(+), 212 deletions(-) create mode 100644 internal/output/context.go create mode 100644 internal/output/context_test.go diff --git a/README.md b/README.md index 1162193..756749c 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/internal/app/app.go b/internal/app/app.go index 7cee83d..50cea99 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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) diff --git a/internal/app/export_test.go b/internal/app/export_test.go index a3e9d41..d8fb7b2 100644 --- a/internal/app/export_test.go +++ b/internal/app/export_test.go @@ -14,9 +14,8 @@ import ( "github.com/spurintel/cli/internal/spur" ) -// streamExport carries the Token header, hits /v1/feeds/ 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/ 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() @@ -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() diff --git a/internal/app/feed.go b/internal/app/feed.go index 582316b..9a149ed 100644 --- a/internal/app/feed.go +++ b/internal/app/feed.go @@ -98,22 +98,21 @@ func newFeedStatusCmd(of *outputFlags, cf *configFlags) *cobra.Command { } } -// newFeedDownloadCmd builds `spur feed download `: 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 ", @@ -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 " + @@ -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 } @@ -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") @@ -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") @@ -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) } } diff --git a/internal/app/feed_test.go b/internal/app/feed_test.go index 7051a2b..f123b5e 100644 --- a/internal/app/feed_test.go +++ b/internal/app/feed_test.go @@ -11,6 +11,8 @@ import ( "hash/crc32" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" @@ -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}, @@ -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) @@ -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) + } +} diff --git a/internal/app/mcp.go b/internal/app/mcp.go index c3ef379..e5b2fbf 100644 --- a/internal/app/mcp.go +++ b/internal/app/mcp.go @@ -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", @@ -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 { diff --git a/internal/app/output.go b/internal/app/output.go index 2f13877..93aea14 100644 --- a/internal/app/output.go +++ b/internal/app/output.go @@ -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 diff --git a/internal/auth/keyring.go b/internal/auth/keyring.go index c6c07b5..d8b9295 100644 --- a/internal/auth/keyring.go +++ b/internal/auth/keyring.go @@ -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" diff --git a/internal/output/context.go b/internal/output/context.go new file mode 100644 index 0000000..6b2d67b --- /dev/null +++ b/internal/output/context.go @@ -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, ", ") +} diff --git a/internal/output/context_test.go b/internal/output/context_test.go new file mode 100644 index 0000000..7d6ef23 --- /dev/null +++ b/internal/output/context_test.go @@ -0,0 +1,71 @@ +package output + +import ( + "bytes" + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" + "github.com/spurintel/cli/internal/spur" +) + +func TestContextReportSectionsAndDetails(t *testing.T) { + c := sampleContext() + c.Client.Concentration = &spur.Concentration{City: "Amsterdam", Country: "NL", Density: 0.82, Skew: 6200} + c.AI = &spur.AIActivity{Operator: "Example AI", Types: []string{"CRAWLER"}} + c.Tunnels[0].Entries = []string{"192.0.2.1"} + c.Tunnels[0].Exits = []string{"192.0.2.2"} + var b bytes.Buffer + if err := Render(&b, FormatText, false, c); err != nil { + t.Fatal(err) + } + out := b.String() + previous := -1 + for _, name := range []string{c.IP, "PROXY & ANONYMIZATION INTELLIGENCE", "GEO INTELLIGENCE", "DEVICE ACTIVITY"} { + at := strings.Index(out, name) + if at <= previous { + t.Fatalf("missing or unordered section %q: %s", name, out) + } + previous = at + } + for _, want := range []string{"[ANONYMOUS TUNNEL]", "PROTON_VPN", "192.0.2.1", "192.0.2.2", "Amsterdam, NL", "0.82", "6200", "Example AI", "CRAWLER"} { + if !strings.Contains(out, want) { + t.Errorf("missing %q: %s", want, out) + } + } +} + +func TestContextReportEmptyValues(t *testing.T) { + var b bytes.Buffer + if err := Render(&b, FormatText, false, spur.IPContext{IP: "192.0.2.1"}); err != nil { + t.Fatal(err) + } + out := b.String() + if !strings.Contains(out, "[NO ANONYMOUS TUNNEL REPORTED]") { + t.Fatal(out) + } + for _, want := range []string{"Risk indicators None", "Tunnel operators None", "Density None", "Devices 0"} { + if !strings.Contains(out, want) { + t.Errorf("missing %q: %s", want, out) + } + } +} + +func TestContextReportRespectsWidth(t *testing.T) { + c := sampleContext() + c.IP = "2001:db8:1234:5678:abcd:abcd:abcd:abcd" + c.AS.Organization = strings.Repeat("長い名前", 30) + c.Infrastructure = strings.Repeat("LONG_INFRASTRUCTURE_", 8) + c.Tunnels[0].Operator = strings.Repeat("LONG_OPERATOR_", 15) + for _, width := range []int{24, 39, 40, 56, 80, 110} { + var b bytes.Buffer + if err := renderIPContext(&b, NewStyles(&b, true), width, c); err != nil { + t.Fatal(err) + } + for _, line := range strings.Split(b.String(), "\n") { + if got := ansi.StringWidth(line); got > width { + t.Errorf("width %d: line has %d columns: %q", width, got, line) + } + } + } +} diff --git a/internal/output/output.go b/internal/output/output.go index f8f6052..f37fccc 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -2,7 +2,7 @@ // format and a value and writes it as human-styled text, newline-delimited // JSON, or CSV. It is a deep module: callers decide the format and whether // color is wanted, and the package hides lipgloss styling, per-entity layout, -// and the tolerant CSV/JSON encoding behind one call. +// and CSV/JSON encoding behind one call. // // The package imports the spur response types it renders; spur stays a pure // API client with no presentation dependency, so the direction is output → @@ -68,9 +68,8 @@ func DefaultFormat(isTTY bool) Format { return FormatJSON } -// IsTerminal reports whether w is an interactive terminal. A plain io.Writer -// (a file, a buffer, a pipe) is not, so this drives both the default-format -// choice and color suppression. +// IsTerminal reports whether w exposes a terminal file descriptor. It drives +// the default-format choice; ColorEnabled separately handles color overrides. func IsTerminal(w io.Writer) bool { f, ok := w.(interface{ Fd() uintptr }) if !ok { diff --git a/internal/output/output_test.go b/internal/output/output_test.go index 2aad05a..49034be 100644 --- a/internal/output/output_test.go +++ b/internal/output/output_test.go @@ -204,8 +204,8 @@ func TestRenderTextBatch(t *testing.T) { t.Errorf("batch text missing %q\n%s", want, out) } } - // Two IP reports means two ◆ headers. - if n := strings.Count(out, "◆"); n != 2 { + // Each IP report retains its own identity and section set. + if n := strings.Count(out, "■ DEVICE ACTIVITY"); n != 2 { t.Errorf("batch text has %d report headers, want 2\n%s", n, out) } } @@ -261,7 +261,7 @@ func TestRenderStatusTextNoColor(t *testing.T) { if strings.Contains(out, esc) { t.Errorf("--no-color status text contains ANSI escape codes: %q", out) } - for _, want := range []string{"Token Status", "Active", "active", "Queries Remaining", "12345", "Service Tier", "online"} { + for _, want := range []string{"Active", "active", "Queries Remaining", "12345", "Service Tier", "online"} { if !strings.Contains(out, want) { t.Errorf("status text missing %q\n%s", want, out) } @@ -374,7 +374,7 @@ func TestRenderTagTextNoColor(t *testing.T) { if strings.Contains(out, esc) { t.Errorf("--no-color tag text contains ANSI escape codes: %q", out) } - for _, want := range []string{"OXYLABS_PROXY", "Oxylabs Proxy", "RESIDENTIAL_PROXY", "Anonymous", "WINDOWS", "HTTP", "Metrics", "123456"} { + for _, want := range []string{"OXYLABS_PROXY", "Oxylabs Proxy", "RESIDENTIAL_PROXY", "Anonymous", "WINDOWS", "HTTP", "METRICS", "123456"} { if !strings.Contains(out, want) { t.Errorf("tag text missing %q\n%s", want, out) } @@ -408,7 +408,7 @@ func TestRenderTagTextNoMetrics(t *testing.T) { t.Fatalf("Render text: %v", err) } out := buf.String() - if strings.Contains(out, "Metrics") { + if strings.Contains(out, "METRICS") { t.Errorf("tag without metrics should omit the Metrics block\n%s", out) } if !strings.Contains(out, "OXYLABS_PROXY") { @@ -555,7 +555,7 @@ func TestRenderFeedMetadataTextNoColor(t *testing.T) { if strings.Contains(out, esc) { t.Errorf("--no-color feed text contains ANSI escape codes: %q", out) } - for _, want := range []string{"Feed", "anonymous", "20250324", "2025-03-24T00:59:41Z", "MMDB", "MiB", "CRC32C", "9f6804a9", "MD5", "5d41402abc4b2a76b9719d911017c592"} { + for _, want := range []string{"anonymous", "20250324", "2025-03-24T00:59:41Z", "MMDB", "MiB", "CRC32C", "9f6804a9", "MD5", "5d41402abc4b2a76b9719d911017c592"} { if !strings.Contains(out, want) { t.Errorf("feed text missing %q\n%s", want, out) } diff --git a/internal/output/styles.go b/internal/output/styles.go index 321f3b6..0880531 100644 --- a/internal/output/styles.go +++ b/internal/output/styles.go @@ -25,7 +25,7 @@ type palette struct { func newPalette(isDark bool) palette { pick := lipgloss.LightDark(isDark) return palette{ - accent: pick(lipgloss.Color("#7C3AED"), lipgloss.Color("#A277FF")), + accent: pick(lipgloss.Color("#4C6500"), lipgloss.Color("#D7FF44")), secondary: pick(lipgloss.Color("#047857"), lipgloss.Color("#61FFCA")), warm: pick(lipgloss.Color("#B45309"), lipgloss.Color("#FFCA85")), muted: pick(lipgloss.Color("#6B7280"), lipgloss.Color("#9A94A8")), @@ -41,9 +41,7 @@ func newPalette(isDark bool) palette { // Writer(w, color). type Styles struct { Title lipgloss.Style // section/report header text - Frame lipgloss.Style // tree connectors (├ │ └) - Block lipgloss.Style // block name - Key lipgloss.Style // field label + Frame lipgloss.Style // progress bar accent Value lipgloss.Style // field value Empty lipgloss.Style // placeholder for absent values Bool lipgloss.Style // boolean values @@ -54,8 +52,8 @@ type Styles struct { // NewStyles builds the style palette for output destined for w. w is used // only to pick the light or dark color variants: when it is a color-enabled -// terminal its background is queried; otherwise the dark palette is assumed -// (nothing colored is shown there anyway). +// terminal its background is queried; otherwise the dark palette is assumed, +// including when color is explicitly forced for a non-terminal writer. func NewStyles(w io.Writer, color bool) Styles { dark := true if color { @@ -68,10 +66,8 @@ func NewStyles(w io.Writer, color bool) Styles { return Styles{ Title: lipgloss.NewStyle().Bold(true).Foreground(p.accent), Frame: lipgloss.NewStyle().Foreground(p.accent), - Block: lipgloss.NewStyle().Bold(true).Foreground(p.warm), - Key: lipgloss.NewStyle().Foreground(p.secondary), Value: lipgloss.NewStyle().Foreground(p.foreground), - Empty: lipgloss.NewStyle().Foreground(p.muted).Italic(true), + Empty: lipgloss.NewStyle().Foreground(p.muted), Bool: lipgloss.NewStyle().Foreground(p.warm), Num: lipgloss.NewStyle().Foreground(p.warm), Success: lipgloss.NewStyle().Foreground(p.secondary), @@ -99,11 +95,9 @@ func Writer(w io.Writer, color bool) io.Writer { return cw } -// ColorEnabled reports whether w should receive ANSI color: an interactive -// terminal whose environment permits it. Detection honors the NO_COLOR -// convention (https://no-color.org), CLICOLOR=0, and CLICOLOR_FORCE, and -// returns no color for a pipe or file. The --no-color flag is applied by the -// caller on top of this. +// ColorEnabled reports whether the destination and environment permit ANSI +// color. Detection honors NO_COLOR, CLICOLOR=0, and CLICOLOR_FORCE; pipes and +// files are uncolored unless color is forced. The caller applies --no-color. func ColorEnabled(w io.Writer) bool { return colorprofile.Detect(w, os.Environ()) > colorprofile.Ascii } diff --git a/internal/output/text.go b/internal/output/text.go index 492b3ce..ef858a4 100644 --- a/internal/output/text.go +++ b/internal/output/text.go @@ -11,9 +11,7 @@ import ( "github.com/spurintel/cli/internal/spur" ) -// block is a titled group of aligned key/value rows, rendered under a tree -// connector. The layout mirrors the synthient/cli output package: a ◆ header, -// then blocks joined by ├ / └ connectors with │-prefixed rows. +// block groups aligned key/value rows beneath a report section heading. type block struct { name string values []kv @@ -24,14 +22,13 @@ type kv struct { val any } -// renderText writes a styled, aligned report for the values this cut knows how -// to lay out. Anything else falls back to JSON so the renderer never refuses a -// value outright; styled layouts are added per entity as commands land. +// renderText dispatches supported response types to their text layouts. Other +// values fall back to JSON. func renderText(w io.Writer, color bool, v any) error { // Styles and width come from the raw destination; the writes themselves go // through the profile writer, which degrades ANSI to what w supports. s := newStyles(w, color) - width := terminalWidth(w) + width := min(80, terminalWidth(w)) cw := Writer(w, color) switch c := v.(type) { case spur.Status: @@ -69,73 +66,6 @@ func renderText(w io.Writer, color bool, v any) error { } } -func renderIPContext(w io.Writer, s Styles, width int, c spur.IPContext) error { - blocks := []block{ - {name: "Network", values: []kv{ - {"ASN", c.AS.Number}, - {"AS Org", c.AS.Organization}, - {"Organization", c.Organization}, - {"Infrastructure", c.Infrastructure}, - }}, - {name: "Location", values: []kv{ - {"City", c.Location.City}, - {"State", c.Location.State}, - {"Country", c.Location.Country}, - }}, - {name: "Client", values: []kv{ - {"Count", c.Client.Count}, - {"Countries", c.Client.Countries}, - {"Spread", c.Client.Spread}, - {"Types", c.Client.Types}, - {"Behaviors", c.Client.Behaviors}, - {"Proxies", c.Client.Proxies}, - }}, - {name: "Assessment", values: []kv{ - {"Risks", c.Risks}, - {"Services", c.Services}, - }}, - } - - if con := c.Client.Concentration; con != nil { - blocks = append(blocks, block{name: "Concentration", values: []kv{ - {"City", con.City}, - {"State", con.State}, - {"Country", con.Country}, - {"Density", con.Density}, - {"Skew (km)", con.Skew}, - }}) - } - if ai := c.AI; ai != nil { - blocks = append(blocks, block{name: "AI Activity", values: []kv{ - {"Operator", ai.Operator}, - {"Types", ai.Types}, - }}) - } - if len(c.Tunnels) > 0 { - tun := block{name: "Tunnels"} - for _, t := range c.Tunnels { - label := t.Operator - if label == "" { - label = t.Type - } - detail := t.Type - if t.Anonymous { - detail += " (anonymous)" - } - tun.values = append(tun.values, kv{label, detail}) - } - blocks = append(blocks, tun) - } - - var b strings.Builder - fmt.Fprintln(&b, s.Title.Render("◆ IP ")+s.Value.Render(c.IP)) - for i, blk := range blocks { - writeBlock(&b, s, width, blk, i+1 == len(blocks)) - } - _, err := io.WriteString(w, b.String()) - return err -} - // renderStatus lays out a token-status report: a single block with the active // flag (as a colored ●/○ marker), queries remaining, and service tier. The // marker uses the Success/Warning palette so a healthy token reads at a glance; @@ -153,8 +83,7 @@ func renderStatus(w io.Writer, s Styles, width int, st spur.Status) error { }} var b strings.Builder - fmt.Fprintln(&b, s.Title.Render("◆ Token Status")) - writeBlock(&b, s, width, blk, true) + writeBlock(&b, s, width, blk) _, err := io.WriteString(w, b.String()) return err } @@ -208,9 +137,9 @@ func renderTagMetadata(w io.Writer, s Styles, width int, t spur.TagMetadata) err } var b strings.Builder - fmt.Fprintln(&b, s.Title.Render("◆ Tag ")+s.Value.Render(label)) - for i, blk := range blocks { - writeBlock(&b, s, width, blk, i+1 == len(blocks)) + writeReportHeader(&b, s, width, label) + for _, blk := range blocks { + writeBlock(&b, s, width, blk) } _, err := io.WriteString(w, b.String()) return err @@ -220,14 +149,13 @@ func renderTagMetadata(w io.Writer, s Styles, width int, t spur.TagMetadata) err // type as name → description, so `spur feed list` reads as a legible menu at a // terminal and stays greppable. func renderFeedTypes(w io.Writer, s Styles, width int, types []spur.FeedType) error { - blk := block{name: "Feeds"} + blk := block{} for _, ft := range types { blk.values = append(blk.values, kv{ft.Name, ft.Description}) } var b strings.Builder - fmt.Fprintln(&b, s.Title.Render("◆ Feed Catalog")) - writeBlock(&b, s, width, blk, true) + writeBlock(&b, s, width, blk) _, err := io.WriteString(w, b.String()) return err } @@ -265,9 +193,9 @@ func renderFeedMetadata(w io.Writer, s Styles, width int, md spur.FeedMetadata) } var b strings.Builder - fmt.Fprintln(&b, s.Title.Render("◆ Feed ")+s.Value.Render(label)) - for i, blk := range blocks { - writeBlock(&b, s, width, blk, i+1 == len(blocks)) + writeReportHeader(&b, s, width, label) + for _, blk := range blocks { + writeBlock(&b, s, width, blk) } _, err := io.WriteString(w, b.String()) return err @@ -288,9 +216,9 @@ func HumanizeBytes(n int64) string { return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp]) } -// flexBoolValue turns an optional FlexBool into a value formatValue can style: +// flexBoolValue turns an optional FlexBool into a report value: // the underlying bool when the API set the field, or the empty string (a muted -// "—") when it was absent, so an unset flag never masquerades as a definite +// "None") when it was absent, so an unset flag never masquerades as a definite // "false". func flexBoolValue(b *spur.FlexBool) any { if b == nil { @@ -305,60 +233,62 @@ func flexNumberValue(n spur.FlexNumber) any { return n.String() } -// writeBlock renders one block: a connector + name line, then a │-prefixed row -// per value with keys padded to a common width for alignment. Values longer -// than the terminal is wide wrap onto continuation lines that stay aligned -// under the value column, so a long description never breaks the tree layout. -func writeBlock(b *strings.Builder, s Styles, width int, blk block, final bool) { - connector, row := "├", "│" - if final { - connector, row = "└", " " +// writeReportHeader identifies the returned entity without repeating the command. +func writeReportHeader(b *strings.Builder, s Styles, width int, label string) { + if label != "" { + fmt.Fprintln(b, ansi.Wrap(s.Title.Render(label), max(1, width), "")) } +} +// writeBlock separates sections with whitespace instead of tree connectors. +func writeBlock(b *strings.Builder, s Styles, width int, blk block) { + if b.Len() > 0 { + fmt.Fprintln(b) + } if blk.name != "" { - fmt.Fprintf(b, "%s %s\n", s.Frame.Render(connector), s.Block.Render(blk.name)) + fmt.Fprintln(b, ansi.Wrap(s.Title.Render("■ "+strings.ToUpper(blk.name)), max(1, width), "")) } - - keyWidth := 0 + indent := 20 for _, v := range blk.values { - if l := lipgloss.Width(v.key); l > keyWidth { - keyWidth = l - } + indent = max(indent, ansi.StringWidth(v.key)+2) } - // Row layout: row glyph + 4 spaces + padded key + 2 spaces + value. - valueWidth := max(24, width-(1+4+keyWidth+2)) - continuation := strings.Repeat(" ", keyWidth) for _, v := range blk.values { - lines := formatValueLines(s, v.val, valueWidth) - for i, line := range lines { - key := continuation - if i == 0 { - key = s.Key.Render(padRight(v.key, keyWidth)) - } - fmt.Fprintf(b, "%s %s %s\n", s.Frame.Render(row), key, line) - } + writeReportRowAt(b, s, width, indent, v.key, v.val) } } -// preStyled is a value the caller has already rendered to a styled string. -// formatValueLines emits it verbatim so callers can inject markers with their -// own color (e.g. the status active/inactive marker) without it being -// re-wrapped in the default Value style. +// preStyled preserves a caller-provided status marker's semantic color. type preStyled string -// formatValueLines renders a field value in a type-appropriate style, wrapped -// to width, collapsing absent scalars and empty slices to a muted placeholder. -// Each returned string is one fully styled display line. -func formatValueLines(s Styles, v any, width int) []string { - if pre, ok := v.(preStyled); ok { - return []string{string(pre)} +// reportValue formats text report values. Zero numbers and false booleans stay +// explicit; empty strings and lists display None. +func reportValue(s Styles, value any) string { + if pre, ok := value.(preStyled); ok { + return string(pre) + } + text, style := valueText(s, value) + return style.Render(text) +} + +func writeReportRow(b *strings.Builder, s Styles, width int, key string, value any) { + writeReportRowAt(b, s, width, max(20, ansi.StringWidth(key)+2), key, value) +} + +func writeReportRowAt(b *strings.Builder, s Styles, width, indent int, key string, value any) { + text := reportValue(s, value) + width = max(1, width) + if width < indent+20 { + fmt.Fprintln(b, ansi.Wrap(s.Empty.UnsetItalic().Render(key), width, "")) + fmt.Fprintln(b, ansi.Wrap(text, width, "")) + return } - text, style := valueText(s, v) - lines := strings.Split(ansi.Wrap(text, width, ""), "\n") - for i, line := range lines { - lines[i] = style.Render(line) + for i, line := range strings.Split(ansi.Wrap(text, width-indent, ""), "\n") { + label := strings.Repeat(" ", indent) + if i == 0 { + label = s.Empty.UnsetItalic().Render(padRight(key, indent)) + } + fmt.Fprintln(b, label+line) } - return lines } // valueText maps a field value to its display text and style. @@ -366,7 +296,7 @@ func valueText(s Styles, v any) (string, lipgloss.Style) { switch val := v.(type) { case string: if val == "" { - return "—", s.Empty + return "None", s.Empty } return val, s.Value case bool: @@ -377,7 +307,7 @@ func valueText(s Styles, v any) (string, lipgloss.Style) { return formatFloat(val), s.Num case []string: if len(val) == 0 { - return "—", s.Empty + return "None", s.Empty } return strings.Join(val, ", "), s.Value default: diff --git a/internal/output/ui_test.go b/internal/output/ui_test.go index 94808e2..c44b0f2 100644 --- a/internal/output/ui_test.go +++ b/internal/output/ui_test.go @@ -11,9 +11,8 @@ import ( ) // TestRenderTextWrapsLongValues renders a tag whose description far exceeds -// the fallback width (100 columns for a non-terminal writer) and checks that -// the value wraps onto continuation lines that stay inside the width and keep -// the tree-row prefix, instead of one overlong line breaking the layout. +// report width and checks that continuation lines stay within 80 columns +// and align beneath the value column. func TestRenderTextWrapsLongValues(t *testing.T) { t.Parallel() long := strings.TrimSpace(strings.Repeat("residential proxy network with global exit pools ", 6)) @@ -27,13 +26,13 @@ func TestRenderTextWrapsLongValues(t *testing.T) { descLines := 0 for _, line := range strings.Split(out, "\n") { - if w := lipgloss.Width(line); w > 100 { - t.Errorf("line exceeds the 100-column fallback width (%d): %q", w, line) + if w := lipgloss.Width(line); w > 80 { + t.Errorf("line exceeds the 80-column report width (%d): %q", w, line) } if strings.Contains(line, "residential proxy network") { descLines++ - if !strings.HasPrefix(line, "│") { - t.Errorf("wrapped description line lost its tree prefix: %q", line) + if !strings.HasPrefix(line, "Description ") && !strings.HasPrefix(line, strings.Repeat(" ", 20)) { + t.Errorf("wrapped description line lost its value alignment: %q", line) } } } @@ -98,3 +97,50 @@ func TestHumanizeBytes(t *testing.T) { } } } + +func TestReportsShareLayout(t *testing.T) { + for name, value := range map[string]any{ + "context": sampleContext(), + "status": spur.Status{Active: true, QueriesRemaining: 42}, + "tag": spur.TagMetadata{Tag: "EXAMPLE_PROXY", Name: "Example"}, + "feed": spur.FeedMetadata{Type: "anonymous", JSON: spur.FeedFile{Date: "2026-10-02"}}, + "catalog": spur.FeedTypes, + } { + t.Run(name, func(t *testing.T) { + var b bytes.Buffer + if err := Render(&b, FormatText, false, value); err != nil { + t.Fatal(err) + } + out := b.String() + if strings.Contains(out, "SPUR / ") || strings.HasPrefix(out, "\n") { + t.Fatalf("redundant header or leading blank line: %s", out) + } + if strings.ContainsAny(out, "◆├└│") { + t.Fatalf("legacy tree formatting: %s", out) + } + for _, line := range strings.Split(out, "\n") { + if lipgloss.Width(line) > 80 { + t.Errorf("overlong line: %q", line) + } + } + if name != "catalog" && !strings.Contains(out, "None") { + t.Errorf("empty value placeholder missing: %s", out) + } + }) + } +} + +func TestReportRowsNarrowWidthAndLongLabels(t *testing.T) { + for _, width := range []int{24, 39, 40, 56, 80} { + var b strings.Builder + s := NewStyles(&bytes.Buffer{}, false) + for _, key := range []string{"Description", "anonymous-residential/realtime"} { + writeReportRow(&b, s, width, key, strings.Repeat("a long value ", 20)) + } + for _, line := range strings.Split(b.String(), "\n") { + if lipgloss.Width(line) > width { + t.Errorf("width %d exceeded: %q", width, line) + } + } + } +} diff --git a/internal/spur/client.go b/internal/spur/client.go index d723488..79423bd 100644 --- a/internal/spur/client.go +++ b/internal/spur/client.go @@ -19,8 +19,8 @@ const BaseURL = "https://api.spur.us" const maxResponseBodyBytes int64 = 1 << 20 -// Client is a stateless-ish wrapper around net/http. Its only mutable state is -// the embedded *http.Client; each API method is safe for concurrent use. +// Client wraps the Spur HTTP endpoints. API methods may run concurrently +// provided callers do not mutate the client configuration during requests. type Client struct { Token string HTTP *http.Client diff --git a/internal/spur/enumdict/enumdict.go b/internal/spur/enumdict/enumdict.go index 1a2214d..e3b3485 100644 --- a/internal/spur/enumdict/enumdict.go +++ b/internal/spur/enumdict/enumdict.go @@ -172,7 +172,7 @@ func categoryForPath(path string) (string, bool) { return "", false } parts := strings.Split(path, ".") - // Trim any items / [N] artefacts when identifying the owning field. + // Trim any items / [N] artifacts when identifying the owning field. var cleaned []string for _, p := range parts { if p == "items" { diff --git a/internal/spur/exports.go b/internal/spur/exports.go index 8e22f97..1d95661 100644 --- a/internal/spur/exports.go +++ b/internal/spur/exports.go @@ -274,7 +274,7 @@ func unsupportedExportFlag(feed ExportFeed, flag string) error { // ExportFeed streams a filtered export from the Exports host. slug is the // resolved feed slug (including any -ipv6 suffix) and q is the query built by // BuildExportQuery. The response is the raw artifact stream — CSV, newline-JSON, -// or an MaxMind DB, per the output parameter — which the caller owns and must +// or a MaxMind DB, per the output parameter — which the caller owns and must // Close. // // Like DownloadFeed this does not buffer or size-limit the body: a full export diff --git a/internal/spur/types.go b/internal/spur/types.go index d802179..468e759 100644 --- a/internal/spur/types.go +++ b/internal/spur/types.go @@ -6,10 +6,9 @@ // auto-generated MCP tool schemas are self-documenting. package spur -// IPContext is the object returned by GET /v2/context/:ip. Optional fields are -// omitted when null per the upstream schema. Enum-valued string slices carry -// their allowable values (and inline descriptions) in the field description so -// they travel with every tool response. +// IPContext models GET /v2/context/:ip. Missing fields decode to Go zero values; +// only fields tagged omitempty are omitted when re-encoded. Enum descriptions +// populate the generated MCP schema. type IPContext struct { AI *AIActivity `json:"ai,omitempty" jsonschema:"AI activity observed from this IP address. Operator is the AI entity name; types are AGENTIC (AI agent) or CRAWLER (AI crawler)."` AS ASInfo `json:"as" jsonschema:"Spur IP GeoBGP autonomous system information (ASN and operator organization)."` @@ -78,7 +77,8 @@ type TunnelEntry struct { // strings ("true"/"false") and the metrics fields as strings, but the live // API returns them as bare booleans and numbers. To be robust to either // shape we use FlexBool and FlexNumber, which tolerate both during -// unmarshal and always emit bare booleans / numbers on marshal. +// unmarshal. Booleans and numeric values marshal as bare JSON values; +// non-numeric FlexNumber strings remain quoted. // // Optional fields use pointers so they're omitted from output when absent. type TagMetadata struct { diff --git a/internal/tools/feeds.go b/internal/tools/feeds.go index 67c5e9f..d1cec21 100644 --- a/internal/tools/feeds.go +++ b/internal/tools/feeds.go @@ -167,7 +167,9 @@ func feedDownload(ctx context.Context, c *spur.Client, in FeedDownloadInput) (*m return toolError(fmt.Errorf("the %q feed is not offered in MMDB format", feedType.Name), "Request format json instead."), FeedDownloadOutput{}, nil } file = md.MMDB - command += " --mmdb" + command += " --format mmdb" + } else { + command += " --format json" } if date != "" { // The metadata document only describes the latest release; its diff --git a/internal/tools/feeds_test.go b/internal/tools/feeds_test.go index d939a60..3c081fc 100644 --- a/internal/tools/feeds_test.go +++ b/internal/tools/feeds_test.go @@ -145,7 +145,7 @@ func TestFeedDownloadJSONPopulatesIntegrity(t *testing.T) { if out.File == nil || out.File.Date != "20250324" { t.Errorf("File = %+v, want date 20250324", out.File) } - if out.Command != "spur feed download anonymous" { + if out.Command != "spur feed download anonymous --format json" { t.Errorf("Command = %q", out.Command) } if out.Integrity == nil || out.Integrity.Size != 999 {