Skip to content
Merged
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
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,15 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for local development and release instruc
- Run **`tollbit guide`** for orientation, then **`tollbit guide --install <SKILLS_DIR>`** to persist the bundled skill. The guide is the full automation contract (exit codes, streams, non-interactive fetch).
- Typical flow: **`search`** → **`content pricing`** → **`content fetch`**. The CLI prompts for authentication when a token is required.
- Optionally set an agent profile with **`tollbit auth set --name <name>`** (or `TOLLBIT_AGENT_DEFAULT_NAME`) / **`--user-agent`**.
- Prefer **`--json`** on **`search`**, **`content pricing`**, **`content fetch`**, and **`auth status`**. Exit codes: `0` success, `1` runtime, `2` usage; stdout is data-only (hints and errors on stderr).
- Prefer **`--json`** on **`search`**, **`content pricing`**, **`content fetch`**, **`feedback`**, and **`auth status`**. Exit codes: `0` success, `1` runtime, `2` usage; stdout is data-only (hints and errors on stderr).

## What the CLI can do

| Command | Purpose |
|--------|---------|
| **`search "query"`** | Search publisher content via the gateway API. |
| **`content pricing/fetch`** | Price and fetch licensed publisher content. |
| **`feedback "message"`** | Submit CLI / agent feedback (requires OBO). |
| **`auth login/logout/status/set`** | Agent profile and OAuth authorization token (also run automatically when needed). |
| **`guide`** | Print the agent guide; optionally install bundled skill markdown. |
| **`version`** | Print the CLI version string. |
Expand Down Expand Up @@ -107,6 +108,16 @@ tollbit content fetch https://example.com/article --confirm --json --rate-index

**Every fetch charges money.** Pricing is shown and you must confirm unless you pass `--confirm` (automation still incurs cost). Use `--toDisk=<path>` to save fetched content locally. When no user agent is configured, the org default `-tbcli-` agent is used. Set a registered user agent with `auth set --user-agent` or `--user-agent` on the fetch command.

### Feedback

Submit product feedback (requires an OBO-linked agent session):

```bash
tollbit feedback "search ranking felt off for this query"
tollbit feedback "great fetch UX" --rating 5 --category content
tollbit feedback "auth hung" --rating 2 --category auth --metadata source=cli --json
```

### Auth

Auth runs automatically when a command needs a token. Use these commands to inspect or manage the profile explicitly:
Expand Down
149 changes: 149 additions & 0 deletions internal/cli/feedback.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package cli

import (
"fmt"
"strings"

"github.com/spf13/cobra"
"github.com/tollbit/cli/internal/app"
"github.com/tollbit/cli/internal/client/tollbit"
"github.com/tollbit/cli/internal/credentials/agenttoken"
"github.com/tollbit/cli/internal/tokens/agent"
)

const feedbackLongHelp = `Submit feedback about the TollBit CLI or agent experience.

Requires an authenticated agent with on-behalf-of (OBO) consent. Feedback is
accepted asynchronously and delivered to Tollbit (Slack + spreadsheet).`

type feedbackOptions struct {
rating int
category string
metadata []string
userAgent string
asJSON bool
}

