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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,25 @@ ana auth login --endpoint https://app.textql.com
ana org show
ana connector list
ana chat send "show me last month's revenue"
ana api textql.rpc.public.auth.PublicAuthService/GetOrganization # raw JSON passthrough
ana update # replace the running binary with the latest release
```

### `ana api` — raw authenticated passthrough

`ana api <path>` sends an authenticated HTTP request and prints the response.
Two path forms:

- `<service>/<Method>` — Connect-RPC short form, prefixed with `/rpc/public/`.
- `/v1/...` (or any leading-slash path) — sent verbatim. Covers both the
documented REST API (`docs.textql.com/api-reference`) and pre-resolved RPC
paths.

Body can be supplied with `--data '<json>'` or `--data-stdin`. Default method
is `POST` with a `{}` body (so short-form RPC calls Just Work); GET/HEAD
auto-omit the body. `--raw` passes the response through verbatim instead of
pretty-printing.

Run `ana --help` or `ana <verb> --help` for command-specific flags.

`ana` checks GitHub for a newer release after each verb and prints a one-line
Expand Down
2 changes: 2 additions & 0 deletions cmd/ana/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"os/signal"
"time"

"github.com/highperformance-tech/ana-cli/internal/api"
"github.com/highperformance-tech/ana-cli/internal/audit"
"github.com/highperformance-tech/ana-cli/internal/auth"
"github.com/highperformance-tech/ana-cli/internal/chat"
Expand Down Expand Up @@ -208,6 +209,7 @@ func drainNudge(ch chan string, timeout time.Duration, verbErr error, stderr io.
// self-hosted and non-prod profiles at the right place.
func buildVerbs(client *transport.Client, env func(string) string, cfgPath, profileName, endpoint string) map[string]cli.Command {
return map[string]cli.Command{
"api": api.New(api.Deps{DoRaw: client.DoRaw}),
"auth": auth.New(authDeps(client, env, cfgPath, profileName)),
"profile": profile.New(profileDeps(env, cfgPath)),
"org": org.New(org.Deps{Unary: client.Unary}),
Expand Down
2 changes: 1 addition & 1 deletion cmd/ana/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ func TestBuildVerbs_Shape(t *testing.T) {
t.Parallel()
client := transport.New("https://example", func(context.Context) (string, error) { return "", nil })
verbs := buildVerbs(client, func(string) string { return "" }, "", "default", "https://example")
want := []string{"auth", "profile", "org", "connector", "chat", "dashboard", "playbook", "ontology", "feed", "audit", "version", "update"}
want := []string{"api", "auth", "profile", "org", "connector", "chat", "dashboard", "playbook", "ontology", "feed", "audit", "version", "update"}
for _, v := range want {
if _, ok := verbs[v]; !ok {
t.Errorf("missing verb: %q", v)
Expand Down
1 change: 1 addition & 0 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Per-endpoint request/response schemas live in `api-catalog/` (~95 endpoints as o
## API shape (global)

- **Style:** Connect-RPC (buf-connect). All calls are `POST https://app.textql.com/rpc/public/<fully.qualified.Service>/<Method>`.
- **CLI raw access:** `ana api <path>` is the untyped escape hatch. Short form (`<service>/<Method>`) maps to Connect-RPC; a leading-slash path (`/v1/...`) hits the documented REST API at `docs.textql.com/api-reference`. Both share host + bearer auth, so one verb covers both surfaces.
- **Content-Type:** `application/json` request + response.
- **Field casing:** protobuf JSON — **camelCase only**. Sending both `chatId` and `chat_id` → 400 `"duplicate field"`. CLI must emit camelCase.
- **Error shape:** `{"code": "<lowercase_code>", "message": "<text>"}` (e.g. `invalid_argument`, `not_found`, `internal`).
Expand Down
5 changes: 3 additions & 2 deletions internal/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# internal

All domain logic for the `ana` CLI. Each verb package is pure dispatch: it declares a narrow `Deps` struct, registers its Connect-RPC service prefix, and exposes a `New(deps) *cli.Group` that `cmd/ana/main.go` wires up. Verb packages do not import `internal/transport` or `internal/config` (except `cli`, which is the dispatch core, and `profile`, whose whole purpose is config management).
All domain logic for the `ana` CLI. Each verb package is pure dispatch: it declares a narrow `Deps` struct, registers its Connect-RPC service prefix, and exposes a `New(deps) *cli.Group` (or a `cli.Command` leaf when there are no subcommands, as in `api/`) that `cmd/ana/main.go` wires up. Verb packages do not import `internal/transport` or `internal/config` (except `cli`, which is the dispatch core, and `profile`, whose whole purpose is config management).

