From a154554ef409336c23d66489e8dd20cf2132efca Mon Sep 17 00:00:00 2001 From: Orion Delwaterman Date: Fri, 11 Sep 2026 11:03:46 -0400 Subject: [PATCH 1/2] Add schema request to cli --- internal/app/app_test.go | 4 ++ internal/cli/analytics.go | 52 ++++++++++++++++++++++++ internal/cli/analytics_test.go | 46 +++++++++++++++++++++ internal/client/analytics/client.go | 45 +++++++++++++++++++- internal/client/analytics/client_test.go | 35 ++++++++++++++++ 5 files changed, 181 insertions(+), 1 deletion(-) diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 9607753..982ffff 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -162,6 +162,10 @@ func (f *fakeAnalytics) Query(context.Context, analytics.QueryRequest, agent.Tok return analytics.QueryResponse{}, nil } +func (f *fakeAnalytics) Schema(context.Context, agent.Token) ([]analytics.QueryTable, error) { + return nil, nil +} + func TestBuildConsentStrategyAgentConfirmsIcons(t *testing.T) { config := testConfig(t) config.Runtime.EndUserProximity = configuration.RuntimeEndUserProximityRemote diff --git a/internal/cli/analytics.go b/internal/cli/analytics.go index c4b1ae0..3f8bb91 100644 --- a/internal/cli/analytics.go +++ b/internal/cli/analytics.go @@ -21,6 +21,7 @@ func NewAnalyticsCommand(factory app.Factory) *cobra.Command { }, } cmd.AddCommand(NewAnalyticsQueryCommand(factory)) + cmd.AddCommand(NewAnalyticsSchemaCommand(factory)) return cmd } @@ -75,3 +76,54 @@ func runAnalyticsQuery(cmd *cobra.Command, factory app.Factory, sql string) erro } return nil } + +func NewAnalyticsSchemaCommand(factory app.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "schema", + Short: "List available analytics tables and columns", + Args: func(cmd *cobra.Command, args []string) error { + if len(args) != 0 { + return UsageError("analytics schema accepts no arguments") + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + return runAnalyticsSchema(cmd, factory) + }, + } + cmd.Flags().String("user-agent", "", "user agent for request") + return cmd +} + +func runAnalyticsSchema(cmd *cobra.Command, factory app.Factory) error { + application, err := appForCommand(factory, cmd) + if err != nil { + return RuntimeError(err) + } + credentials, err := application.Credentials() + if err != nil { + return RuntimeError(err) + } + analyticsClient, err := application.Analytics() + if err != nil { + return RuntimeError(err) + } + identity, err := credentials.ResolveIdentity(cmd.Context(), agenttoken.ResolveIdentityOptions{ + UserAgent: flagChangedStr(cmd, "user-agent"), + }) + if err != nil { + return RuntimeError(fmt.Errorf("error resolving identity: %w", err)) + } + token, err := credentials.GetAgentToken(cmd, identity, agenttoken.WithOBO()) + if err != nil { + return RuntimeError(fmt.Errorf("error fetching agent token: %w", err)) + } + result, err := analyticsClient.Schema(cmd.Context(), token) + if err != nil { + return RuntimeError(fmt.Errorf("error fetching analytics schema: %w", err)) + } + if err := writeJSON(cmd.OutOrStdout(), result); err != nil { + return RuntimeError(fmt.Errorf("error writing analytics schema: %w", err)) + } + return nil +} diff --git a/internal/cli/analytics_test.go b/internal/cli/analytics_test.go index 3686058..e4301a5 100644 --- a/internal/cli/analytics_test.go +++ b/internal/cli/analytics_test.go @@ -80,6 +80,52 @@ func TestAnalyticsQueryUsesOBOAgentTokenAndWritesJSON(t *testing.T) { } } +func TestAnalyticsSchemaUsesOBOAgentTokenAndWritesJSON(t *testing.T) { + token := testAgentJWTWithOBO(t) + storageDir := t.TempDir() + if err := os.WriteFile(filepath.Join(storageDir, "agent-token.jwt"), []byte(token), 0o600); err != nil { + t.Fatal(err) + } + analyticsSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/analytics/agent/v1/query/schema" { + t.Fatalf("unexpected analytics request: %s %s", r.Method, r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer "+token { + t.Fatal("unexpected authorization header") + } + _, _ = w.Write([]byte(`[{"name":"agent_logs_by_page","columns":[{"name":"host","type":"STRING"}]}]`)) + })) + defer analyticsSrv.Close() + + config := testConfig() + config.Analytics.Enabled = true + config.Analytics.BaseURL = analyticsSrv.URL + config.Credentials.StorageDir = storageDir + config.Runtime.StateDir = storageDir + + var stdout, stderr bytes.Buffer + code := executeTestCommandWithConfig(config, []string{"analytics", "schema"}, nil, &stdout, &stderr) + if code != 0 { + t.Fatalf("expected success, got %d (stderr=%q)", code, stderr.String()) + } + var output []struct { + Name string `json:"name"` + Columns []struct { + Name string `json:"name"` + Type string `json:"type"` + } `json:"columns"` + } + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("invalid JSON output %q: %v", stdout.String(), err) + } + if len(output) != 1 || output[0].Name != "agent_logs_by_page" { + t.Fatalf("unexpected tables: %#v", output) + } + if len(output[0].Columns) != 1 || output[0].Columns[0].Name != "host" { + t.Fatalf("unexpected columns: %#v", output[0].Columns) + } +} + func TestAnalyticsQueryRequiresOneSQLArgument(t *testing.T) { config := testConfig() config.Analytics.Enabled = true diff --git a/internal/client/analytics/client.go b/internal/client/analytics/client.go index e894fa9..d8fd965 100644 --- a/internal/client/analytics/client.go +++ b/internal/client/analytics/client.go @@ -15,7 +15,10 @@ import ( "github.com/tollbit/cli/internal/tokens/agent" ) -const queryPath = "/analytics/agent/v1/query" +const ( + queryPath = "/analytics/agent/v1/query" + schemaPath = queryPath + "/schema" +) type ( Config struct { @@ -24,6 +27,7 @@ type ( Client interface { Query(context.Context, QueryRequest, agent.Token) (QueryResponse, error) + Schema(context.Context, agent.Token) ([]QueryTable, error) } client struct { @@ -44,6 +48,11 @@ type ( Columns []QueryColumn `json:"columns"` Rows [][]any `json:"rows"` } + + QueryTable struct { + Name string `json:"name"` + Columns []QueryColumn `json:"columns"` + } ) var _ Client = (*client)(nil) @@ -101,3 +110,37 @@ func (c *client) Query(ctx context.Context, request QueryRequest, token agent.To } return result, nil } + +func (c *client) Schema(ctx context.Context, token agent.Token) ([]QueryTable, error) { + if strings.TrimSpace(token.RawToken) == "" { + return nil, errors.New("agent token is required") + } + if err := token.Validate(); err != nil { + return nil, err + } + + u := *c.baseURL + u.Path = strings.TrimRight(c.baseURL.Path, "/") + schemaPath + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+token.RawToken) + + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return nil, errorsx.ParseResponseError(ctx, resp.Status, resp.StatusCode, resp.Header, body) + } + + var result []QueryTable + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + return result, nil +} diff --git a/internal/client/analytics/client_test.go b/internal/client/analytics/client_test.go index 8d36fd9..5f1294a 100644 --- a/internal/client/analytics/client_test.go +++ b/internal/client/analytics/client_test.go @@ -57,6 +57,41 @@ func TestQuery(t *testing.T) { } } +func TestSchema(t *testing.T) { + token := validAgentToken(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/prefix/analytics/agent/v1/query/schema" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if r.Header.Get("Accept") != "application/json" { + t.Fatalf("unexpected accept header: %q", r.Header.Get("Accept")) + } + if r.Header.Get("Authorization") != "Bearer "+token.RawToken { + t.Fatal("unexpected authorization header") + } + _ = json.NewEncoder(w).Encode([]QueryTable{{ + Name: "agent_logs_by_page", + Columns: []QueryColumn{{Name: "host", Type: "STRING"}}, + }}) + })) + defer srv.Close() + + client, err := NewClient(Config{BaseURL: " " + srv.URL + "/prefix "}) + if err != nil { + t.Fatal(err) + } + tables, err := client.Schema(context.Background(), token) + if err != nil { + t.Fatal(err) + } + if len(tables) != 1 || tables[0].Name != "agent_logs_by_page" { + t.Fatalf("unexpected tables: %#v", tables) + } + if len(tables[0].Columns) != 1 || tables[0].Columns[0].Name != "host" { + t.Fatalf("unexpected columns: %#v", tables[0].Columns) + } +} + func TestQueryParsesProblemJSON(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Request-ID", "request-123") From 1193922fcc114560e56144516bce8d5006c86071 Mon Sep 17 00:00:00 2001 From: Orion Delwaterman Date: Fri, 11 Sep 2026 11:04:34 -0400 Subject: [PATCH 2/2] Bump version --- README.md | 6 +++--- internal/version/version.go | 2 +- skill/tollbit-cli/SKILL.md | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a7f35ef..b952370 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.1 +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 -- --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.1 -Force +Install-Tollbit -Version v0.3.2 -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.1 --force +curl -fsSL "https://raw.githubusercontent.com/tollbit/cli/main/scripts/install.sh" | bash -s -- --version v0.3.2 --force ``` ```powershell diff --git a/internal/version/version.go b/internal/version/version.go index 52cec79..a9a0d06 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.1" +const Version = "0.3.2" diff --git a/skill/tollbit-cli/SKILL.md b/skill/tollbit-cli/SKILL.md index ddcb33d..796f20c 100644 --- a/skill/tollbit-cli/SKILL.md +++ b/skill/tollbit-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: tollbit-cli -version: 0.3.1 +version: 0.3.2 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. ---