diff --git a/README.md b/README.md index b952370..fe0d6ce 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ curl -fsSL "https://raw.githubusercontent.com/tollbit/cli/main/scripts/install.s Pin a version or choose an install directory: ```bash -curl -fsSL "https://raw.githubusercontent.com/tollbit/cli/main/scripts/install.sh" | bash -s -- --version v0.3.2 +curl -fsSL "https://raw.githubusercontent.com/tollbit/cli/main/scripts/install.sh" | bash -s -- --version v0.3.3 curl -fsSL "https://raw.githubusercontent.com/tollbit/cli/main/scripts/install.sh" | bash -s -- --install-dir "$HOME/bin" --force ``` @@ -37,7 +37,7 @@ Pin a version or skip `PATH` changes (useful in CI): ```powershell irm "https://raw.githubusercontent.com/tollbit/cli/main/scripts/install.ps1" | iex -Install-Tollbit -Version v0.3.2 -Force +Install-Tollbit -Version v0.3.3 -Force Install-Tollbit -NoModifyPath -PrintPathInstructions ``` @@ -167,7 +167,7 @@ Installer channel updates: curl -fsSL "https://raw.githubusercontent.com/tollbit/cli/main/scripts/install.sh" | bash # Pinned -curl -fsSL "https://raw.githubusercontent.com/tollbit/cli/main/scripts/install.sh" | bash -s -- --version v0.3.2 --force +curl -fsSL "https://raw.githubusercontent.com/tollbit/cli/main/scripts/install.sh" | bash -s -- --version v0.3.3 --force ``` ```powershell diff --git a/analytics-cli-plan.md b/analytics-cli-plan.md new file mode 100644 index 0000000..0866fc3 --- /dev/null +++ b/analytics-cli-plan.md @@ -0,0 +1,131 @@ +# `tollbit analytics` usability fixes + +## Context + +A help-only review of `tollbit analytics` (findings in `analytics-cli-review.md`, untracked) turned up 31 issues. The command group is agent-facing, so the fixes split into three buckets: what the CLI itself can fix (help text, output, input, error hints), what the bundled skill must teach agents, and what only the server can provide (limits, truncation, descriptions, error codes, data-model cleanup). Server limits (10k rows, ~30 GB scanned, 10 s) are configured server-side and must never be hard-coded in the CLI; the CLI renders whatever the server reports. + +Decisions already made: +- JSON stays the default output. Add other formats behind a flag. +- Server limits come from the server, not the CLI. Proposed contract below. +- `--user-agent` is removed from both analytics commands. It only affected token minting and never reached the analytics request. The client still sends the CLI's own `User-Agent` and `X-Tollbit-Client` headers. +- Server data-model asks are recorded as a backlog section, not built here. + +## Proposed server contract (for the backlog; CLI tolerates old and new shapes) + +**Schema `GET /analytics/agent/v1/query/schema`** moves from a top-level array to an object: + +```json +{ + "dialect": "bigquery", + "tables": [ + { + "name": "user_agent_aggregate", + "description": "Daily request counts per host, user agent, status class and request kind.", + "columns": [ + {"name": "timestamp", "type": "TIMESTAMP", "description": "Day bucket, 00:00 UTC."}, + {"name": "type", "type": "STRING", "description": "Request kind.", "values": ["REQUEST", "ROBOT", "SITEMAP", "WELL-KNOWN"]} + ] + } + ], + "limits": { + "max_rows": {"value": 10000, "unit": "rows", "description": "Result sets are capped at this many rows. Use ORDER BY with LIMIT/OFFSET to page."}, + "max_bytes_scanned": {"value": 32212254720, "unit": "bytes", "description": "Queries estimated to scan more than this are rejected. Filter on timestamp and select fewer columns."}, + "max_duration": {"value": 10, "unit": "seconds", "description": "Queries running longer than this are cancelled."} + } +} +``` + +`limits` is a map of name to `{value, unit, description}`. The CLI iterates the map and prints every entry it receives, so new limits need no CLI change. `description` and `values` are optional everywhere; the CLI omits what is absent. + +**Query `POST /analytics/agent/v1/query`** adds a `meta` block: + +```json +{"columns": [...], "rows": [...], "meta": {"row_count": 10000, "truncated": true, "bytes_scanned": 1234567, "duration_ms": 850}} +``` + +**Errors** keep ProblemJSON and add `code` values the CLI can map to hints: `analytics_unknown_table`, `analytics_scan_limit_exceeded`, `analytics_query_timeout`, `analytics_statement_not_allowed`. `detail` for the scan-limit case should include the estimate and the limit. The unknown-table `detail` should list available tables and stop referencing the REST path. + +## CLI changes + +All in `internal/cli/analytics.go` and `internal/client/analytics/client.go` unless noted. + +### 1. Help text +- Add `analyticsLongHelp`, `analyticsQueryLongHelp`, `analyticsSchemaLongHelp` consts following the `searchLongHelp` pattern (`internal/cli/search.go:21-26`). Cover: run `schema` first, BigQuery Standard SQL, only SELECT, daily `timestamp` buckets, always filter on `timestamp` for the per-page and referrer tables, limits are reported by `schema`, stdout is JSON, truncation warning on stderr. +- Replace the broken example with several real ones: a `schema` call, a 7-day `SUM(count)` grouped by `user_agent`, a per-path query with a date filter, and a stdin example. +- `Args`: separate messages for zero args ("analytics query requires ") and extra args ("analytics query accepts a single argument"); reject blank SQL like `search.go:46-48`. +- Remove the `--user-agent` flag from `query` and `schema`. Identity resolves from the stored profile with no override. + +### 2. Input +- `query -` reads SQL from stdin (`cmd.InOrStdin()`, `io.ReadAll`, trim). Error if empty. No `--file` flag. + +### 3. Output formats +- Add `--format json|table|csv` on `query` (default `json`) and `--format json|table` on `schema`. Invalid value is a `UsageError`. +- `table`: `text/tabwriter` with the same params as `auth status` (`internal/cli/auth.go:564-576`), header row from `columns`, NULL rendered as empty. +- `csv`: stdlib `encoding/csv`, header row, NULL as empty. +- JSON output is the raw `QueryResponse` including `meta` when present (add `Meta *QueryMeta` with `json:"meta,omitempty"`). +- Schema `table` view: one block per table (name, description, columns with type and description and values), then a "Limits:" block listing every entry of the map, then "Dialect:". JSON view is the raw object. + +### 4. Truncation and metadata +- After a successful query, if `meta.truncated` is true, print to stderr via `printLeadingCommand`-style helper: `warning: result truncated at rows (server limit). Add ORDER BY and LIMIT/OFFSET to page, or narrow the query.` Number comes from `meta.row_count`, never a constant. +- Nothing is printed when `meta` is absent (old server). + +### 5. Schema decoding +- Client `Schema` returns a new `SchemaResponse{Dialect string; Tables []QueryTable; Limits map[string]Limit}`. Decode into `json.RawMessage`, sniff first non-space byte: `[` means legacy array of tables, `{` means the new object. Both paths produce `SchemaResponse`. +- `QueryTable` and `QueryColumn` gain `Description string` and `QueryColumn` gains `Values []string`, all `omitempty`. + +### 6. Error hints +- In `runAnalyticsQuery`, after `Query` fails, inspect the error with `errors.As` for `*problemjson.Problem`. Map `Code` to a stderr hint line appended after the error: + - `analytics_unknown_table` and `analytics_statement_not_allowed`: "Run `tollbit analytics schema` to list available tables." + - `analytics_scan_limit_exceeded` and `analytics_query_timeout`: "Run `tollbit analytics schema` to see query limits, then filter on timestamp or select fewer columns." +- Unknown or absent codes keep today's behavior. No string matching on `detail`. + +### 7. Client headers and token flow +- `Query` and `Schema` set `User-Agent: version.HTTPUserAgent()` and `X-Tollbit-Client: version.ClientHeader()`, matching `internal/client/tollbit/client.go:355-357`. No `Tollbit-User-Agent` header and no signature change. +- Switch both runners to the `RetryOnOBORequired` branch used by search/pricing/fetch (`internal/cli/search.go:112-122`) so behavior matches the rest of the CLI. + +### 8. Not doing +- Hiding `--end-user-proximity` from analytics help. No precedent for hiding flags; it is one line of noise. +- Version bump. Release is a separate PR via `make bump`. +- Removing dead `trim`/`joinArgs` in `common.go`. + +## Skill and docs + +- `skill/tollbit-cli/SKILL.md`: add an `## Analytics` section between Fetch and Auth: purpose (org traffic analytics), always run `schema` first and read `limits`, dialect, only SELECT, `timestamp` is a daily bucket and the four log tables need a date filter, `type` meaning until the server documents it, JSON shape, `--format`, stdin, truncation warning, error hints. Include three example queries matching the help. Extend the `## For automation` bullets: `--format` on analytics, and "check `meta.truncated`". Update the frontmatter `description` to add "...or query the org's site traffic analytics (bot and referrer logs)". Keep `version: 0.3.2`. +- `README.md`: add an `analytics query` / `analytics schema` row to the command table at lines 74-82 and a short section after Feedback with two examples. + +## Server backlog (evidence in `analytics-cli-review.md`) + +Contract (needed for CLI items 4, 5, 6): +1. Schema object with `dialect`, `tables[].description`, `columns[].description`, `columns[].values`, and `limits` map as above. +2. Query `meta` with `row_count`, `truncated`, `bytes_scanned`, `duration_ms`. +3. ProblemJSON `code` values listed above; scan-limit `detail` includes numbers; unknown-table `detail` lists tables; multi-statement error should say "multiple statements are not supported". +4. Over-limit queries should never surface as 500 (seen once on a 330-day query). + +Data model: +5. `agent_logs_by_page` and `page_logs_by_agent` are identical. Drop one or document the difference. +6. `page_logs_for_referrers` and `referrer_logs` differ only by today's rows. Same ask. +7. `normalized_landing_path` never differs from `landing_path` over 90 days. Drop or fix normalization. +8. `type = ROBOT` means "/robots.txt request", not "is a bot". Rename to `request_kind` or document via `values` descriptions. +9. `status_code` holds only 200/300/400/500. Rename to `status_class` or document. +10. No bot/human/AI classification column. `ip_provider` is null for 99% of rows. Add a `client_class` or similar. +11. `timestamp` is a day bucket typed TIMESTAMP. Consider a `day` DATE column or document. +12. Empty-string `user_agent` vs NULL; `user_agent` is sometimes a family name and sometimes raw. Document normalization. +13. `Edge Health Probe` dominates one host. Consider excluding infra probes or tagging them. +14. `full_referrer` is sometimes an origin and sometimes a full URL. Document. + +## Files + +- `internal/cli/analytics.go` (help, args, stdin, formats, truncation warning, hints, OBO retry, drop `--user-agent`) +- `internal/cli/analytics_test.go` (new cases below) +- `internal/client/analytics/client.go` and `client_test.go` (headers, `SchemaResponse`, `Meta`, legacy array decode) +- `skill/tollbit-cli/SKILL.md` +- `README.md` + +## Verification + +- `make test` (runs both `-tags dev` and release via CI; locally `go test ./... && go test -tags dev ./...`). +- New CLI tests: `--format table` and `csv` render header plus NULL as empty; `query -` reads stdin; blank SQL is a usage error; extra args message; truncation warning appears on stderr only when `meta.truncated`; schema decodes both array and object bodies and renders limits generically (a limit name the test invents must print); ProblemJSON `code` maps to the hint on stderr; unknown code prints no hint. +- New client tests: request carries `User-Agent` and `X-Tollbit-Client`; `Meta` decodes; legacy schema array decodes. +- CLI test: `--user-agent` on `analytics query` or `analytics schema` is rejected as an unknown flag (exit 2). +- Skill tests: `TestSkillFrontmatterVersionMatchesCLI` still passes; rendered guide has no `{{`. +- Manual: `make build`, then `./tollbit analytics --help`, `./tollbit analytics query --help`, `./tollbit analytics schema --format table`, `echo 'SELECT 1 AS x' | ./tollbit analytics query -`, and a real 7-day query with `--format table` against the live gateway. The live server still returns the legacy schema array, so limits will print only once the server ships. diff --git a/analytics-cli-review.md b/analytics-cli-review.md new file mode 100644 index 0000000..c21eebb --- /dev/null +++ b/analytics-cli-review.md @@ -0,0 +1,78 @@ +# `tollbit analytics` usability review + +Date: 2026-09-11. CLI version 0.3.2. Method: help text, `tollbit guide`, and live queries only. No source code consulted. + +## Summary + +The analytics commands work: `schema` lists five tables, `query` accepts BigQuery-style SQL (CTEs, joins, `COUNTIF`, `FORMAT_TIMESTAMP`, string date literals all work), only SELECT is allowed, and bad-column errors are helpful. But a first-time user hits several walls that the help does not warn about. + +Top issues, ranked: + +1. **Silent 10,000-row cap.** A full-table select returns exactly 10,000 rows, exit 0, with no truncation marker in the JSON and no note in `--help`. `LIMIT 20000` also returns 10,000. +2. **Undocumented scan limit.** `SELECT * FROM agent_logs_by_page LIMIT 2` fails with a 422 "would scan more than the allowed limit". LIMIT does not help, no number is given, and the safe date window depends on which columns you select. One boundary query surfaced as a 500 instead. +3. **The only help example is broken.** `SELECT * FROM logs LIMIT 10` fails because no `logs` table exists, and the error points at a REST endpoint instead of `tollbit analytics schema`. +4. **Duplicate tables.** `agent_logs_by_page` and `page_logs_by_agent` are identical in content. The two referrer tables differ only by what looks like today's refresh lag. +5. **Misleading column names.** `type = ROBOT` means "requested /robots.txt", not "is a bot", so Chrome and Safari show up as ROBOT. `status_code` holds only 200/300/400/500 classes. `normalized_landing_path` never differs from `landing_path`. +6. **No bot / AI classification column.** Answering "which AI crawlers hit my site" requires knowing agent names in advance. `ip_provider` is null for 99% of traffic. +7. **No table descriptions, no SQL dialect stated, and the agent guide never mentions analytics.** +8. **Output is JSON only** with positional rows and no metadata, while sibling commands default to human output with a `--json` opt-in. + +Questions asked and answered during the pass: + +- Top user agents on pioneervalleygazette.com, 30 days: "Edge Health Probe" at 62k hits dwarfs everything else. +- Daily ChatGPT-User / GPTBot / OAI-SearchBot trend, 14 days: works, but only because the agent names were already known. +- Paths GPTBot fetches most: `/` and `/careers` on tollbit.com. +- Referrers to tollbit.com blog pages: mostly self-referrals to WordPress probe paths, then google.com. +- Error rate by host, 30 days: tollbit.com 37% 4xx/5xx, thedailydispatching.com 51%. + +## Detailed findings + +## A. Documentation / discoverability + +1. **The only example in `query --help` fails.** `SELECT * FROM logs LIMIT 10` returns `400 Unknown table`. There is no `logs` table. +2. **Error for unknown table points at a REST endpoint, not the CLI.** It says "Use GET /analytics/agent/v1/query/schema" instead of "run `tollbit analytics schema`". +3. **SQL dialect is never stated.** Errors look like BigQuery (`Unexpected keyword ROWS at [1:72]`, `TIMESTAMP_SUB`, `COUNTIF`, `FORMAT_TIMESTAMP` all work). A user has to guess. `rows` is a reserved word, which bit me on the first aggregate query. +4. **`tollbit guide` has zero mentions of analytics.** The bundled agent skill documents search/pricing/fetch/auth only. An agent following the guide will not know analytics exists. +5. **`schema` output has no table or column descriptions.** Just names and types. None of the questions below can be answered from the schema alone. +6. **No mention of auth requirement, org scoping, or which hosts you will see.** It just worked because I was already logged in; unclear what a fresh user sees. +7. **`--user-agent` on `query` and `schema` has no visible effect** and no explanation of why an analytics query would need one. It accepts any string silently. +8. **No exit-code or error-format documentation.** Errors go to stderr as `error querying analytics: : `, exit 1. Fine, but undocumented. + +## B. Result limits (the big ones) + +9. **Silent 10,000-row cap.** `SELECT * FROM user_agent_aggregate` (122,552 rows) returns exactly 10,000 rows, exit 0, no warning, no `truncated` flag in the JSON, no note in `--help`. `LIMIT 20000` also returns 10,000. A user summing rows client-side will get wrong answers without knowing. +10. **Rows come back in nondeterministic order without ORDER BY**, so the 10k you get is arbitrary. `LIMIT 1` twice gave rows from 2025-09-08 and 2026-06-02. +11. **Undocumented scan limit.** `SELECT * FROM agent_logs_by_page LIMIT 2` fails with `422 Query would scan more than the allowed limit`. LIMIT does not help. The message says "narrow the date range or the columns selected" but gives no number and no hint of what range is safe. Empirically: `SELECT *` over the full history fails; `SUM(count)` over 365 days works; `COUNT(*), SUM(count)` over 365 days fails; 300 days works for both. So the limit is bytes-scanned and depends on columns, which is invisible to the user. +12. **Same over-limit condition sometimes surfaces as a 500 Internal Server Error** ("The query could not be completed") instead of the 422. Seen once on a 330-day query; three retries then succeeded. Transient, but a 500 reads like an outage, not "your query is too big". +13. **`user_agent_aggregate` can be scanned in full but the other four tables cannot.** Nothing tells you which tables are "small" and which need a date filter. + +## C. Table and column semantics + +14. **`agent_logs_by_page` and `page_logs_by_agent` are the same table.** Identical columns (only column order differs), identical row count, totals, distinct user agents, and distinct paths over the same window. Why two names? +15. **`page_logs_for_referrers` and `referrer_logs` are almost the same table.** Same columns, different order. Over the same 7-day window one has 8,682 rows and the other 8,691. The extra rows in `referrer_logs` are all from today, so it looks like a refresh lag rather than a semantic difference. Undocumented either way. +16. **`normalized_landing_path` is always equal to `landing_path`.** Zero differing rows over 90 days (72,439 rows). Either the column is redundant or normalization is not running. +17. **`type` values `REQUEST` / `ROBOT` / `SITEMAP` / `WELL-KNOWN` are not explained.** `ROBOT` looks like "is a bot" but it is actually "requests for /robots.txt" (7-day totals: 2,507 ROBOT rows vs 2,506 hits to `/robots.txt`). Chrome, Safari and Firefox all appear with `type = ROBOT`. This is a naming trap for exactly the "how much bot traffic do I get" question this product is about. +18. **There is no bot / human / AI classification column at all.** To answer "which AI crawlers hit my site" you must know the user-agent names yourself. `ip_provider` helps a little but is null for 99% of traffic and is only populated for a handful of providers. +19. **`ip_provider` semantics unclear.** Is it verified-IP-range ownership? It is populated on `ChatGPT-User`, `Googlebot`, etc, but also on `Chrome` (946 hits attributed to `google`), which is confusing without an explanation. +20. **`status_code` is really a status class.** Values are only 200, 300, 400, 500. The column name and INT64 type suggest real codes (301, 404, 429...). A user filtering `status_code = 404` gets nothing with no error. +21. **`user_agent` is a normalized family name, not a raw UA string** ("Chrome", "Googlebot", "Edge Health Probe"), except sometimes it looks raw ("Mozilla/5.0", "Win64"). Empty string `""` is also a value (2.2% of 30-day traffic) and is distinct from NULL. +22. **`timestamp` is a daily bucket at 00:00Z but typed TIMESTAMP.** Every row has hour 0. Today's bucket already exists at 12:00 local, so it is a partial day. A DATE column, or a note, would prevent misreading it as event time. Related footgun: `TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)` silently drops the oldest day bucket compared with `>= '2026-09-04'`. +23. **`count` as a column name collides with `COUNT()`.** It works, but `SUM(count)` vs `COUNT(*)` is easy to mix up and there is no description saying `count` is the pre-aggregated request count. +24. **`full_referrer` is sometimes a bare origin and sometimes a full URL** ("https://www.google.com" vs "https://t.co/xVEzhCBk7X"). Unclear whether paths are stripped for privacy on some domains. + +## D. CLI ergonomics + +25. **No output format option.** Always JSON. Other commands in the same CLI (`search`, `content`) have `--json` as opt-in with a human default; `analytics` has no human table view and no `--csv`. `--format`, `--json`, `--csv`, `--limit` are all "unknown flag". +26. **JSON shape is `{"columns":[{name,type}],"rows":[[...]]}`.** Positional rows are compact but painful with `jq`; an array of objects (or a flag for it) would be friendlier. No `row_count`, `truncated`, or `bytes_scanned` metadata. +27. **No way to read SQL from stdin or a file.** `query -` sends the literal `-` as SQL. Long multi-line queries must be shell-quoted. +28. **Only one positional arg accepted; a second arg gives the generic "analytics query requires "** rather than "too many arguments". +29. **`SHOW TABLES` / `DESCRIBE` are rejected** ("Statement not supported: ShowStatement"). Reasonable, but the error could say "use `tollbit analytics schema`". +30. **Multi-statement queries rejected with a misleading message.** `SELECT 1; SELECT 2` says "Only SELECT statements are supported" even though both are SELECTs. +31. **The `--end-user-proximity` global flag shows on every analytics help page** and is irrelevant to analytics. It adds noise for a command that never triggers browser consent. + +## E. Data observations (not bugs, but surprising as a first-time user) + +- Four hosts visible for this org: tollbit.com, pioneervalleygazette.com, tollbit.news, thedailydispatching.com. History starts 2025-06-01. +- The top "user agent" on pioneervalleygazette.com over 30 days is `Edge Health Probe` at 62k hits, dwarfing everything else. Probably infra noise that should be filterable or excluded. +- 37% of tollbit.com traffic over 30 days is 4xx/5xx; thedailydispatching.com is 51%. Lots of scanner paths (`/wp-admin`, `///admin.php`, `/www/phpinfo.php`). +- `referrer_logs` "landing paths" include things like `/blog/wp-json/batch/v1` self-referred from tollbit.com, which look like bot probes, not human referrals. diff --git a/internal/cli/analytics.go b/internal/cli/analytics.go index 3f8bb91..965c3a2 100644 --- a/internal/cli/analytics.go +++ b/internal/cli/analytics.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "strings" "github.com/spf13/cobra" "github.com/tollbit/cli/internal/app" @@ -9,10 +10,52 @@ import ( "github.com/tollbit/cli/internal/credentials/agenttoken" ) +const analyticsLongHelp = `Query TollBit analytics for your organization's sites. + +Start with "analytics schema" to see the tables and columns available to you, +then run SQL with "analytics query". Both commands print JSON to stdout and +errors to stderr, and require an authorized agent token (auth runs +automatically when needed).` + +const analyticsQueryLongHelp = `Execute a read-only SQL query against TollBit analytics. + +Queries use BigQuery Standard SQL. Only a single SELECT statement is accepted; +SHOW, DESCRIBE, and data-modifying statements are rejected by the server. +Use "analytics schema" to list the tables and columns you can query. + +Rows are daily aggregates: the timestamp column is a day bucket at 00:00 UTC +and the count column is the number of requests in that bucket. Filter on +timestamp (for example, timestamp >= '2026-09-01') to keep queries within the +server's scan limit; the per-page and referrer tables are large enough that +unfiltered queries are rejected even with LIMIT. The server also caps the +number of rows returned, so add ORDER BY with LIMIT and OFFSET when paging +through large results. + +Output is a JSON object with "columns" (name and type) and "rows" (arrays in +column order, null for missing values).` + +const analyticsSchemaLongHelp = `List the analytics tables and columns available to your organization. + +Output is a JSON array of tables, each with its name and columns (name and +type). Run this before "analytics query" to discover table and column names.` + +const analyticsQueryExample = ` # Discover tables and columns first + tollbit analytics schema + + # Requests per user agent over the last 7 days + tollbit analytics query "SELECT user_agent, SUM(count) AS requests FROM user_agent_aggregate WHERE timestamp >= '2026-09-04' GROUP BY user_agent ORDER BY requests DESC LIMIT 20" + + # Most requested paths on one host, with a date filter + tollbit analytics query "SELECT path, SUM(count) AS requests FROM agent_logs_by_page WHERE host = 'example.com' AND timestamp >= '2026-09-04' GROUP BY path ORDER BY requests DESC LIMIT 20" + + # Daily trend for one crawler + tollbit analytics query "SELECT timestamp, SUM(count) AS requests FROM user_agent_aggregate WHERE user_agent = 'GPTBot' AND timestamp >= '2026-08-12' GROUP BY timestamp ORDER BY timestamp"` + func NewAnalyticsCommand(factory app.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "analytics", Short: "Query TollBit analytics", + Long: analyticsLongHelp, Args: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { return UsageError("analytics requires a subcommand") @@ -29,18 +72,24 @@ func NewAnalyticsQueryCommand(factory app.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "query ", Short: "Execute an analytics SQL query", - Example: " tollbit analytics query 'SELECT * FROM logs LIMIT 10'", + Long: analyticsQueryLongHelp, + Example: analyticsQueryExample, Args: func(cmd *cobra.Command, args []string) error { - if len(args) != 1 { + if len(args) == 0 { return UsageError("analytics query requires ") } + if len(args) > 1 { + return UsageError("analytics query accepts a single argument; quote the whole statement") + } + if strings.TrimSpace(args[0]) == "" { + return UsageError("analytics query SQL must not be empty") + } return nil }, RunE: func(cmd *cobra.Command, args []string) error { return runAnalyticsQuery(cmd, factory, args[0]) }, } - cmd.Flags().String("user-agent", "", "user agent for request") return cmd } @@ -57,9 +106,7 @@ func runAnalyticsQuery(cmd *cobra.Command, factory app.Factory, sql string) erro if err != nil { return RuntimeError(err) } - identity, err := credentials.ResolveIdentity(cmd.Context(), agenttoken.ResolveIdentityOptions{ - UserAgent: flagChangedStr(cmd, "user-agent"), - }) + identity, err := credentials.ResolveIdentity(cmd.Context(), agenttoken.ResolveIdentityOptions{}) if err != nil { return RuntimeError(fmt.Errorf("error resolving identity: %w", err)) } @@ -79,8 +126,10 @@ func runAnalyticsQuery(cmd *cobra.Command, factory app.Factory, sql string) erro func NewAnalyticsSchemaCommand(factory app.Factory) *cobra.Command { cmd := &cobra.Command{ - Use: "schema", - Short: "List available analytics tables and columns", + Use: "schema", + Short: "List available analytics tables and columns", + Long: analyticsSchemaLongHelp, + Example: " tollbit analytics schema", Args: func(cmd *cobra.Command, args []string) error { if len(args) != 0 { return UsageError("analytics schema accepts no arguments") @@ -91,7 +140,6 @@ func NewAnalyticsSchemaCommand(factory app.Factory) *cobra.Command { return runAnalyticsSchema(cmd, factory) }, } - cmd.Flags().String("user-agent", "", "user agent for request") return cmd } @@ -108,9 +156,7 @@ func runAnalyticsSchema(cmd *cobra.Command, factory app.Factory) error { if err != nil { return RuntimeError(err) } - identity, err := credentials.ResolveIdentity(cmd.Context(), agenttoken.ResolveIdentityOptions{ - UserAgent: flagChangedStr(cmd, "user-agent"), - }) + identity, err := credentials.ResolveIdentity(cmd.Context(), agenttoken.ResolveIdentityOptions{}) if err != nil { return RuntimeError(fmt.Errorf("error resolving identity: %w", err)) } diff --git a/internal/cli/analytics_test.go b/internal/cli/analytics_test.go index e4301a5..98f7404 100644 --- a/internal/cli/analytics_test.go +++ b/internal/cli/analytics_test.go @@ -131,14 +131,63 @@ func TestAnalyticsQueryRequiresOneSQLArgument(t *testing.T) { config.Analytics.Enabled = true config.Analytics.BaseURL = "https://analytics.example" + for _, tc := range []struct { + args []string + want string + }{ + {[]string{"analytics", "query"}, "analytics query requires "}, + {[]string{"analytics", "query", "SELECT", "1"}, "analytics query accepts a single argument"}, + {[]string{"analytics", "query", " "}, "analytics query SQL must not be empty"}, + } { + var stdout, stderr bytes.Buffer + code := executeTestCommandWithConfig(config, tc.args, nil, &stdout, &stderr) + if code != 2 || !strings.Contains(stderr.String(), tc.want) { + t.Fatalf("expected usage error %q for %#v, got code=%d stderr=%q", tc.want, tc.args, code, stderr.String()) + } + } +} + +func TestAnalyticsCommandsRejectUserAgentFlag(t *testing.T) { + config := testConfig() + config.Analytics.Enabled = true + config.Analytics.BaseURL = "https://analytics.example" + for _, args := range [][]string{ - {"analytics", "query"}, - {"analytics", "query", "SELECT", "1"}, + {"analytics", "query", "--user-agent", "Agent", "SELECT 1"}, + {"analytics", "schema", "--user-agent", "Agent"}, } { var stdout, stderr bytes.Buffer code := executeTestCommandWithConfig(config, args, nil, &stdout, &stderr) - if code != 2 || !strings.Contains(stderr.String(), "analytics query requires ") { - t.Fatalf("expected SQL usage error for %#v, got code=%d stderr=%q", args, code, stderr.String()) + if code != 2 || !strings.Contains(stderr.String(), "unknown flag: --user-agent") { + t.Fatalf("expected unknown flag error for %#v, got code=%d stderr=%q", args, code, stderr.String()) + } + } +} + +func TestAnalyticsHelpDocumentsQueryContract(t *testing.T) { + config := testConfig() + config.Analytics.Enabled = true + + for _, tc := range []struct { + args []string + want []string + }{ + {[]string{"analytics", "--help"}, []string{"analytics schema", "analytics query"}}, + {[]string{"analytics", "query", "--help"}, []string{"BigQuery Standard SQL", "single SELECT", "timestamp", "user_agent_aggregate", "Examples:"}}, + {[]string{"analytics", "schema", "--help"}, []string{"JSON array of tables", "analytics query"}}, + } { + var stdout, stderr bytes.Buffer + code := executeTestCommandWithConfig(config, tc.args, nil, &stdout, &stderr) + if code != 0 { + t.Fatalf("expected help to succeed for %#v, got code=%d stderr=%q", tc.args, code, stderr.String()) + } + for _, want := range tc.want { + if !strings.Contains(stdout.String(), want) { + t.Fatalf("expected help for %#v to mention %q, got:\n%s", tc.args, want, stdout.String()) + } + } + if strings.Contains(stdout.String(), "FROM logs") { + t.Fatalf("help for %#v still references the nonexistent logs table", tc.args) } } } diff --git a/internal/version/version.go b/internal/version/version.go index a9a0d06..f146e57 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -2,4 +2,4 @@ // Bump this when shipping; keep skill frontmatter `version` in sync (tests enforce it). package version -const Version = "0.3.2" +const Version = "0.3.3" diff --git a/skill/tollbit-cli/SKILL.md b/skill/tollbit-cli/SKILL.md index 796f20c..c0c04d5 100644 --- a/skill/tollbit-cli/SKILL.md +++ b/skill/tollbit-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: tollbit-cli -version: 0.3.2 +version: 0.3.3 description: Search for news and articles and ground answers in licensed publisher content on the TollBit network. Use whenever the user wants to find news, articles, reporting, or sources on a topic or current event — searches the catalog, then prices and fetches full article content (paid) with the tollbit CLI. ---