func NewFeedbackCommand(factory app.Factory) *cobra.Command {
var opts feedbackOptions

cmd := &cobra.Command{
Use: `feedback "message"`,
Short: "Submit feedback to Tollbit",
Long: feedbackLongHelp,
Args: func(cmd *cobra.Command, args []string) error {
if len(args) != 1 {
return UsageError(`feedback requires exactly one message argument`)
}
if strings.TrimSpace(args[0]) == "" {
return UsageError("feedback message must not be empty")
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
return runFeedback(cmd, factory, opts, strings.Trim(args[0], `"'`))
},
}

cmd.Flags().IntVar(&opts.rating, "rating", 0, "optional rating from 1 (worst) to 5 (best)")
cmd.Flags().StringVar(&opts.category, "category", "", "optional category label (e.g. search, auth, content)")
cmd.Flags().StringArrayVar(&opts.metadata, "metadata", nil, "optional key=value context (repeatable)")
cmd.Flags().StringVar(&opts.userAgent, "user-agent", "", "user agent for request")
cmd.Flags().BoolVar(&opts.asJSON, "json", false, "emit raw JSON response")

return cmd
}

func runFeedback(cmd *cobra.Command, factory app.Factory, opts feedbackOptions, message string) error {
message = strings.TrimSpace(message)
if message == "" {
return UsageError("feedback message must not be empty")
}

req := tollbit.SubmitFeedbackRequest{
Message: message,
Category: strings.TrimSpace(opts.category),
}
if cmd.Flags().Changed("rating") {
if opts.rating < 1 || opts.rating > 5 {
return UsageError("feedback --rating must be between 1 and 5")
}
rating := opts.rating
req.Rating = &rating
}
metadata, err := parseMetadataFlags(opts.metadata)
if err != nil {
return err
}
req.Metadata = metadata

app, err := appForCommand(factory, cmd)
if err != nil {
return RuntimeError(err)
}
credentials, err := app.Credentials()
if err != nil {
return RuntimeError(err)
}
tollbitClient, err := app.Tollbit()
if err != nil {
return RuntimeError(err)
}

identityOpts := agenttoken.ResolveIdentityOptions{
UserAgent: flagChangedStr(cmd, "user-agent"),
}
identity, err := credentials.ResolveIdentity(cmd.Context(), identityOpts)
if err != nil {
return RuntimeError(fmt.Errorf("error resolving identity: %w", err))
}

var resp tollbit.SubmitFeedbackResponse
if app.Config().Auth.RetryOnOBORequired {
resp, err = agenttoken.WithOBORetry(cmd, credentials, identity, func(token agent.Token) (tollbit.SubmitFeedbackResponse, error) {
return tollbitClient.SubmitFeedback(cmd.Context(), req, token)
})
} else {
token, tokenErr := credentials.GetAgentToken(cmd, identity)
if tokenErr != nil {
return RuntimeError(fmt.Errorf("error fetching agent token: %w", tokenErr))
}
resp, err = tollbitClient.SubmitFeedback(cmd.Context(), req, token)
}
if err != nil {
return RuntimeError(fmt.Errorf("error submitting feedback: %w", err))
}

if opts.asJSON {
return RuntimeError(writeJSON(cmd.OutOrStdout(), resp))
}
if resp.Accepted {
fmt.Fprintln(cmd.OutOrStdout(), "Feedback accepted.")
} else {
fmt.Fprintln(cmd.OutOrStdout(), "Feedback was not accepted.")
}
return nil
}

func parseMetadataFlags(values []string) (map[string]string, error) {
if len(values) == 0 {
return nil, nil
}
out := make(map[string]string, len(values))
for _, raw := range values {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
key, value, ok := strings.Cut(raw, "=")
key = strings.TrimSpace(key)
if !ok || key == "" {
return nil, UsageError("feedback --metadata must be key=value, got %q", raw)
}
out[key] = strings.TrimSpace(value)
}
if len(out) == 0 {
return nil, nil
}
return out, nil
}
126 changes: 126 additions & 0 deletions internal/cli/feedback_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package cli

import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/tollbit/cli/internal/client/tollbit"
)

func TestParseMetadataFlags(t *testing.T) {
meta, err := parseMetadataFlags([]string{"source=cli", " feature = search ", "empty="})
if err != nil {
t.Fatal(err)
}
if meta["source"] != "cli" || meta["feature"] != "search" || meta["empty"] != "" {
t.Fatalf("unexpected metadata: %#v", meta)
}

_, err = parseMetadataFlags([]string{"no-equals"})
if err == nil || !strings.Contains(err.Error(), "key=value") {
t.Fatalf("expected key=value usage error, got %v", err)
}
}

func TestRunFeedbackAccepted(t *testing.T) {
token := testAgentJWT(t)
authSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.RequestURI() != "/agent/v1/tokens/identity" {
t.Fatalf("unexpected auth request: %s %s", r.Method, r.URL.RequestURI())
}
_ = json.NewEncoder(w).Encode(map[string]string{"token": token})
}))
defer authSrv.Close()

gatewaySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/agents/v1/feedback" {
t.Fatalf("unexpected gateway request: %s %s", r.Method, r.URL.String())
}
if r.Header.Get("Authorization") != "Bearer "+token {
t.Fatalf("unexpected authorization header: %q", r.Header.Get("Authorization"))
}
var body tollbit.SubmitFeedbackRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body.Message != "CLI smoke" {
t.Fatalf("unexpected message: %q", body.Message)
}
if body.Rating == nil || *body.Rating != 5 {
t.Fatalf("unexpected rating: %#v", body.Rating)
}
if body.Category != "deploy" {
t.Fatalf("unexpected category: %q", body.Category)
}
if body.Metadata["source"] != "cli-test" {
t.Fatalf("unexpected metadata: %#v", body.Metadata)
}
w.WriteHeader(http.StatusAccepted)
_ = json.NewEncoder(w).Encode(tollbit.SubmitFeedbackResponse{Accepted: true})
}))
defer gatewaySrv.Close()

