diff --git a/cli/get.go b/cli/get.go new file mode 100644 index 00000000..4d6db602 --- /dev/null +++ b/cli/get.go @@ -0,0 +1,257 @@ +package cli + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + + "github.com/spf13/cobra" +) + +// rawRequest performs an HTTP request against the space and returns the raw body and status. +// Unlike apiGet/apiPost, it does not interpret the body or fail on non-2xx — the caller decides. +func (c *SpaceConnection) rawRequest(method, path string, body io.Reader) ([]byte, int, error) { + headers, err := c.buildHeaders() + if err != nil { + return nil, 0, err + } + req, err := http.NewRequest(method, c.baseURL+path, body) + if err != nil { + return nil, 0, err + } + req.Header = headers + resp, err := c.client().Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + if resp.StatusCode == 401 || (resp.StatusCode >= 300 && resp.StatusCode < 400 && resp.StatusCode != 304) { + // Don't surface auth-required as a "raw" response; emit a clear error. + return nil, resp.StatusCode, fmt.Errorf("authentication required; use --token, or configure a space with 'space add'") + } + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, resp.StatusCode, err + } + return respBody, resp.StatusCode, nil +} + +func GetCommand() *cobra.Command { + var ( + selector []string + where []string + sortBy []string + limit int + offset int + selects string + verbose bool + ) + cmd := &cobra.Command{ + Use: "get [tag] [ref]", + Short: "List indexed tags, list objects of a tag, or fetch one object (kubectl-style)", + Long: `Retrieve indexed objects. + + sb get # list all used tag names + sb get # list all objects with this tag (paged, filtered) + sb get # fetch one object by its ref + +OBJECTS AND TAGS + +Each object has a "ref" field that uniquely identifies it within its tag. +For pages, the ref is the page name; for many others, it's typically "PageName@PositionIndex". + +Run 'sb describe ' to see the field schema for a tag (when defined). + +FILTERING (--where, -l/--selector) + + -l, --selector field=value[,field=value] + Comma-separated equality selectors. Quickest path for AND-of-equalities. + + --where (repeatable) + Full operator support. Syntax: 'field=value' (equality) or 'field:OP=value'. + Multiple --where flags are AND-ed together. + + Operators: + eq equal to --where done=false + ne not equal --where status:ne=archived + gt numeric/string greater than --where priority:gt=2 + gte greater than or equal --where due:gte=2026-01-01 + lt less than --where lineCount:lt=100 + lte less than or equal --where due:lte=2026-06-01 + in value in comma-separated list --where status:in=open,pending + contains string contains substring --where name:contains=meeting + startsWith string starts with prefix --where name:startsWith=2026- + + Field paths use dotted notation for nested fields: + --where meta.author=alice + +VALUE TYPING + +Values are auto-typed: + 42, -3.14 -> number + true, false -> boolean + null -> nil (matches missing or explicit-null fields) + anything else -> string + +Force a type with a prefix: + --where zipCode=str:01234 # treat as string, not number + --where count:gt=num:10 # explicit number + --where active=bool:true # explicit boolean + +SORTING, PAGING, PROJECTION + + --sort-by field[:desc] (repeatable) — multi-key sort + --limit N default 100, max 1000 + --offset N pagination offset + --select f1,f2,... project only these fields per result + +OUTPUT (global -o, --output | --text | --json) + + auto (default) Text on a TTY (table for object lists), JSON otherwise — for humans + pipes + text string-as-string; arrays of objects render as a kubectl-style table; fallback to pretty JSON + table force table rendering (up to 8 columns, 40-char cells) + json pretty-printed JSON + jsonl one JSON value per line — friendly to xargs / line-oriented tools + yaml YAML + +--text and --json are shortcuts for -o text / -o json. + +VERBOSE (-v) + + Adds the synthesized Lua query as an X-Equivalent-Lua response header + and includes it in stderr. Useful for understanding what the server ran. + +EXIT CODES + + 0 success + 1 transport / connection error + 2 API error (non-2xx other than 404) + 3 not found (404) — when fetching a single object by ref + +ALSO + + sb describe show the field schema for a tag + sb query '' full Lua collection query (for things REST can't express)`, + Example: ` # List all known tag names + sb get + + # List all tasks + sb get task + + # Unfinished tasks, highest priority first, top 20 + sb get task -l done=false --sort-by priority:desc --limit 20 +`, + Args: cobra.RangeArgs(0, 2), + RunE: func(cmd *cobra.Command, args []string) error { + conn, err := connFromFlags(cmd) + if err != nil { + return err + } + // No args: list known tag names + if len(args) == 0 { + body, status, err := conn.rawRequest(http.MethodGet, "/.runtime/objects", nil) + if err != nil { + return err + } + if status != 200 { + return apiError(status, body) + } + return FormatOutputBytes(os.Stdout, body, OutputModeFromCmd(cmd)) + } + tag := args[0] + // Single-object get + if len(args) == 2 { + p := "/.runtime/objects/" + url.PathEscape(tag) + "/" + url.PathEscape(args[1]) + body, status, err := conn.rawRequest(http.MethodGet, p, nil) + if err != nil { + return err + } + if status == 404 { + fmt.Fprintln(os.Stderr, "Not found") + os.Exit(3) + } + if status != 200 { + return apiError(status, body) + } + return FormatOutputBytes(os.Stdout, body, OutputModeFromCmd(cmd)) + } + + // List + v := url.Values{} + for _, s := range selector { + for _, kv := range strings.Split(s, ",") { + eq := strings.SplitN(kv, "=", 2) + if len(eq) != 2 { + return fmt.Errorf("invalid -l value: %q (want field=value)", kv) + } + v.Add("where["+eq[0]+"]", eq[1]) + } + } + for _, w := range where { + eq := strings.SplitN(w, "=", 2) + if len(eq) != 2 { + return fmt.Errorf("invalid --where: %q (want field=val or field:op=val)", w) + } + key, val := eq[0], eq[1] + if i := strings.IndexByte(key, ':'); i > 0 { + field, op := key[:i], key[i+1:] + v.Add("where["+field+"]["+op+"]", val) + } else { + v.Add("where["+key+"]", val) + } + } + for _, s := range sortBy { + v.Add("order", s) + } + if limit > 0 { + v.Set("limit", fmt.Sprintf("%d", limit)) + } + if offset > 0 { + v.Set("offset", fmt.Sprintf("%d", offset)) + } + if selects != "" { + v.Set("select", selects) + } + if verbose { + v.Set("debug", "1") + } + p := "/.runtime/objects/" + url.PathEscape(tag) + if encoded := v.Encode(); encoded != "" { + p += "?" + encoded + } + body, status, err := conn.rawRequest(http.MethodGet, p, nil) + if err != nil { + return err + } + if status != 200 { + return apiError(status, body) + } + return FormatOutputBytes(os.Stdout, body, OutputModeFromCmd(cmd)) + }, + } + cmd.Flags().StringSliceVarP(&selector, "selector", "l", nil, "Equality selectors: field=val,field2=val2") + cmd.Flags().StringSliceVar(&where, "where", nil, "Filter clause: field=val or field:op=val (repeatable)") + cmd.Flags().StringSliceVar(&sortBy, "sort-by", nil, "Sort key: field or field:desc (repeatable)") + cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of results") + cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset") + cmd.Flags().StringVar(&selects, "select", "", "Comma-separated projection fields") + cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Include equivalent Lua in debug output") + return cmd +} + +func apiError(status int, body []byte) error { + var apiErr struct { + Error string `json:"error"` + Code string `json:"code"` + } + _ = json.Unmarshal(body, &apiErr) + msg := apiErr.Error + if msg == "" { + msg = string(body) + } + return fmt.Errorf("API error (HTTP %d, code=%s): %s", status, apiErr.Code, msg) +} diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 6bf0ae71..27b33b74 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -25,6 +25,7 @@ func main() { cli.ScriptCommand(), cli.LuaScriptCommand(), // hidden alias cli.QueryCommand(), + cli.GetCommand(), cli.DescribeCommand(), cli.LogsCommand(), cli.ScreenshotCommand(),