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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ internal-docs/
.claude/settings.local.md
.claude/
.cursor/
devhome/
2 changes: 2 additions & 0 deletions modules/gitops/gitops_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ func (noopResolver) ResolveBodyFn(id string) cmdctx.CreateBodyFn
func (noopResolver) ResolveQueryParamsFn(id string) cmdctx.QueryParamsFn { return nil }
func (noopResolver) ResolveFlagResolveFn(id string) cmdctx.FlagResolveFn { return nil }
func (noopResolver) ResolveFetchFn(id string) (cmdctx.FetchFn, error) { return nil, nil }
func (noopResolver) ResolveListTransformFn(id string) cmdctx.ListTransformFn { return nil }
func (noopResolver) ResolveEndpointValidator(id string) cmdctx.EndpointValidatorFn { return nil }
func (noopResolver) GetSpec(verb, noun string) *spec.CommandSpec { return nil }
func (noopResolver) GetNoun(noun string) *spec.NounDef { return nil }
Expand Down Expand Up @@ -376,6 +377,7 @@ func (s *moduleInitSpy) RegisterBodyFn(string, cmdctx.CreateBodyFn)
func (s *moduleInitSpy) RegisterQueryParamsFn(string, cmdctx.QueryParamsFn) {}
func (s *moduleInitSpy) RegisterFollowFn(string, cmdctx.FollowFn) {}
func (s *moduleInitSpy) RegisterFetchFn(string, cmdctx.FetchFn) {}
func (s *moduleInitSpy) RegisterListTransformFn(string, cmdctx.ListTransformFn) {}
func (s *moduleInitSpy) RegisterFlagCompletionFn(string, registry.FlagCompletionFn) {}
func (s *moduleInitSpy) RegisterFlagResolveFn(string, cmdctx.FlagResolveFn) {}
func (s *moduleInitSpy) RegisterEndpointValidatorFn(string, cmdctx.EndpointValidatorFn) {}
16 changes: 16 additions & 0 deletions pkg/cmdctx/cmdctx.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ type PageResult struct {
// at once — the driver's slice math handles the window.
type FetchFn func(ctx *Ctx, ep *spec.EndpointSpec, wantStart, wantCount int, cursor any) (*PageResult, error)

// ListTransformFn converts a get/execute response into list-rendering inputs:
// the row slice, the columns available on those rows, and optional paging summary info.
type ListTransformFn func(ctx *Ctx, data any) (items []any, fields []spec.FieldDef, meta PageMeta, err error)

// RawBody signals that the body should be sent as-is with the given ContentType,
// bypassing JSON encoding. Return this from a CreateBodyFn when the API expects
// a raw non-JSON body (e.g. application/yaml).
Expand All @@ -110,6 +114,7 @@ type Resolver interface {
ResolveQueryParamsFn(id string) QueryParamsFn
ResolveFlagResolveFn(id string) FlagResolveFn
ResolveFetchFn(id string) (FetchFn, error)
ResolveListTransformFn(id string) ListTransformFn
ResolveEndpointValidator(id string) EndpointValidatorFn
GetSpec(verb, noun string) *spec.CommandSpec
GetNoun(noun string) *spec.NounDef
Expand Down Expand Up @@ -164,6 +169,17 @@ type PagingFlags struct {
// GlobalFlags is reserved for future non-formatting global flags.
type GlobalFlags struct{}

// PageMeta carries optional paging summary information and a notice for display after a table.
// Offset is the item-level offset of the first item returned. Count is the number
// of items actually returned. HasTotal indicates whether Total is valid.
type PageMeta struct {
Offset int
Count int
HasTotal bool
Total int64
Notice string
}

// Ctx is passed to every workflow handler, providing resolved auth and the parsed command identity.
// Auth is nil for management commands (version, etc.) that do not require credentials.
// When Auth is non-nil, OrgID and ProjectID already reflect any --org/--project overrides.
Expand Down
11 changes: 5 additions & 6 deletions pkg/endpoint/pagingdriver.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"fmt"

"github.com/harness/cli/pkg/cmdctx"
"github.com/harness/cli/pkg/format"
"github.com/harness/cli/pkg/hlog"
"github.com/harness/cli/pkg/spec"
)
Expand All @@ -17,7 +16,7 @@ const defaultPageSize = 20

// FetchItems normalizes PagingFlags into the appropriate FetchRange/FetchAll call.
// --all maps to FetchAll; otherwise FetchRange is called with the offset/limit from flags.
func FetchItems(ctx *cmdctx.Ctx, ep *spec.EndpointSpec, pf cmdctx.PagingFlags) ([]any, *format.PageMeta, error) {
func FetchItems(ctx *cmdctx.Ctx, ep *spec.EndpointSpec, pf cmdctx.PagingFlags) ([]any, *cmdctx.PageMeta, error) {
if ep.Paging == nil {
return nil, nil, fmt.Errorf("FetchItems called on endpoint with no paging spec")
}
Expand All @@ -33,13 +32,13 @@ func FetchItems(ctx *cmdctx.Ctx, ep *spec.EndpointSpec, pf cmdctx.PagingFlags) (
// FetchRange fetches items [wantStart, wantStart+wantCount) using the FetchFn
// resolved from ep. It is strategy-blind: all paging knowledge lives in the FetchFn.
// If wantCount is 0 it defaults to defaultPageSize.
func FetchRange(ctx *cmdctx.Ctx, ep *spec.EndpointSpec, wantStart, wantCount int) ([]any, *format.PageMeta, error) {
func FetchRange(ctx *cmdctx.Ctx, ep *spec.EndpointSpec, wantStart, wantCount int) ([]any, *cmdctx.PageMeta, error) {
return fetchRange(ctx, ep, wantStart, wantCount, nil)
}

// fetchRange is the implementation behind FetchRange/FetchAll, with an optional
// per-page progress callback.
func fetchRange(ctx *cmdctx.Ctx, ep *spec.EndpointSpec, wantStart, wantCount int, onPage func(int, int64, bool)) ([]any, *format.PageMeta, error) {
func fetchRange(ctx *cmdctx.Ctx, ep *spec.EndpointSpec, wantStart, wantCount int, onPage func(int, int64, bool)) ([]any, *cmdctx.PageMeta, error) {
fn, err := ResolveFetchFn(ctx, ep)
if err != nil {
return nil, nil, err
Expand All @@ -51,7 +50,7 @@ func fetchRange(ctx *cmdctx.Ctx, ep *spec.EndpointSpec, wantStart, wantCount int
wantEnd := wantStart + wantCount

var out []any
meta := &format.PageMeta{Offset: wantStart}
meta := &cmdctx.PageMeta{Offset: wantStart}

var cursor any
pos := wantStart
Expand Down Expand Up @@ -101,7 +100,7 @@ func fetchRange(ctx *cmdctx.Ctx, ep *spec.EndpointSpec, wantStart, wantCount int
}

// FetchAll fetches every available item from offset 0.
func FetchAll(ctx *cmdctx.Ctx, ep *spec.EndpointSpec) ([]any, *format.PageMeta, error) {
func FetchAll(ctx *cmdctx.Ctx, ep *spec.EndpointSpec) ([]any, *cmdctx.PageMeta, error) {
return FetchRange(ctx, ep, 0, maxItemsAll)
}

Expand Down
55 changes: 36 additions & 19 deletions pkg/format/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,22 +32,12 @@ var validArrayFormats = map[string]bool{
"json": true, "jsonl": true, "table": true, "csv": true, "tsv": true, "markdown": true,
}

// PageMeta carries optional paging summary information for display after a table.
// Offset is the item-level offset of the first item returned. Count is the number
// of items actually returned. HasTotal indicates whether Total is valid.
type PageMeta struct {
Offset int
Count int
HasTotal bool
Total int64
}

// FormatArrayOutput renders a list response (table, json, jsonl, csv, tsv).
// itemsExpr is an expr-lang expression that resolves the row slice; "it" is bound to the full response.
// defaultTspec is the command's declared table layout; may be nil.
// exprEnv is the base expr-lang environment (ctx, flags, auth, helpers); "it" is injected per row for columns.
// meta, when non-nil, causes a "showing X-Y of Z" footer to be printed after the table.
func FormatArrayOutput(flags cmdctx.FormatFlags, isPty bool, data any, itemsExpr string, defaultTspec *spec.TableSpec, fields []spec.FieldDef, exprEnv map[string]any, meta *PageMeta) error {
// meta, when non-nil, causes paging information and/or a notice to be printed after the table.
func FormatArrayOutput(flags cmdctx.FormatFlags, isPty bool, data any, itemsExpr string, defaultTspec *spec.TableSpec, fields []spec.FieldDef, exprEnv map[string]any, meta *cmdctx.PageMeta) error {
// 1. Resolve --columns into a tspec (overrides default).
tspec := defaultTspec
if flags.Columns != "" {
Expand All @@ -63,6 +53,10 @@ func FormatArrayOutput(flags cmdctx.FormatFlags, isPty bool, data any, itemsExpr
}

// 2. Default format: table only when attached to a terminal and we have a spec.
// requestedFormat preserves whether the user explicitly asked for json/jsonl, since
// an unset format silently becomes "json" below when there's no schema to build a
// table from — that fallback needs different handling than an explicit request.
requestedFormat := flags.Format
if flags.Format == "" {
if tspec != nil {
flags.Format = "table"
Expand All @@ -75,23 +69,40 @@ func FormatArrayOutput(flags cmdctx.FormatFlags, isPty bool, data any, itemsExpr
return fmt.Errorf("unknown format %q: must be one of json, jsonl, table, csv, tsv, markdown", flags.Format)
}

// 3. Table format requires a resolved spec.
if flags.Format == "table" && tspec == nil {
return fmt.Errorf("--format table requires a table spec or --columns")
}

if flags.Raw && flags.Format != "json" {
return fmt.Errorf("--raw is only supported with --format json")
}

itemsEnv := withIt(exprEnv, data)

// tspec nil means no columns are known at all, not just zero rows (a known schema
// with zero rows renders as a normal empty table below). table/csv/tsv/markdown all
// need column info; bail quietly if there's no data either. Explicit json/jsonl
// bypass this and dump raw data regardless of schema.
if tspec == nil && requestedFormat != "json" && requestedFormat != "jsonl" {
items, err := evalItemsExpr(itemsEnv, itemsExpr)
if err != nil {
return fmt.Errorf("items_expr %q: %w", itemsExpr, err)
}
if len(items) == 0 {
if !flags.NoHeaders {
fmt.Fprintln(os.Stderr, "No results or columns")
}
return nil
}
if requestedFormat != "" {
return fmt.Errorf("--format %s requires a table spec or --columns", flags.Format)
}
// requestedFormat was unset and there IS data despite no schema: fall back to
// dumping it as raw json, same as the default-format resolution above chose.
}

w, close, err := OpenWriter(flags.OutFile)
if err != nil {
return err
}
defer close()

itemsEnv := withIt(exprEnv, data)

if flags.Format == "jsonl" {
items, err := evalItemsExpr(itemsEnv, itemsExpr)
if err != nil {
Expand Down Expand Up @@ -130,6 +141,12 @@ func FormatArrayOutput(flags cmdctx.FormatFlags, isPty bool, data any, itemsExpr
} else if meta.HasTotal {
fmt.Fprintf(w, "No results (%d items total)\n", meta.Total)
}
if meta.Notice != "" {
if meta.Count == 0 && !meta.HasTotal {
fmt.Fprintln(w, "─────")
}
fmt.Fprintln(w, meta.Notice)
}
}
}
return nil
Expand Down
14 changes: 14 additions & 0 deletions pkg/registry/checks.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ func (r *Registry) checkFunctionsSpec(cs *spec.CommandSpec) []string {
errs = append(errs, fmt.Sprintf("command %q: query_params_fn %q not registered", cs.Command, cs.Endpoint.QueryParamsFn))
}
}
if cs.Endpoint.ListTransformFn != "" {
if _, ok := r.listTransformFns[cs.Endpoint.ListTransformFn]; !ok {
errs = append(errs, fmt.Sprintf("command %q: list_transform_fn %q not registered", cs.Command, cs.Endpoint.ListTransformFn))
}
}
}
if cs.FollowFn != "" {
if _, ok := r.followFns[cs.FollowFn]; !ok {
Expand Down Expand Up @@ -177,6 +182,15 @@ func validateEndpointConstraints(cs *spec.CommandSpec) error {
if cs.VerbHandler == VerbGet && ep.ItemExpr == "" {
return fmt.Errorf("get endpoint %q requires item_expr (use \"it\" for bare item responses)", cs.FullNoun())
}
if ep.ListTransformFn != "" && cs.VerbHandler == VerbList {
return fmt.Errorf("command %q: list_transform_fn is not allowed on list verbs (use items_expr instead)", cs.Command)
}
if ep.ListTransformFn != "" && ep.FieldExtract != "" {
return fmt.Errorf("command %q: list_transform_fn and field_extract are mutually exclusive", cs.Command)
}
if ep.ListTransformFn != "" && ep.TextFormatter != "" {
return fmt.Errorf("command %q: list_transform_fn and text_formatter are mutually exclusive", cs.Command)
}
if ep.Paging != nil {
if err := validatePaging(cs.Command, ep.Paging); err != nil {
return err
Expand Down
119 changes: 119 additions & 0 deletions pkg/registry/coretransforms.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// Copyright © 2026 Harness Inc.
// SPDX-License-Identifier: Apache-2.0

package registry

import (
"fmt"

"github.com/harness/cli/pkg/cmdctx"
"github.com/harness/cli/pkg/spec"
)

// registerCoreTransforms registers all built-in "core:*" list transform fns.
// These are available to any module via list_transform_fn: core:<name>.
func (r *Registry) registerCoreTransforms() {
r.RegisterListTransformFn("core:columns-rows", columnsRowsListTransform)
}

// columnsRowsListTransform converts a {columns, rows} query result (HQL and similar)
// into list-rendering inputs. Use with list_transform_fn: core:columns-rows.
func columnsRowsListTransform(ctx *cmdctx.Ctx, data any) ([]any, []spec.FieldDef, cmdctx.PageMeta, error) {
rows, fields, err := expandColumnsRows(data)
if err != nil {
return nil, nil, cmdctx.PageMeta{}, err
}
var meta cmdctx.PageMeta
if m, ok := data.(map[string]any); ok {
if truncated, _ := m["truncated"].(bool); truncated {
meta.Notice = "(truncated)"
}
}
return rows, fields, meta, nil
}

// expandColumnsRows converts a {columns, rows} query result into inputs for
// FormatArrayOutput / FormatList. Returns an error when data is not that shape.
//
// Expected shape (HQL executeQuery and similar):
//
// {
// "columns": [{"name": "col", "data_type": "FIELD_TYPE_STR"}, ...],
// "rows": [{"values": [v0, v1, ...]}, ...],
// "truncated": false
// }
func expandColumnsRows(data any) ([]any, []spec.FieldDef, error) {
m, ok := data.(map[string]any)
if !ok {
return nil, nil, fmt.Errorf("response is not an object, got %T", data)
}
colsRaw, hasCols := m["columns"]
rowsRaw, hasRows := m["rows"]
if !hasCols || !hasRows {
return nil, nil, fmt.Errorf("response is missing \"columns\" and/or \"rows\" fields")
}
colsSlice, ok1 := colsRaw.([]any)
rowsSlice, ok2 := rowsRaw.([]any)
if !ok1 {
return nil, nil, fmt.Errorf("response \"columns\" field is not an array, got %T", colsRaw)
}
if !ok2 {
return nil, nil, fmt.Errorf("response \"rows\" field is not an array, got %T", rowsRaw)
}

names := make([]string, 0, len(colsSlice))
fields := make([]spec.FieldDef, 0, len(colsSlice))
used := make(map[string]bool, len(colsSlice))
nextSuffix := make(map[string]int, len(colsSlice))
for i, c := range colsSlice {
base := columnName(c, i)
name := base
if used[name] {
suffix := nextSuffix[base]
if suffix < 2 {
suffix = 2
}
for {
name = fmt.Sprintf("%s_%d", base, suffix)
suffix++
if !used[name] {
break
}
}
nextSuffix[base] = suffix
}
used[name] = true
names = append(names, name)
fields = append(fields, spec.FieldDef{
ID: name,
Label: name,
Expr: fmt.Sprintf("it[%q]", name),
})
}

rows := make([]any, 0, len(rowsSlice))
for _, r := range rowsSlice {
rm, _ := r.(map[string]any)
vals, _ := rm["values"].([]any)
row := make(map[string]any, len(names))
for i, name := range names {
if i < len(vals) {
row[name] = vals[i]
}
}
rows = append(rows, row)
}
return rows, fields, nil
}

func columnName(col any, i int) string {
m, ok := col.(map[string]any)
if !ok {
return fmt.Sprintf("col_%d", i)
}
name, _ := m["name"].(string)
if name == "" {
return fmt.Sprintf("col_%d", i)
}
return name
}
Loading
Loading