t.Setenv(testAuthBaseURLEnvVar, authSrv.URL)
t.Setenv(testGatewayBaseURLEnvVar, gatewaySrv.URL)
t.Setenv(testCredentialsStorageDirEnvVar, t.TempDir())
var stdout, stderr bytes.Buffer
code := executeTestCommand([]string{
"feedback", "CLI smoke",
"--rating", "5",
"--category", "deploy",
"--metadata", "source=cli-test",
}, nil, &stdout, &stderr)
if code != 0 {
t.Fatalf("expected exit code 0, got %d stderr=%q", code, stderr.String())
}
if want := "Feedback accepted."; !strings.Contains(stdout.String(), want) {
t.Fatalf("expected stdout to contain %q, got %q", want, stdout.String())
}
}

func TestRunFeedbackJSON(t *testing.T) {
token := testAgentJWT(t)
authSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]string{"token": token})
}))
defer authSrv.Close()

gatewaySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusAccepted)
_ = json.NewEncoder(w).Encode(tollbit.SubmitFeedbackResponse{Accepted: true})
}))
defer gatewaySrv.Close()

t.Setenv(testAuthBaseURLEnvVar, authSrv.URL)
t.Setenv(testGatewayBaseURLEnvVar, gatewaySrv.URL)
t.Setenv(testCredentialsStorageDirEnvVar, t.TempDir())
var stdout, stderr bytes.Buffer
code := executeTestCommand([]string{"feedback", "json please", "--json"}, nil, &stdout, &stderr)
if code != 0 {
t.Fatalf("expected exit code 0, got %d stderr=%q", code, stderr.String())
}
var resp tollbit.SubmitFeedbackResponse
if err := json.NewDecoder(&stdout).Decode(&resp); err != nil {
t.Fatal(err)
}
if !resp.Accepted {
t.Fatal("expected accepted true")
}
}

func TestRunFeedbackUsageErrors(t *testing.T) {
var stdout, stderr bytes.Buffer
code := executeTestCommand([]string{"feedback"}, nil, &stdout, &stderr)
if code != 2 {
t.Fatalf("expected usage exit 2, got %d stderr=%q", code, stderr.String())
}

code = executeTestCommand([]string{"feedback", "hi", "--rating", "9"}, nil, &stdout, &stderr)
if code != 2 {
t.Fatalf("expected usage exit 2 for bad rating, got %d stderr=%q", code, stderr.String())
}
}
1 change: 1 addition & 0 deletions internal/cli/index.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ func NewCommandTree(factory app.Factory) *cobra.Command {
rootCmd.AddCommand(
NewAuthCommand(factory),
NewContentCommand(factory),
NewFeedbackCommand(factory),
NewSearchCommand(factory),
NewRuntimeCommand(factory),
NewGuideCommand(factory),
Expand Down
35 changes: 35 additions & 0 deletions internal/client/tollbit/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ type (
BatchGetRates(ctx context.Context, urls []string, token agent.Token) ([]BatchRateResponseV2, error)
CreateContentAccessToken(ctx context.Context, req CreateContentAccessTokenRequest, token agent.Token) (CreateContentAccessTokenResponse, error)
GetContent(ctx context.Context, articleURL, contentToken, userAgent string, token agent.Token) (GetContentResponse, error)
SubmitFeedback(ctx context.Context, req SubmitFeedbackRequest, token agent.Token) (SubmitFeedbackResponse, error)
}

client struct {
Expand Down Expand Up @@ -139,6 +140,17 @@ type (
License BatchRateLicenseResponse `json:"license"`
}

SubmitFeedbackRequest struct {
Message string `json:"message"`
Rating *int `json:"rating,omitempty"`
Category string `json:"category,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}

SubmitFeedbackResponse struct {
Accepted bool `json:"accepted"`
}

requestOption func(*http.Request)
)

Expand Down Expand Up @@ -247,6 +259,29 @@ func (c *client) GetContent(ctx context.Context, articleURL, contentToken, userA
)
}

func (c *client) SubmitFeedback(ctx context.Context, req SubmitFeedbackRequest, token agent.Token) (SubmitFeedbackResponse, error) {
if err := requireAgentToken(token); err != nil {
return SubmitFeedbackResponse{}, err
}
message := strings.TrimSpace(req.Message)
if message == "" {
return SubmitFeedbackResponse{}, errors.New("feedback message is required")
}
req.Message = message
if cat := strings.TrimSpace(req.Category); cat != "" {
req.Category = cat
} else {
req.Category = ""
}
if req.Rating != nil && (*req.Rating < 1 || *req.Rating > 5) {
return SubmitFeedbackResponse{}, errors.New("rating must be between 1 and 5")
}

u := c.resolve("/agents/v1/feedback")
var out SubmitFeedbackResponse
return out, c.doJSON(ctx, http.MethodPost, u.String(), req, &out, withBearerToken(token.RawToken))
}

func contentResourcePath(articleURL string) (string, error) {
parsed, err := url.Parse(strings.TrimSpace(articleURL))
if err != nil {
Expand Down
Loading