## Test layout convention

Expand All @@ -13,7 +13,8 @@ Multi-file verb packages use one `<source>_test.go` per source file (e.g. `list.
| `cli/` | Dispatch core: `Command` interface, `Group`, `ParseFlags`, `ParseGlobal`, `Dispatch`, exit-code mapping. |
| `testcli/` | Test helpers for verb packages (stdlib `httptest` analogue): `FailingWriter`, `FailingIO`, `NewIO`. |
| `config/` | Multi-profile config file reader/writer. XDG path resolution, `Resolve` precedence. |
| `transport/` | Connect-RPC HTTP client. Unary JSON + server-streaming JSON framing. |
| `transport/` | Connect-RPC HTTP client. Unary JSON + server-streaming JSON framing + `DoRaw` passthrough. Bearer + User-Agent applied via RoundTripper middleware. |
| `api/` | `ana api <path>` — raw authenticated HTTP passthrough for Connect-RPC short form + documented REST. Single leaf. |
| `auth/` | `ana auth` verb tree — login/logout/whoami/keys/service-accounts. |
| `profile/` | `ana profile` verb tree — add/use/remove/list/show. Imports `internal/config` by design. |
| `org/` | `ana org` — list/show + nested members/roles/permissions. |
Expand Down
10 changes: 10 additions & 0 deletions internal/api/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# internal/api

The `ana api <path>` verb — authenticated raw-JSON passthrough over the shared transport client. Single leaf, no subcommands. Covers both Connect-RPC (`textql.rpc.public.<Service>/<Method>` or `/rpc/public/...`) and the documented REST API (`/v1/...`) — one verb, two surfaces, distinguished by leading slash.

## Files

- `api.go` — `Deps` (single `DoRaw` function field), `New` (returns a leaf `cli.Command`, not a `*cli.Group` — no subcommands), and the `/rpc/public/` prefix constant.
- `call.go` — the leaf: flag parsing, path dispatch (leading slash → verbatim; else prefix-prepend), body resolution (`--data` / `--data-stdin` / default `{}` for POST, `nil` for GET/HEAD), and the `emitError`/`emitSuccess` split. Non-2xx writes the server body to stderr and returns an `api: HTTP <status>` summary error; 2xx writes pretty JSON to stdout (fallthrough to raw if the body isn't valid JSON; `--raw` skips pretty-print entirely).
- `api_test.go` — shared `fakeDeps` + `TestNew*`/`TestHelp*`.
- `call_test.go` — per-source test file covering both path forms, every body-resolution branch, mutual-exclusion, non-2xx stderr + trailing-newline branches, and the raw/pretty/non-JSON 2xx paths.
37 changes: 37 additions & 0 deletions internal/api/api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Package api provides the `ana api` verb — an authenticated raw-JSON
// passthrough over the shared transport client. Dispatches to a single leaf.
// Two path forms:
//
// - Leading slash → sent verbatim (REST e.g. `/v1/...`, or a pre-resolved
// RPC path e.g. `/rpc/public/<service>/<Method>`).
// - No leading slash → treated as a fully-qualified Connect-RPC short form
// (e.g. `textql.rpc.public.auth.PublicAuthService/GetOrganization`) and
// prefixed with `/rpc/public/`.
//
// Like every other verb package, api never imports internal/transport — the
// caller adapts its transport client to the narrow Deps.DoRaw field.
package api

import (
"context"

"github.com/highperformance-tech/ana-cli/internal/cli"
)

// connectRPCPrefix is the path prefix applied to Connect-RPC short-form paths
// (those without a leading slash). Matches what every typed verb hard-codes.
const connectRPCPrefix = "/rpc/public/"

// Deps is the injection boundary. A real wiring layer adapts
// transport.Client.DoRaw; tests pass fakes that record (method, path, body)
// so assertions can inspect the outbound request and the returned response.
type Deps struct {
DoRaw func(ctx context.Context, method, path string, body []byte) (int, []byte, error)
}

// New returns the `api` verb as a single leaf command. Unlike other verb
// packages this is not a *cli.Group — there are no subcommands, just a path
// positional.
func New(deps Deps) cli.Command {
return &callCmd{deps: deps}
}
77 changes: 77 additions & 0 deletions internal/api/api_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package api

import (
"context"
"strings"
"testing"

"github.com/highperformance-tech/ana-cli/internal/cli"
)

// --- fakes and helpers ---

// fakeDeps is the package-wide fake for Deps. DoRaw delegates to doRawFn if
// set; either way it records (method, path, body) for post-call assertions.
type fakeDeps struct {
doRawFn func(ctx context.Context, method, path string, body []byte) (int, []byte, error)
lastMethod string
lastPath string
lastBody []byte
}

func (f *fakeDeps) deps() Deps {
return Deps{
DoRaw: func(ctx context.Context, method, path string, body []byte) (int, []byte, error) {
f.lastMethod = method
f.lastPath = path
// Copy so callers that reuse the slice don't mutate recorded state.
if body != nil {
f.lastBody = append(f.lastBody[:0], body...)
} else {
f.lastBody = nil
}
if f.doRawFn != nil {
return f.doRawFn(ctx, method, path, body)
}
return 200, []byte(`{"ok":true}`), nil
},
}
}

// --- New / leaf surface ---

func TestNewReturnsLeaf(t *testing.T) {
t.Parallel()
f := &fakeDeps{}
cmd := New(f.deps())
if cmd == nil {
t.Fatalf("New returned nil")
}
// Must NOT be a *cli.Group — api is a single-leaf verb.
if _, isGroup := cmd.(*cli.Group); isGroup {
t.Fatalf("api.New returned *cli.Group; want a leaf Command")
}
if _, isFlagger := cmd.(cli.Flagger); !isFlagger {
t.Fatalf("api leaf should implement cli.Flagger so --help can render a Flags block")
}
}

// --- Help() ---

func TestHelpContainsBothPathForms(t *testing.T) {
t.Parallel()
f := &fakeDeps{}
cmd := New(f.deps())
h := cmd.Help()
for _, want := range []string{
"Usage",
"<service>/<Method>",
"/rpc/public/",
"/v1/",
"--raw",
} {
if !strings.Contains(h, want) {
t.Errorf("help missing %q in:\n%s", want, h)
}
}
}
176 changes: 176 additions & 0 deletions internal/api/call.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package api

import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"io"
"strings"

"github.com/highperformance-tech/ana-cli/internal/cli"
)

// callCmd implements the `ana api <path>` leaf. Flags:
//
// --method HTTP verb (default POST; any non-empty string accepted).
// --data literal JSON request body.
// --data-stdin read the request body from stdin (mutually exclusive with --data).
// --raw emit the response body verbatim (skip json.Indent).
//
// The global --json flag is a no-op here — the default output IS pretty JSON;
// --raw is the opposite. Documented in Help().
type callCmd struct {
deps Deps

method string
data string
dataStdin bool
raw bool
}

func (c *callCmd) Help() string {
return "api Authenticated HTTP passthrough. JSON pretty-printed by default; --raw for verbatim bytes.\n" +
"Usage: ana api <path> [--method M] [--data JSON | --data-stdin] [--raw]\n" +
"\n" +
"Paths:\n" +
" <service>/<Method> Connect-RPC short form; prefixed with /rpc/public/\n" +
" e.g. textql.rpc.public.auth.PublicAuthService/GetOrganization\n" +
" /rpc/public/<...> Connect-RPC full path; sent as-is\n" +
" /v1/<...> Documented REST API path (docs.textql.com/api-reference)\n" +
"\n" +
"Note: the global --json flag is ignored here; output is JSON by default."
}

// Flags declares this leaf's flags. Implementing cli.Flagger lets dispatchChild
// render a `Flags:` block under --help so the four knobs are discoverable.
func (c *callCmd) Flags(fs *flag.FlagSet) {
fs.StringVar(&c.method, "method", "POST", "HTTP method (default POST)")
fs.StringVar(&c.data, "data", "", "literal JSON request body")
fs.BoolVar(&c.dataStdin, "data-stdin", false, "read the request body from stdin")
fs.BoolVar(&c.raw, "raw", false, "pass the response body through verbatim (skip pretty-print)")
}

func (c *callCmd) Run(ctx context.Context, args []string, stdio cli.IO) error {
fs := cli.NewFlagSet("api")
c.Flags(fs)
cli.ApplyAncestorFlags(ctx, fs)
if err := cli.ParseFlags(fs, args); err != nil {
return err
}

if fs.NArg() == 0 {
return cli.UsageErrf("api: <path> positional argument required")
}
if fs.NArg() > 1 {
return cli.UsageErrf("api: unexpected positional arguments: %v", fs.Args()[1:])
}
// Trim once and reuse — otherwise a whitespace-padded arg like
// `" /v1/things "` passes the blank check but gets forwarded to the
// transport verbatim, which joinURL would then stitch into a malformed URL.
path := strings.TrimSpace(fs.Arg(0))
if path == "" {
return cli.UsageErrf("api: <path> positional argument required")
}

if c.method == "" {
return cli.UsageErrf("api: --method must not be empty")
}
dataSet := cli.FlagWasSet(fs, "data")
if dataSet && c.dataStdin {
return cli.UsageErrf("api: --data and --data-stdin are mutually exclusive")
}

// Path dispatch: leading slash → verbatim; otherwise Connect-RPC short form.
resolvedPath := path
if !strings.HasPrefix(path, "/") {
resolvedPath = connectRPCPrefix + path
}

body, err := resolveBody(c, dataSet, stdio.Stdin)
if err != nil {
return err
}

status, respBody, err := c.deps.DoRaw(ctx, c.method, resolvedPath, body)
if err != nil {
return fmt.Errorf("api: %w", err)
}

if status < 200 || status >= 300 {
return c.emitError(stdio, status, respBody)
}
return c.emitSuccess(stdio, respBody)
}

// resolveBody picks the outbound body bytes. Precedence (after the
// --data/--data-stdin mutual-exclusion check in the caller):
//
// - --data-stdin: io.ReadAll so the bytes round-trip exactly (ReadToken
// would trim whitespace, which matters for binary-ish JSON payloads).
// - --data set (even to ""): use the literal bytes.
// - neither: nil for GET/HEAD (no body), `{}` otherwise so Connect-RPC's
// required-body contract is still satisfied.
func resolveBody(c *callCmd, dataSet bool, stdin io.Reader) ([]byte, error) {
switch {
case c.dataStdin:
b, err := io.ReadAll(stdin)
if err != nil {
return nil, fmt.Errorf("api: read stdin: %w", err)
}
return b, nil
case dataSet:
return []byte(c.data), nil
}
if strings.EqualFold(c.method, "GET") || strings.EqualFold(c.method, "HEAD") {
return nil, nil
}
return []byte("{}"), nil
}

// emitError writes the server's error body to stderr (so stdout stays empty
// for `| jq`) and returns a summary error. Main's fallback printer adds the
// `api: HTTP <status>` line on its own stderr write — body + status together.
// A trailing newline is appended if the body didn't already end with one so
// the status line doesn't get glued to the last byte of the response.
func (c *callCmd) emitError(stdio cli.IO, status int, body []byte) error {
if len(body) > 0 {
if _, werr := stdio.Stderr.Write(body); werr != nil {
return fmt.Errorf("api: %w", werr)
}
if !bytes.HasSuffix(body, []byte("\n")) {
if _, werr := fmt.Fprintln(stdio.Stderr); werr != nil {
return fmt.Errorf("api: %w", werr)
}
}
}
return fmt.Errorf("api: HTTP %d", status)
}

// emitSuccess writes the 2xx body. With --raw (or when the body is empty)
// the bytes are passed through verbatim; otherwise json.Indent pretty-prints.
// A body that isn't valid JSON (e.g. 204 empty, or some future text endpoint)
// falls through to the raw path so we don't fail an otherwise-successful call.
func (c *callCmd) emitSuccess(stdio cli.IO, body []byte) error {
if c.raw || len(body) == 0 {
if _, werr := stdio.Stdout.Write(body); werr != nil {
return fmt.Errorf("api: %w", werr)
}
return nil
}
var pretty bytes.Buffer
if err := json.Indent(&pretty, body, "", " "); err != nil {
if _, werr := stdio.Stdout.Write(body); werr != nil {
return fmt.Errorf("api: %w", werr)
}
return nil
}
if _, werr := stdio.Stdout.Write(pretty.Bytes()); werr != nil {
return fmt.Errorf("api: %w", werr)
}
if _, werr := stdio.Stdout.Write([]byte("\n")); werr != nil {
return fmt.Errorf("api: %w", werr)
}
return nil
}
Loading
Loading