From 1fd4e72f6e24bd32b089e3764a51fe6d97668c40 Mon Sep 17 00:00:00 2001 From: Vinay Srini Date: Tue, 4 Aug 2026 15:00:08 -0700 Subject: [PATCH 01/10] feat(format): add core:columns-rows formatter for HQL table output Enable --format table/csv/tsv on execute hql:run by expanding the standard {columns, rows} query result shape through FormatArrayOutput, and register a reusable core:columns-rows text formatter for others. Co-authored-by: Cursor --- pkg/format/columnsrows.go | 137 +++++++++++++++++++++++++++++++++ pkg/format/columnsrows_test.go | 128 ++++++++++++++++++++++++++++++ pkg/format/format.go | 32 +++++--- pkg/registry/coreformatters.go | 15 ++++ pkg/spec/kg.spec.yaml | 2 +- 5 files changed, 303 insertions(+), 11 deletions(-) create mode 100644 pkg/format/columnsrows.go create mode 100644 pkg/format/columnsrows_test.go diff --git a/pkg/format/columnsrows.go b/pkg/format/columnsrows.go new file mode 100644 index 0000000..370db38 --- /dev/null +++ b/pkg/format/columnsrows.go @@ -0,0 +1,137 @@ +// Copyright © 2026 Harness Inc. +// SPDX-License-Identifier: Apache-2.0 + +package format + +import ( + "fmt" + "io" + + "github.com/harness/cli/pkg/cmdctx" + "github.com/harness/cli/pkg/spec" +) + +// tabularSingleFormats are output formats that FormatSingleOutput can honor when the +// payload matches the columns/rows shape (via ExpandColumnsRows → FormatArrayOutput). +var tabularSingleFormats = map[string]bool{ + "table": true, "csv": true, "tsv": true, "markdown": true, "jsonl": true, +} + +// ExpandColumnsRows converts a {columns, rows} query result into inputs for +// FormatArrayOutput / FormatList. Returns ok=false 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) (rows []any, fields []spec.FieldDef, ok bool) { + m, ok := data.(map[string]any) + if !ok { + return nil, nil, false + } + colsRaw, hasCols := m["columns"] + rowsRaw, hasRows := m["rows"] + if !hasCols || !hasRows { + return nil, nil, false + } + colsSlice, ok1 := colsRaw.([]any) + rowsSlice, ok2 := rowsRaw.([]any) + if !ok1 || !ok2 { + return nil, nil, false + } + + names := make([]string, 0, len(colsSlice)) + fields = make([]spec.FieldDef, 0, len(colsSlice)) + seen := make(map[string]int, len(colsSlice)) + for i, c := range colsSlice { + base := columnName(c, i) + name := base + if n := seen[base]; n > 0 { + name = fmt.Sprintf("%s_%d", base, n+1) + } + seen[base]++ + 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, true +} + +// FormatColumnsRowsArray expands a columns/rows payload and renders it via FormatArrayOutput. +// Returns (false, nil) when data is not a columns/rows payload. +func FormatColumnsRowsArray(flags cmdctx.FormatFlags, isPty bool, data any, exprEnv map[string]any) (bool, error) { + rows, fields, ok := ExpandColumnsRows(data) + if !ok { + return false, nil + } + return true, FormatArrayOutput(flags, isPty, rows, "it", fieldsToTableSpec(fields), fields, exprEnv, nil) +} + +// WriteColumnsRows renders a columns/rows payload as a borderless table. +// Returns false when data is not that shape (caller should fall back). +func WriteColumnsRows(w io.Writer, data any, noHeaders bool) (bool, error) { + rows, fields, ok := ExpandColumnsRows(data) + if !ok { + return false, nil + } + tspec := fieldsToTableSpec(fields) + t, err := BuildTable(tspec, "it", rows, noHeaders, map[string]any{}) + if err != nil { + return true, err + } + t.SetOutputMirror(w) + t.Render() + + if m, ok := data.(map[string]any); ok { + if truncated, _ := m["truncated"].(bool); truncated { + fmt.Fprintln(w, "(truncated)") + } + } + return true, 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 +} + +func fieldsToTableSpec(fields []spec.FieldDef) *spec.TableSpec { + if len(fields) == 0 { + return &spec.TableSpec{} + } + cols := make([]spec.TableColumn, len(fields)) + for i, f := range fields { + header := f.Label + if header == "" { + header = f.ID + } + cols[i] = spec.TableColumn{Header: header, Expr: f.Expr, Align: f.Align, FieldType: f.FieldType, WidthMax: f.WidthMax} + } + return &spec.TableSpec{Columns: cols} +} diff --git a/pkg/format/columnsrows_test.go b/pkg/format/columnsrows_test.go new file mode 100644 index 0000000..31fcb18 --- /dev/null +++ b/pkg/format/columnsrows_test.go @@ -0,0 +1,128 @@ +// Copyright © 2026 Harness Inc. +// SPDX-License-Identifier: Apache-2.0 + +package format + +import ( + "bytes" + "strings" + "testing" + + "github.com/harness/cli/pkg/cmdctx" +) + +func sampleColumnsRows() map[string]any { + return map[string]any{ + "columns": []any{ + map[string]any{"name": "name", "data_type": "FIELD_TYPE_STR"}, + map[string]any{"name": "is_deleted", "data_type": "FIELD_TYPE_BOOL"}, + }, + "rows": []any{ + map[string]any{"values": []any{"alpha", false}}, + map[string]any{"values": []any{"beta", true}}, + }, + "truncated": false, + } +} + +func TestExpandColumnsRows(t *testing.T) { + rows, fields, ok := ExpandColumnsRows(sampleColumnsRows()) + if !ok { + t.Fatal("expected ok") + } + if len(fields) != 2 || fields[0].ID != "name" || fields[1].ID != "is_deleted" { + t.Fatalf("fields = %+v", fields) + } + if fields[0].Expr != `it["name"]` { + t.Fatalf("expr = %q", fields[0].Expr) + } + if len(rows) != 2 { + t.Fatalf("len(rows) = %d", len(rows)) + } + r0 := rows[0].(map[string]any) + if r0["name"] != "alpha" || r0["is_deleted"] != false { + t.Fatalf("row0 = %+v", r0) + } +} + +func TestExpandColumnsRows_NotShape(t *testing.T) { + if _, _, ok := ExpandColumnsRows(map[string]any{"foo": 1}); ok { + t.Fatal("expected !ok") + } + if _, _, ok := ExpandColumnsRows([]any{}); ok { + t.Fatal("expected !ok for slice") + } + if _, _, ok := ExpandColumnsRows(nil); ok { + t.Fatal("expected !ok for nil") + } +} + +func TestExpandColumnsRows_DuplicateNames(t *testing.T) { + data := map[string]any{ + "columns": []any{ + map[string]any{"name": "x"}, + map[string]any{"name": "x"}, + }, + "rows": []any{ + map[string]any{"values": []any{"a", "b"}}, + }, + } + rows, fields, ok := ExpandColumnsRows(data) + if !ok { + t.Fatal("expected ok") + } + if fields[0].ID != "x" || fields[1].ID != "x_2" { + t.Fatalf("fields = %+v", fields) + } + r0 := rows[0].(map[string]any) + if r0["x"] != "a" || r0["x_2"] != "b" { + t.Fatalf("row = %+v", r0) + } +} + +func TestWriteColumnsRows(t *testing.T) { + var buf bytes.Buffer + ok, err := WriteColumnsRows(&buf, sampleColumnsRows(), false) + if err != nil || !ok { + t.Fatalf("ok=%v err=%v", ok, err) + } + out := buf.String() + if !strings.Contains(out, "name") || !strings.Contains(out, "alpha") || !strings.Contains(out, "beta") { + t.Fatalf("output = %q", out) + } +} + +func TestWriteColumnsRows_Truncated(t *testing.T) { + data := sampleColumnsRows() + data["truncated"] = true + var buf bytes.Buffer + ok, err := WriteColumnsRows(&buf, data, false) + if err != nil || !ok { + t.Fatalf("ok=%v err=%v", ok, err) + } + if !strings.Contains(buf.String(), "(truncated)") { + t.Fatalf("output = %q", buf.String()) + } +} + +func TestFormatSingleOutput_ColumnsRowsTable(t *testing.T) { + out := t.TempDir() + "/out.txt" + data := map[string]any{"result": sampleColumnsRows()} + err := FormatSingleOutput(cmdctx.FormatFlags{Format: "table", OutFile: out}, false, data, "it.result", "", nil, nil, map[string]any{}) + if err != nil { + t.Fatalf("FormatSingleOutput table: %v", err) + } + + err = FormatSingleOutput(cmdctx.FormatFlags{Format: "table"}, false, map[string]any{"x": 1}, "it", "", nil, nil, map[string]any{}) + if err == nil || !strings.Contains(err.Error(), "not supported here") { + t.Fatalf("err = %v, want not supported", err) + } +} + +func TestFormatColumnsRowsArray_CSV(t *testing.T) { + out := t.TempDir() + "/out.csv" + handled, err := FormatColumnsRowsArray(cmdctx.FormatFlags{Format: "csv", OutFile: out}, false, sampleColumnsRows(), map[string]any{}) + if !handled || err != nil { + t.Fatalf("handled=%v err=%v", handled, err) + } +} diff --git a/pkg/format/format.go b/pkg/format/format.go index 960fc5f..66bb84c 100644 --- a/pkg/format/format.go +++ b/pkg/format/format.go @@ -195,6 +195,10 @@ func FormatFieldsOutput(flags cmdctx.FormatFlags, data any, itemExpr string, fie // yamlPickExpr, when non-empty, enables --format yaml and defines which subtree to emit; evaluated // from the raw response root. textFmt, when non-nil, is used when format is "text". // "it" is bound to the full response; ctx, auth, flags, and helpers are also available via exprEnv. +// +// When format is table/csv/tsv/markdown/jsonl and the extracted payload has the columns/rows +// shape (see ExpandColumnsRows), output is routed through FormatArrayOutput so those formats +// work on execute/get commands that return tabular query results. func FormatSingleOutput(flags cmdctx.FormatFlags, isPty bool, data any, itemExpr string, yamlPickExpr string, yamlExclude []string, textFmt TextFormatterFn, exprEnv map[string]any) error { if flags.Format == "" { if textFmt != nil { @@ -206,8 +210,25 @@ func FormatSingleOutput(flags cmdctx.FormatFlags, isPty bool, data any, itemExpr if flags.Format == "yaml" && yamlPickExpr == "" { return fmt.Errorf("--format yaml is not supported for this command") } + + payload := data + if !flags.Raw && itemExpr != "" && !(flags.Format == "yaml" && yamlPickExpr != "") { + extracted := evalColumnExpr(withIt(exprEnv, data), itemExpr) + if extracted == nil { + return nil + } + payload = extracted + } + + if tabularSingleFormats[flags.Format] { + if handled, err := FormatColumnsRowsArray(flags, isPty, payload, exprEnv); handled { + return err + } + return fmt.Errorf("format %q is not supported here; use json, text, or yaml", flags.Format) + } + if flags.Format != "json" && flags.Format != "text" && flags.Format != "yaml" { - return fmt.Errorf("format %q is not supported here; use json or text", flags.Format) + return fmt.Errorf("format %q is not supported here; use json, text, or yaml", flags.Format) } w, closeW, err := OpenWriter(flags.OutFile) @@ -231,15 +252,6 @@ func FormatSingleOutput(flags cmdctx.FormatFlags, isPty bool, data any, itemExpr return writeYAML(w, picked) } - payload := data - if !flags.Raw && itemExpr != "" { - extracted := evalColumnExpr(withIt(exprEnv, data), itemExpr) - if extracted == nil { - return nil - } - payload = extracted - } - if flags.Format == "text" { if textFmt == nil { return fmt.Errorf("--format text is not supported for this command") diff --git a/pkg/registry/coreformatters.go b/pkg/registry/coreformatters.go index 54a73c5..31a4695 100644 --- a/pkg/registry/coreformatters.go +++ b/pkg/registry/coreformatters.go @@ -17,6 +17,7 @@ const corePrefix = "core" // These are available to any module via text_formatter: core:. func (r *Registry) registerCoreFormatters() { r.RegisterTextFormatter("core:metadata-text", formatMetadataText) + r.RegisterTextFormatter("core:columns-rows", formatColumnsRows) } // formatMetadataText renders a metadata response ([]any of {key, value, type} objects) @@ -40,3 +41,17 @@ func formatMetadataText(w io.Writer, d cmdctx.DataAccessor) error { format.WriteLabeledValues(w, rows) return nil } + +// formatColumnsRows renders a {columns, rows} query result (HQL and similar) as a table. +// Use with text_formatter: core:columns-rows. For --format table|csv|tsv, FormatSingleOutput +// expands the same shape automatically via format.ExpandColumnsRows. +func formatColumnsRows(w io.Writer, d cmdctx.DataAccessor) error { + ok, err := format.WriteColumnsRows(w, d.GetData(), false) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("response is not a columns/rows result") + } + return nil +} diff --git a/pkg/spec/kg.spec.yaml b/pkg/spec/kg.spec.yaml index 8c5c56a..8eecdb7 100644 --- a/pkg/spec/kg.spec.yaml +++ b/pkg/spec/kg.spec.yaml @@ -271,7 +271,7 @@ commands: options.timeout_ms: 'flags.timeout != "" ? flags.timeout : nil' no_fields: true item_expr: it.result - text_header: "{{it}}" + text_formatter: core:columns-rows - command: execute hql:explain verb: execute From 0e914a7539596a904e776329ef3c638a8ed31fbe Mon Sep 17 00:00:00 2001 From: Vinay Srini Date: Tue, 4 Aug 2026 16:01:51 -0700 Subject: [PATCH 02/10] fix(format): harden columns-rows output edge cases Preserve unique generated column IDs, enforce raw JSON semantics, and surface truncation in human-readable table output while covering empty and raw result paths. Co-authored-by: Cursor --- pkg/format/columnsrows.go | 28 ++++++++++--- pkg/format/columnsrows_test.go | 76 ++++++++++++++++++++++++++++++++-- pkg/format/format.go | 14 ++++++- 3 files changed, 108 insertions(+), 10 deletions(-) diff --git a/pkg/format/columnsrows.go b/pkg/format/columnsrows.go index 370db38..bf79f0e 100644 --- a/pkg/format/columnsrows.go +++ b/pkg/format/columnsrows.go @@ -45,14 +45,26 @@ func ExpandColumnsRows(data any) (rows []any, fields []spec.FieldDef, ok bool) { names := make([]string, 0, len(colsSlice)) fields = make([]spec.FieldDef, 0, len(colsSlice)) - seen := make(map[string]int, 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 n := seen[base]; n > 0 { - name = fmt.Sprintf("%s_%d", base, n+1) + 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 } - seen[base]++ + used[name] = true names = append(names, name) fields = append(fields, spec.FieldDef{ ID: name, @@ -83,7 +95,13 @@ func FormatColumnsRowsArray(flags cmdctx.FormatFlags, isPty bool, data any, expr if !ok { return false, nil } - return true, FormatArrayOutput(flags, isPty, rows, "it", fieldsToTableSpec(fields), fields, exprEnv, nil) + var meta *PageMeta + if m, ok := data.(map[string]any); ok { + if truncated, _ := m["truncated"].(bool); truncated { + meta = &PageMeta{Notice: "(truncated)"} + } + } + return true, FormatArrayOutput(flags, isPty, rows, "it", fieldsToTableSpec(fields), fields, exprEnv, meta) } // WriteColumnsRows renders a columns/rows payload as a borderless table. diff --git a/pkg/format/columnsrows_test.go b/pkg/format/columnsrows_test.go index 31fcb18..36ef2f1 100644 --- a/pkg/format/columnsrows_test.go +++ b/pkg/format/columnsrows_test.go @@ -5,6 +5,7 @@ package format import ( "bytes" + "os" "strings" "testing" @@ -61,25 +62,45 @@ func TestExpandColumnsRows_DuplicateNames(t *testing.T) { data := map[string]any{ "columns": []any{ map[string]any{"name": "x"}, + map[string]any{"name": "x_2"}, map[string]any{"name": "x"}, }, "rows": []any{ - map[string]any{"values": []any{"a", "b"}}, + map[string]any{"values": []any{"a", "b", "c"}}, }, } rows, fields, ok := ExpandColumnsRows(data) if !ok { t.Fatal("expected ok") } - if fields[0].ID != "x" || fields[1].ID != "x_2" { + if fields[0].ID != "x" || fields[1].ID != "x_2" || fields[2].ID != "x_3" { t.Fatalf("fields = %+v", fields) } r0 := rows[0].(map[string]any) - if r0["x"] != "a" || r0["x_2"] != "b" { + if r0["x"] != "a" || r0["x_2"] != "b" || r0["x_3"] != "c" { t.Fatalf("row = %+v", r0) } } +func TestExpandColumnsRows_EmptyResult(t *testing.T) { + data := map[string]any{ + "columns": []any{}, + "rows": []any{}, + } + rows, fields, ok := ExpandColumnsRows(data) + if !ok { + t.Fatal("expected empty columns/rows result to be recognized") + } + if len(rows) != 0 || len(fields) != 0 { + t.Fatalf("rows=%v fields=%v", rows, fields) + } + out := t.TempDir() + "/out.txt" + handled, err := FormatColumnsRowsArray(cmdctx.FormatFlags{Format: "table", OutFile: out}, false, data, map[string]any{}) + if err != nil || !handled { + t.Fatalf("handled=%v err=%v", handled, err) + } +} + func TestWriteColumnsRows(t *testing.T) { var buf bytes.Buffer ok, err := WriteColumnsRows(&buf, sampleColumnsRows(), false) @@ -112,6 +133,13 @@ func TestFormatSingleOutput_ColumnsRowsTable(t *testing.T) { if err != nil { t.Fatalf("FormatSingleOutput table: %v", err) } + got, err := os.ReadFile(out) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(got), "alpha") { + t.Fatalf("table output did not use extracted item_expr payload: %q", got) + } err = FormatSingleOutput(cmdctx.FormatFlags{Format: "table"}, false, map[string]any{"x": 1}, "it", "", nil, nil, map[string]any{}) if err == nil || !strings.Contains(err.Error(), "not supported here") { @@ -119,6 +147,48 @@ func TestFormatSingleOutput_ColumnsRowsTable(t *testing.T) { } } +func TestFormatSingleOutput_RawTableRejected(t *testing.T) { + data := map[string]any{"result": sampleColumnsRows()} + err := FormatSingleOutput(cmdctx.FormatFlags{Format: "table", Raw: true}, false, data, "it.result", "", nil, nil, map[string]any{}) + if err == nil || err.Error() != "--raw is only supported with --format json" { + t.Fatalf("err = %v", err) + } +} + +func TestFormatSingleOutput_RawJSONKeepsEnvelope(t *testing.T) { + out := t.TempDir() + "/out.json" + data := map[string]any{"result": sampleColumnsRows(), "metadata": "kept"} + err := FormatSingleOutput(cmdctx.FormatFlags{Format: "json", Raw: true, OutFile: out}, false, data, "it.result", "", nil, nil, map[string]any{}) + if err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(out) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(got), `"metadata": "kept"`) || !strings.Contains(string(got), `"result"`) { + t.Fatalf("raw JSON did not preserve envelope: %q", got) + } +} + +func TestFormatSingleOutput_TruncatedTableNotice(t *testing.T) { + out := t.TempDir() + "/out.txt" + result := sampleColumnsRows() + result["truncated"] = true + data := map[string]any{"result": result} + err := FormatSingleOutput(cmdctx.FormatFlags{Format: "table", OutFile: out}, false, data, "it.result", "", nil, nil, map[string]any{}) + if err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(out) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(got), "(truncated)") { + t.Fatalf("table output = %q", got) + } +} + func TestFormatColumnsRowsArray_CSV(t *testing.T) { out := t.TempDir() + "/out.csv" handled, err := FormatColumnsRowsArray(cmdctx.FormatFlags{Format: "csv", OutFile: out}, false, sampleColumnsRows(), map[string]any{}) diff --git a/pkg/format/format.go b/pkg/format/format.go index 66bb84c..2500a47 100644 --- a/pkg/format/format.go +++ b/pkg/format/format.go @@ -32,7 +32,7 @@ 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. +// 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 { @@ -40,13 +40,14 @@ type PageMeta struct { Count int HasTotal bool Total int64 + Notice string } // 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. +// 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 *PageMeta) error { // 1. Resolve --columns into a tspec (overrides default). tspec := defaultTspec @@ -130,6 +131,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 @@ -210,6 +217,9 @@ func FormatSingleOutput(flags cmdctx.FormatFlags, isPty bool, data any, itemExpr if flags.Format == "yaml" && yamlPickExpr == "" { return fmt.Errorf("--format yaml is not supported for this command") } + if flags.Raw && tabularSingleFormats[flags.Format] { + return fmt.Errorf("--raw is only supported with --format json") + } payload := data if !flags.Raw && itemExpr != "" && !(flags.Format == "yaml" && yamlPickExpr != "") { From 545627b8b8db78775dc02ce012ebc68d9573b82e Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Wed, 5 Aug 2026 14:01:34 -0700 Subject: [PATCH 03/10] move pagemeta from format => cmdctx --- .gitignore | 1 + pkg/cmdctx/cmdctx.go | 11 +++++++++++ pkg/endpoint/pagingdriver.go | 11 +++++------ pkg/format/columnsrows.go | 4 ++-- pkg/format/format.go | 13 +------------ pkg/registry/endpoint.go | 2 +- 6 files changed, 21 insertions(+), 21 deletions(-) diff --git a/.gitignore b/.gitignore index e6aaed6..8a0b505 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ internal-docs/ .claude/settings.local.md .claude/ .cursor/ +devhome/ diff --git a/pkg/cmdctx/cmdctx.go b/pkg/cmdctx/cmdctx.go index 2ef76f0..36aa1ff 100644 --- a/pkg/cmdctx/cmdctx.go +++ b/pkg/cmdctx/cmdctx.go @@ -164,6 +164,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. diff --git a/pkg/endpoint/pagingdriver.go b/pkg/endpoint/pagingdriver.go index dad86c8..1135822 100644 --- a/pkg/endpoint/pagingdriver.go +++ b/pkg/endpoint/pagingdriver.go @@ -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" ) @@ -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") } @@ -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 @@ -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 @@ -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) } diff --git a/pkg/format/columnsrows.go b/pkg/format/columnsrows.go index bf79f0e..3afb36a 100644 --- a/pkg/format/columnsrows.go +++ b/pkg/format/columnsrows.go @@ -95,10 +95,10 @@ func FormatColumnsRowsArray(flags cmdctx.FormatFlags, isPty bool, data any, expr if !ok { return false, nil } - var meta *PageMeta + var meta *cmdctx.PageMeta if m, ok := data.(map[string]any); ok { if truncated, _ := m["truncated"].(bool); truncated { - meta = &PageMeta{Notice: "(truncated)"} + meta = &cmdctx.PageMeta{Notice: "(truncated)"} } } return true, FormatArrayOutput(flags, isPty, rows, "it", fieldsToTableSpec(fields), fields, exprEnv, meta) diff --git a/pkg/format/format.go b/pkg/format/format.go index 2500a47..62e78ff 100644 --- a/pkg/format/format.go +++ b/pkg/format/format.go @@ -32,23 +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 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 -} - // 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 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 *PageMeta) error { +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 != "" { diff --git a/pkg/registry/endpoint.go b/pkg/registry/endpoint.go index d8ed012..814683c 100644 --- a/pkg/registry/endpoint.go +++ b/pkg/registry/endpoint.go @@ -466,7 +466,7 @@ func renderCount(ctx *cmdctx.Ctx, n int64) error { // renderList applies item_item_expr unwrapping and calls FormatArrayOutput. // items must already be extracted (post-ItemsExpr). -func renderList(ctx *cmdctx.Ctx, ep *spec.EndpointSpec, items []any, meta *format.PageMeta) error { +func renderList(ctx *cmdctx.Ctx, ep *spec.EndpointSpec, items []any, meta *cmdctx.PageMeta) error { exprEnv := exprenv.Make(ctx) fields := resolveFieldsForCommand(ctx, ep) tspec := buildTspec(ep.Columns, fields) From 01c75a858b778d436b62f602a95c59fba09b7437 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Wed, 5 Aug 2026 14:10:50 -0700 Subject: [PATCH 04/10] new list_transform_fn on specs to convert a get/execute request to a format that can be consumed by the list formatting code --- pkg/cmdctx/cmdctx.go | 5 +++++ pkg/registry/checks.go | 11 +++++++++++ pkg/registry/fields_test.go | 9 +++++---- pkg/registry/moduleregistrar.go | 10 ++++++++++ pkg/registry/registry.go | 15 +++++++++++++++ pkg/spec/spec.go | 6 ++++++ 6 files changed, 52 insertions(+), 4 deletions(-) diff --git a/pkg/cmdctx/cmdctx.go b/pkg/cmdctx/cmdctx.go index 36aa1ff..494d45d 100644 --- a/pkg/cmdctx/cmdctx.go +++ b/pkg/cmdctx/cmdctx.go @@ -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). @@ -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 diff --git a/pkg/registry/checks.go b/pkg/registry/checks.go index 83c6b16..4864292 100644 --- a/pkg/registry/checks.go +++ b/pkg/registry/checks.go @@ -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 { @@ -177,6 +182,12 @@ 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.Paging != nil { if err := validatePaging(cs.Command, ep.Paging); err != nil { return err diff --git a/pkg/registry/fields_test.go b/pkg/registry/fields_test.go index 6d5bf38..2da828f 100644 --- a/pkg/registry/fields_test.go +++ b/pkg/registry/fields_test.go @@ -95,10 +95,11 @@ func (tr *testResolver) GetSpecsForModule(module string) []*spec.CommandSpec { r func (tr *testResolver) ResolveTextFormatter(id string) cmdctx.TextFormatterFn { return nil } -func (tr *testResolver) ResolveBodyFn(id string) cmdctx.CreateBodyFn { return nil } -func (tr *testResolver) ResolveQueryParamsFn(id string) cmdctx.QueryParamsFn { return nil } -func (tr *testResolver) ResolveFetchFn(id string) (cmdctx.FetchFn, error) { return nil, nil } -func (tr *testResolver) ResolveFlagResolveFn(id string) cmdctx.FlagResolveFn { return nil } +func (tr *testResolver) ResolveBodyFn(id string) cmdctx.CreateBodyFn { return nil } +func (tr *testResolver) ResolveQueryParamsFn(id string) cmdctx.QueryParamsFn { return nil } +func (tr *testResolver) ResolveFetchFn(id string) (cmdctx.FetchFn, error) { return nil, nil } +func (tr *testResolver) ResolveListTransformFn(id string) cmdctx.ListTransformFn { return nil } +func (tr *testResolver) ResolveFlagResolveFn(id string) cmdctx.FlagResolveFn { return nil } func (tr *testResolver) ResolveEndpointValidator(id string) cmdctx.EndpointValidatorFn { return nil } diff --git a/pkg/registry/moduleregistrar.go b/pkg/registry/moduleregistrar.go index 08f074e..876d031 100644 --- a/pkg/registry/moduleregistrar.go +++ b/pkg/registry/moduleregistrar.go @@ -27,6 +27,7 @@ type ModuleRegistrar interface { RegisterQueryParamsFn(shortID string, fn cmdctx.QueryParamsFn) RegisterFollowFn(shortID string, fn cmdctx.FollowFn) RegisterFetchFn(shortID string, fn cmdctx.FetchFn) + RegisterListTransformFn(shortID string, fn cmdctx.ListTransformFn) RegisterFlagCompletionFn(shortID string, fn FlagCompletionFn) RegisterFlagResolveFn(shortID string, fn cmdctx.FlagResolveFn) RegisterEndpointValidatorFn(shortID string, fn cmdctx.EndpointValidatorFn) @@ -87,6 +88,9 @@ func (m *moduleRegistrar) Register(cs *spec.CommandSpec) error { if cs.Endpoint != nil && cs.Endpoint.FetchFn != "" { cs.Endpoint.FetchFn = m.qualify(cs.Endpoint.FetchFn, cmd+" fetch_fn", true) } + if cs.Endpoint != nil && cs.Endpoint.ListTransformFn != "" { + cs.Endpoint.ListTransformFn = m.qualify(cs.Endpoint.ListTransformFn, cmd+" list_transform_fn", true) + } if cs.Endpoint != nil { for i, id := range cs.Endpoint.ValidatorsEndpoint { cs.Endpoint.ValidatorsEndpoint[i] = m.qualify(id, fmt.Sprintf("%s validators_endpoint[%d]", cmd, i), true) @@ -139,6 +143,12 @@ func (m *moduleRegistrar) RegisterFetchFn(shortID string, fn cmdctx.FetchFn) { } } +func (m *moduleRegistrar) RegisterListTransformFn(shortID string, fn cmdctx.ListTransformFn) { + if q := m.qualify(shortID, fmt.Sprintf("list_transform_fn %q", shortID), false); q != "" { + m.reg.RegisterListTransformFn(q, fn) + } +} + func (m *moduleRegistrar) RegisterFlagCompletionFn(shortID string, fn FlagCompletionFn) { if q := m.qualify(shortID, fmt.Sprintf("flag_completion_fn %q", shortID), false); q != "" { m.reg.RegisterFlagCompletionFn(q, fn) diff --git a/pkg/registry/registry.go b/pkg/registry/registry.go index 20fee6c..4356e3c 100644 --- a/pkg/registry/registry.go +++ b/pkg/registry/registry.go @@ -60,6 +60,7 @@ type Registry struct { queryParamsFns map[string]cmdctx.QueryParamsFn followFns map[string]cmdctx.FollowFn fetchFns map[string]cmdctx.FetchFn + listTransformFns map[string]cmdctx.ListTransformFn flagCompletionFns map[string]FlagCompletionFn flagResolveFns map[string]cmdctx.FlagResolveFn endpointValidatorFns map[string]cmdctx.EndpointValidatorFn @@ -78,6 +79,7 @@ func New() *Registry { queryParamsFns: map[string]cmdctx.QueryParamsFn{}, followFns: map[string]cmdctx.FollowFn{}, fetchFns: map[string]cmdctx.FetchFn{}, + listTransformFns: map[string]cmdctx.ListTransformFn{}, flagCompletionFns: map[string]FlagCompletionFn{}, flagResolveFns: map[string]cmdctx.FlagResolveFn{}, endpointValidatorFns: map[string]cmdctx.EndpointValidatorFn{}, @@ -244,6 +246,19 @@ func (r *Registry) RegisterFetchFn(id string, fn cmdctx.FetchFn) { r.fetchFns[id] = fn } +// ResolveListTransformFn implements cmdctx.Resolver. +func (r *Registry) ResolveListTransformFn(id string) cmdctx.ListTransformFn { + return r.listTransformFns[id] +} + +// RegisterListTransformFn registers a fully-qualified list transform function ID. +func (r *Registry) RegisterListTransformFn(id string, fn cmdctx.ListTransformFn) { + if _, ok := r.listTransformFns[id]; ok { + panic(fmt.Sprintf("registry: duplicate list transform fn %q", id)) + } + r.listTransformFns[id] = fn +} + // RegisterFlagCompletionFn registers a fully-qualified flag completion function. func (r *Registry) RegisterFlagCompletionFn(id string, fn FlagCompletionFn) { if _, ok := r.flagCompletionFns[id]; ok { diff --git a/pkg/spec/spec.go b/pkg/spec/spec.go index 4f82fe9..048ab3a 100644 --- a/pkg/spec/spec.go +++ b/pkg/spec/spec.go @@ -400,6 +400,12 @@ type EndpointSpec struct { // machinery. Used for in-memory or config-file backed list commands (e.g. "list noun"). // When empty, HTTPFetchFn is used. FetchFn string `yaml:"fetch_fn,omitempty"` + // ListTransformFn names a registered ListTransformFn that converts this command's + // get/execute response into a list for rendering via the standard list pipeline + // (columns/table/csv/tsv/jsonl), instead of FormatSingleOutput's json/text/yaml. + // Qualified by module at registration time. Not allowed on VerbList commands + // (they already have items_expr for this). + ListTransformFn string `yaml:"list_transform_fn,omitempty"` } // CommandSpec fully describes one CLI command. From b9c651979832c0779399d73639772b5f5f8b650c Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Wed, 5 Aug 2026 14:28:19 -0700 Subject: [PATCH 05/10] wire up list_transform_fn into endpoint --- pkg/registry/checks.go | 3 +++ pkg/registry/endpoint.go | 24 +++++++++++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/pkg/registry/checks.go b/pkg/registry/checks.go index 4864292..8d57a08 100644 --- a/pkg/registry/checks.go +++ b/pkg/registry/checks.go @@ -188,6 +188,9 @@ func validateEndpointConstraints(cs *spec.CommandSpec) error { 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 diff --git a/pkg/registry/endpoint.go b/pkg/registry/endpoint.go index 814683c..fb29352 100644 --- a/pkg/registry/endpoint.go +++ b/pkg/registry/endpoint.go @@ -302,6 +302,22 @@ func RunEndpoint(ctx *cmdctx.Ctx, ep *spec.EndpointSpec) (any, error) { exprEnv := exprenv.Make(ctx) + // list_transform_fn is checked before the nil-result guard: a command that declares it + // is unconditionally list-shaped, so even a nil/empty response must + // go through the transform fn (to render as an empty list) rather than fall into the + // single-item nil-result path below. + if ep.ListTransformFn != "" && ctx.Resolver != nil { + fn := ctx.Resolver.ResolveListTransformFn(ep.ListTransformFn) + if fn == nil { + return nil, fmt.Errorf("list_transform_fn %q not registered", ep.ListTransformFn) + } + items, fields, meta, err := fn(ctx, result) + if err != nil { + return nil, err + } + return result, renderListWithFields(ctx, ep, items, fields, &meta) + } + if result == nil { var textFmt cmdctx.TextFormatterFn if ep.TextFormatter != "" && ctx.Resolver != nil { @@ -467,8 +483,14 @@ func renderCount(ctx *cmdctx.Ctx, n int64) error { // renderList applies item_item_expr unwrapping and calls FormatArrayOutput. // items must already be extracted (post-ItemsExpr). func renderList(ctx *cmdctx.Ctx, ep *spec.EndpointSpec, items []any, meta *cmdctx.PageMeta) error { + return renderListWithFields(ctx, ep, items, resolveFieldsForCommand(ctx, ep), meta) +} + +// renderListWithFields is renderList but takes fields explicitly instead of resolving +// them from the noun/fields_extra — for list_transform_fn callers whose columns are +// derived from the response itself rather than static spec metadata. +func renderListWithFields(ctx *cmdctx.Ctx, ep *spec.EndpointSpec, items []any, fields []spec.FieldDef, meta *cmdctx.PageMeta) error { exprEnv := exprenv.Make(ctx) - fields := resolveFieldsForCommand(ctx, ep) tspec := buildTspec(ep.Columns, fields) listResult := any(items) From 5f39caf0386d659483ff70f389a44cfd937bd426 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Wed, 5 Aug 2026 14:55:48 -0700 Subject: [PATCH 06/10] wire up new list_transform_fn to kg's hql:run and then remove special case in FormatSingleOutput --- modules/gitops/gitops_test.go | 2 + pkg/format/columnsrows.go | 62 ------------------- pkg/format/columnsrows_test.go | 106 --------------------------------- pkg/format/format.go | 35 ++++------- pkg/registry/coreformatters.go | 30 ++++++---- pkg/spec/kg.spec.yaml | 2 +- 6 files changed, 30 insertions(+), 207 deletions(-) diff --git a/modules/gitops/gitops_test.go b/modules/gitops/gitops_test.go index 0feb4dc..ba1250c 100644 --- a/modules/gitops/gitops_test.go +++ b/modules/gitops/gitops_test.go @@ -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 } @@ -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) {} diff --git a/pkg/format/columnsrows.go b/pkg/format/columnsrows.go index 3afb36a..f8fd0b3 100644 --- a/pkg/format/columnsrows.go +++ b/pkg/format/columnsrows.go @@ -5,18 +5,10 @@ package format import ( "fmt" - "io" - "github.com/harness/cli/pkg/cmdctx" "github.com/harness/cli/pkg/spec" ) -// tabularSingleFormats are output formats that FormatSingleOutput can honor when the -// payload matches the columns/rows shape (via ExpandColumnsRows → FormatArrayOutput). -var tabularSingleFormats = map[string]bool{ - "table": true, "csv": true, "tsv": true, "markdown": true, "jsonl": true, -} - // ExpandColumnsRows converts a {columns, rows} query result into inputs for // FormatArrayOutput / FormatList. Returns ok=false when data is not that shape. // @@ -88,45 +80,6 @@ func ExpandColumnsRows(data any) (rows []any, fields []spec.FieldDef, ok bool) { return rows, fields, true } -// FormatColumnsRowsArray expands a columns/rows payload and renders it via FormatArrayOutput. -// Returns (false, nil) when data is not a columns/rows payload. -func FormatColumnsRowsArray(flags cmdctx.FormatFlags, isPty bool, data any, exprEnv map[string]any) (bool, error) { - rows, fields, ok := ExpandColumnsRows(data) - if !ok { - return false, nil - } - var meta *cmdctx.PageMeta - if m, ok := data.(map[string]any); ok { - if truncated, _ := m["truncated"].(bool); truncated { - meta = &cmdctx.PageMeta{Notice: "(truncated)"} - } - } - return true, FormatArrayOutput(flags, isPty, rows, "it", fieldsToTableSpec(fields), fields, exprEnv, meta) -} - -// WriteColumnsRows renders a columns/rows payload as a borderless table. -// Returns false when data is not that shape (caller should fall back). -func WriteColumnsRows(w io.Writer, data any, noHeaders bool) (bool, error) { - rows, fields, ok := ExpandColumnsRows(data) - if !ok { - return false, nil - } - tspec := fieldsToTableSpec(fields) - t, err := BuildTable(tspec, "it", rows, noHeaders, map[string]any{}) - if err != nil { - return true, err - } - t.SetOutputMirror(w) - t.Render() - - if m, ok := data.(map[string]any); ok { - if truncated, _ := m["truncated"].(bool); truncated { - fmt.Fprintln(w, "(truncated)") - } - } - return true, nil -} - func columnName(col any, i int) string { m, ok := col.(map[string]any) if !ok { @@ -138,18 +91,3 @@ func columnName(col any, i int) string { } return name } - -func fieldsToTableSpec(fields []spec.FieldDef) *spec.TableSpec { - if len(fields) == 0 { - return &spec.TableSpec{} - } - cols := make([]spec.TableColumn, len(fields)) - for i, f := range fields { - header := f.Label - if header == "" { - header = f.ID - } - cols[i] = spec.TableColumn{Header: header, Expr: f.Expr, Align: f.Align, FieldType: f.FieldType, WidthMax: f.WidthMax} - } - return &spec.TableSpec{Columns: cols} -} diff --git a/pkg/format/columnsrows_test.go b/pkg/format/columnsrows_test.go index 36ef2f1..e1826a7 100644 --- a/pkg/format/columnsrows_test.go +++ b/pkg/format/columnsrows_test.go @@ -4,12 +4,7 @@ package format import ( - "bytes" - "os" - "strings" "testing" - - "github.com/harness/cli/pkg/cmdctx" ) func sampleColumnsRows() map[string]any { @@ -94,105 +89,4 @@ func TestExpandColumnsRows_EmptyResult(t *testing.T) { if len(rows) != 0 || len(fields) != 0 { t.Fatalf("rows=%v fields=%v", rows, fields) } - out := t.TempDir() + "/out.txt" - handled, err := FormatColumnsRowsArray(cmdctx.FormatFlags{Format: "table", OutFile: out}, false, data, map[string]any{}) - if err != nil || !handled { - t.Fatalf("handled=%v err=%v", handled, err) - } -} - -func TestWriteColumnsRows(t *testing.T) { - var buf bytes.Buffer - ok, err := WriteColumnsRows(&buf, sampleColumnsRows(), false) - if err != nil || !ok { - t.Fatalf("ok=%v err=%v", ok, err) - } - out := buf.String() - if !strings.Contains(out, "name") || !strings.Contains(out, "alpha") || !strings.Contains(out, "beta") { - t.Fatalf("output = %q", out) - } -} - -func TestWriteColumnsRows_Truncated(t *testing.T) { - data := sampleColumnsRows() - data["truncated"] = true - var buf bytes.Buffer - ok, err := WriteColumnsRows(&buf, data, false) - if err != nil || !ok { - t.Fatalf("ok=%v err=%v", ok, err) - } - if !strings.Contains(buf.String(), "(truncated)") { - t.Fatalf("output = %q", buf.String()) - } -} - -func TestFormatSingleOutput_ColumnsRowsTable(t *testing.T) { - out := t.TempDir() + "/out.txt" - data := map[string]any{"result": sampleColumnsRows()} - err := FormatSingleOutput(cmdctx.FormatFlags{Format: "table", OutFile: out}, false, data, "it.result", "", nil, nil, map[string]any{}) - if err != nil { - t.Fatalf("FormatSingleOutput table: %v", err) - } - got, err := os.ReadFile(out) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(got), "alpha") { - t.Fatalf("table output did not use extracted item_expr payload: %q", got) - } - - err = FormatSingleOutput(cmdctx.FormatFlags{Format: "table"}, false, map[string]any{"x": 1}, "it", "", nil, nil, map[string]any{}) - if err == nil || !strings.Contains(err.Error(), "not supported here") { - t.Fatalf("err = %v, want not supported", err) - } -} - -func TestFormatSingleOutput_RawTableRejected(t *testing.T) { - data := map[string]any{"result": sampleColumnsRows()} - err := FormatSingleOutput(cmdctx.FormatFlags{Format: "table", Raw: true}, false, data, "it.result", "", nil, nil, map[string]any{}) - if err == nil || err.Error() != "--raw is only supported with --format json" { - t.Fatalf("err = %v", err) - } -} - -func TestFormatSingleOutput_RawJSONKeepsEnvelope(t *testing.T) { - out := t.TempDir() + "/out.json" - data := map[string]any{"result": sampleColumnsRows(), "metadata": "kept"} - err := FormatSingleOutput(cmdctx.FormatFlags{Format: "json", Raw: true, OutFile: out}, false, data, "it.result", "", nil, nil, map[string]any{}) - if err != nil { - t.Fatal(err) - } - got, err := os.ReadFile(out) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(got), `"metadata": "kept"`) || !strings.Contains(string(got), `"result"`) { - t.Fatalf("raw JSON did not preserve envelope: %q", got) - } -} - -func TestFormatSingleOutput_TruncatedTableNotice(t *testing.T) { - out := t.TempDir() + "/out.txt" - result := sampleColumnsRows() - result["truncated"] = true - data := map[string]any{"result": result} - err := FormatSingleOutput(cmdctx.FormatFlags{Format: "table", OutFile: out}, false, data, "it.result", "", nil, nil, map[string]any{}) - if err != nil { - t.Fatal(err) - } - got, err := os.ReadFile(out) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(got), "(truncated)") { - t.Fatalf("table output = %q", got) - } -} - -func TestFormatColumnsRowsArray_CSV(t *testing.T) { - out := t.TempDir() + "/out.csv" - handled, err := FormatColumnsRowsArray(cmdctx.FormatFlags{Format: "csv", OutFile: out}, false, sampleColumnsRows(), map[string]any{}) - if !handled || err != nil { - t.Fatalf("handled=%v err=%v", handled, err) - } } diff --git a/pkg/format/format.go b/pkg/format/format.go index 62e78ff..17b8604 100644 --- a/pkg/format/format.go +++ b/pkg/format/format.go @@ -191,10 +191,6 @@ func FormatFieldsOutput(flags cmdctx.FormatFlags, data any, itemExpr string, fie // yamlPickExpr, when non-empty, enables --format yaml and defines which subtree to emit; evaluated // from the raw response root. textFmt, when non-nil, is used when format is "text". // "it" is bound to the full response; ctx, auth, flags, and helpers are also available via exprEnv. -// -// When format is table/csv/tsv/markdown/jsonl and the extracted payload has the columns/rows -// shape (see ExpandColumnsRows), output is routed through FormatArrayOutput so those formats -// work on execute/get commands that return tabular query results. func FormatSingleOutput(flags cmdctx.FormatFlags, isPty bool, data any, itemExpr string, yamlPickExpr string, yamlExclude []string, textFmt TextFormatterFn, exprEnv map[string]any) error { if flags.Format == "" { if textFmt != nil { @@ -206,28 +202,8 @@ func FormatSingleOutput(flags cmdctx.FormatFlags, isPty bool, data any, itemExpr if flags.Format == "yaml" && yamlPickExpr == "" { return fmt.Errorf("--format yaml is not supported for this command") } - if flags.Raw && tabularSingleFormats[flags.Format] { - return fmt.Errorf("--raw is only supported with --format json") - } - - payload := data - if !flags.Raw && itemExpr != "" && !(flags.Format == "yaml" && yamlPickExpr != "") { - extracted := evalColumnExpr(withIt(exprEnv, data), itemExpr) - if extracted == nil { - return nil - } - payload = extracted - } - - if tabularSingleFormats[flags.Format] { - if handled, err := FormatColumnsRowsArray(flags, isPty, payload, exprEnv); handled { - return err - } - return fmt.Errorf("format %q is not supported here; use json, text, or yaml", flags.Format) - } - if flags.Format != "json" && flags.Format != "text" && flags.Format != "yaml" { - return fmt.Errorf("format %q is not supported here; use json, text, or yaml", flags.Format) + return fmt.Errorf("format %q is not supported here; use json or text", flags.Format) } w, closeW, err := OpenWriter(flags.OutFile) @@ -251,6 +227,15 @@ func FormatSingleOutput(flags cmdctx.FormatFlags, isPty bool, data any, itemExpr return writeYAML(w, picked) } + payload := data + if !flags.Raw && itemExpr != "" { + extracted := evalColumnExpr(withIt(exprEnv, data), itemExpr) + if extracted == nil { + return nil + } + payload = extracted + } + if flags.Format == "text" { if textFmt == nil { return fmt.Errorf("--format text is not supported for this command") diff --git a/pkg/registry/coreformatters.go b/pkg/registry/coreformatters.go index 31a4695..62018a6 100644 --- a/pkg/registry/coreformatters.go +++ b/pkg/registry/coreformatters.go @@ -9,15 +9,17 @@ import ( "github.com/harness/cli/pkg/cmdctx" "github.com/harness/cli/pkg/format" + "github.com/harness/cli/pkg/spec" ) const corePrefix = "core" -// registerCoreFormatters registers all built-in "core:*" text formatters. -// These are available to any module via text_formatter: core:. +// registerCoreFormatters registers all built-in "core:*" text formatters and +// list transform fns. These are available to any module via +// text_formatter: core: / list_transform_fn: core:. func (r *Registry) registerCoreFormatters() { r.RegisterTextFormatter("core:metadata-text", formatMetadataText) - r.RegisterTextFormatter("core:columns-rows", formatColumnsRows) + r.RegisterListTransformFn("core:columns-rows", columnsRowsListTransform) } // formatMetadataText renders a metadata response ([]any of {key, value, type} objects) @@ -42,16 +44,18 @@ func formatMetadataText(w io.Writer, d cmdctx.DataAccessor) error { return nil } -// formatColumnsRows renders a {columns, rows} query result (HQL and similar) as a table. -// Use with text_formatter: core:columns-rows. For --format table|csv|tsv, FormatSingleOutput -// expands the same shape automatically via format.ExpandColumnsRows. -func formatColumnsRows(w io.Writer, d cmdctx.DataAccessor) error { - ok, err := format.WriteColumnsRows(w, d.GetData(), false) - if err != nil { - return err - } +// 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, ok := format.ExpandColumnsRows(data) if !ok { - return fmt.Errorf("response is not a columns/rows result") + return nil, nil, cmdctx.PageMeta{}, fmt.Errorf("response is not a columns/rows result") } - return nil + 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 } diff --git a/pkg/spec/kg.spec.yaml b/pkg/spec/kg.spec.yaml index 8eecdb7..d7235da 100644 --- a/pkg/spec/kg.spec.yaml +++ b/pkg/spec/kg.spec.yaml @@ -271,7 +271,7 @@ commands: options.timeout_ms: 'flags.timeout != "" ? flags.timeout : nil' no_fields: true item_expr: it.result - text_formatter: core:columns-rows + list_transform_fn: core:columns-rows - command: execute hql:explain verb: execute From 2cb273793f1a974db6133f4c6f6f05332114b96d Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Wed, 5 Aug 2026 15:11:35 -0700 Subject: [PATCH 07/10] must apply itemexpr before passing to list_transform_fn --- pkg/registry/endpoint.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/pkg/registry/endpoint.go b/pkg/registry/endpoint.go index fb29352..d9bf196 100644 --- a/pkg/registry/endpoint.go +++ b/pkg/registry/endpoint.go @@ -305,13 +305,20 @@ func RunEndpoint(ctx *cmdctx.Ctx, ep *spec.EndpointSpec) (any, error) { // list_transform_fn is checked before the nil-result guard: a command that declares it // is unconditionally list-shaped, so even a nil/empty response must // go through the transform fn (to render as an empty list) rather than fall into the - // single-item nil-result path below. - if ep.ListTransformFn != "" && ctx.Resolver != nil { + // single-item nil-result path below. --json bypasses the transform entirely since it + // wants the raw response, not the list-rendered shape. + if ep.ListTransformFn != "" && ctx.Resolver != nil && ctx.FormatFlags.Format != "json" { fn := ctx.Resolver.ResolveListTransformFn(ep.ListTransformFn) if fn == nil { return nil, fmt.Errorf("list_transform_fn %q not registered", ep.ListTransformFn) } - items, fields, meta, err := fn(ctx, result) + transformInput := result + if ep.ItemExpr != "" && ep.ItemExpr != "it" { + if v, ok := exprenv.EvalExprAny(exprenv.WithIt(exprEnv, result), ep.ItemExpr); ok { + transformInput = v + } + } + items, fields, meta, err := fn(ctx, transformInput) if err != nil { return nil, err } From 21cc4c438984f3931e2d8a1517010bad8e3ceb4e Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Wed, 5 Aug 2026 15:20:57 -0700 Subject: [PATCH 08/10] fix naming to reflect that this is now a transform not a formatter --- pkg/registry/coreformatters.go | 23 ++------------- .../coretransforms.go} | 29 +++++++++++++++++-- .../coretransforms_test.go} | 14 ++++----- pkg/registry/registry.go | 1 + 4 files changed, 36 insertions(+), 31 deletions(-) rename pkg/{format/columnsrows.go => registry/coretransforms.go} (64%) rename pkg/{format/columnsrows_test.go => registry/coretransforms_test.go} (85%) diff --git a/pkg/registry/coreformatters.go b/pkg/registry/coreformatters.go index 62018a6..54a73c5 100644 --- a/pkg/registry/coreformatters.go +++ b/pkg/registry/coreformatters.go @@ -9,17 +9,14 @@ import ( "github.com/harness/cli/pkg/cmdctx" "github.com/harness/cli/pkg/format" - "github.com/harness/cli/pkg/spec" ) const corePrefix = "core" -// registerCoreFormatters registers all built-in "core:*" text formatters and -// list transform fns. These are available to any module via -// text_formatter: core: / list_transform_fn: core:. +// registerCoreFormatters registers all built-in "core:*" text formatters. +// These are available to any module via text_formatter: core:. func (r *Registry) registerCoreFormatters() { r.RegisterTextFormatter("core:metadata-text", formatMetadataText) - r.RegisterListTransformFn("core:columns-rows", columnsRowsListTransform) } // formatMetadataText renders a metadata response ([]any of {key, value, type} objects) @@ -43,19 +40,3 @@ func formatMetadataText(w io.Writer, d cmdctx.DataAccessor) error { format.WriteLabeledValues(w, rows) return nil } - -// 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, ok := format.ExpandColumnsRows(data) - if !ok { - return nil, nil, cmdctx.PageMeta{}, fmt.Errorf("response is not a columns/rows result") - } - 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 -} diff --git a/pkg/format/columnsrows.go b/pkg/registry/coretransforms.go similarity index 64% rename from pkg/format/columnsrows.go rename to pkg/registry/coretransforms.go index f8fd0b3..19a6619 100644 --- a/pkg/format/columnsrows.go +++ b/pkg/registry/coretransforms.go @@ -1,15 +1,38 @@ // Copyright © 2026 Harness Inc. // SPDX-License-Identifier: Apache-2.0 -package format +package registry import ( "fmt" + "github.com/harness/cli/pkg/cmdctx" "github.com/harness/cli/pkg/spec" ) -// ExpandColumnsRows converts a {columns, rows} query result into inputs for +// registerCoreTransforms registers all built-in "core:*" list transform fns. +// These are available to any module via list_transform_fn: core:. +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, ok := expandColumnsRows(data) + if !ok { + return nil, nil, cmdctx.PageMeta{}, fmt.Errorf("response is not a columns/rows result") + } + 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 ok=false when data is not that shape. // // Expected shape (HQL executeQuery and similar): @@ -19,7 +42,7 @@ import ( // "rows": [{"values": [v0, v1, ...]}, ...], // "truncated": false // } -func ExpandColumnsRows(data any) (rows []any, fields []spec.FieldDef, ok bool) { +func expandColumnsRows(data any) (rows []any, fields []spec.FieldDef, ok bool) { m, ok := data.(map[string]any) if !ok { return nil, nil, false diff --git a/pkg/format/columnsrows_test.go b/pkg/registry/coretransforms_test.go similarity index 85% rename from pkg/format/columnsrows_test.go rename to pkg/registry/coretransforms_test.go index e1826a7..158626f 100644 --- a/pkg/format/columnsrows_test.go +++ b/pkg/registry/coretransforms_test.go @@ -1,7 +1,7 @@ // Copyright © 2026 Harness Inc. // SPDX-License-Identifier: Apache-2.0 -package format +package registry import ( "testing" @@ -22,7 +22,7 @@ func sampleColumnsRows() map[string]any { } func TestExpandColumnsRows(t *testing.T) { - rows, fields, ok := ExpandColumnsRows(sampleColumnsRows()) + rows, fields, ok := expandColumnsRows(sampleColumnsRows()) if !ok { t.Fatal("expected ok") } @@ -42,13 +42,13 @@ func TestExpandColumnsRows(t *testing.T) { } func TestExpandColumnsRows_NotShape(t *testing.T) { - if _, _, ok := ExpandColumnsRows(map[string]any{"foo": 1}); ok { + if _, _, ok := expandColumnsRows(map[string]any{"foo": 1}); ok { t.Fatal("expected !ok") } - if _, _, ok := ExpandColumnsRows([]any{}); ok { + if _, _, ok := expandColumnsRows([]any{}); ok { t.Fatal("expected !ok for slice") } - if _, _, ok := ExpandColumnsRows(nil); ok { + if _, _, ok := expandColumnsRows(nil); ok { t.Fatal("expected !ok for nil") } } @@ -64,7 +64,7 @@ func TestExpandColumnsRows_DuplicateNames(t *testing.T) { map[string]any{"values": []any{"a", "b", "c"}}, }, } - rows, fields, ok := ExpandColumnsRows(data) + rows, fields, ok := expandColumnsRows(data) if !ok { t.Fatal("expected ok") } @@ -82,7 +82,7 @@ func TestExpandColumnsRows_EmptyResult(t *testing.T) { "columns": []any{}, "rows": []any{}, } - rows, fields, ok := ExpandColumnsRows(data) + rows, fields, ok := expandColumnsRows(data) if !ok { t.Fatal("expected empty columns/rows result to be recognized") } diff --git a/pkg/registry/registry.go b/pkg/registry/registry.go index 4356e3c..6fcf893 100644 --- a/pkg/registry/registry.go +++ b/pkg/registry/registry.go @@ -85,6 +85,7 @@ func New() *Registry { endpointValidatorFns: map[string]cmdctx.EndpointValidatorFn{}, } r.registerCoreFormatters() + r.registerCoreTransforms() return r } From 65716325b0dfe4943c4df1d1cddf24d390f0185e Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Wed, 5 Aug 2026 15:51:32 -0700 Subject: [PATCH 09/10] fixed bug when we have no (dynamic) columns returned --- pkg/format/format.go | 35 +++++++++++++++++++++++------ pkg/registry/coretransforms.go | 27 ++++++++++++---------- pkg/registry/coretransforms_test.go | 30 ++++++++++++------------- 3 files changed, 58 insertions(+), 34 deletions(-) diff --git a/pkg/format/format.go b/pkg/format/format.go index 17b8604..0562715 100644 --- a/pkg/format/format.go +++ b/pkg/format/format.go @@ -53,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" @@ -65,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 { diff --git a/pkg/registry/coretransforms.go b/pkg/registry/coretransforms.go index 19a6619..bcf0e2a 100644 --- a/pkg/registry/coretransforms.go +++ b/pkg/registry/coretransforms.go @@ -19,9 +19,9 @@ func (r *Registry) registerCoreTransforms() { // 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, ok := expandColumnsRows(data) - if !ok { - return nil, nil, cmdctx.PageMeta{}, fmt.Errorf("response is not a columns/rows result") + 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 { @@ -33,7 +33,7 @@ func columnsRowsListTransform(ctx *cmdctx.Ctx, data any) ([]any, []spec.FieldDef } // expandColumnsRows converts a {columns, rows} query result into inputs for -// FormatArrayOutput / FormatList. Returns ok=false when data is not that shape. +// FormatArrayOutput / FormatList. Returns an error when data is not that shape. // // Expected shape (HQL executeQuery and similar): // @@ -42,24 +42,27 @@ func columnsRowsListTransform(ctx *cmdctx.Ctx, data any) ([]any, []spec.FieldDef // "rows": [{"values": [v0, v1, ...]}, ...], // "truncated": false // } -func expandColumnsRows(data any) (rows []any, fields []spec.FieldDef, ok bool) { +func expandColumnsRows(data any) ([]any, []spec.FieldDef, error) { m, ok := data.(map[string]any) if !ok { - return nil, nil, false + 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, false + return nil, nil, fmt.Errorf("response is missing \"columns\" and/or \"rows\" fields") } colsSlice, ok1 := colsRaw.([]any) rowsSlice, ok2 := rowsRaw.([]any) - if !ok1 || !ok2 { - return nil, nil, false + 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)) + 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 { @@ -88,7 +91,7 @@ func expandColumnsRows(data any) (rows []any, fields []spec.FieldDef, ok bool) { }) } - rows = make([]any, 0, len(rowsSlice)) + rows := make([]any, 0, len(rowsSlice)) for _, r := range rowsSlice { rm, _ := r.(map[string]any) vals, _ := rm["values"].([]any) @@ -100,7 +103,7 @@ func expandColumnsRows(data any) (rows []any, fields []spec.FieldDef, ok bool) { } rows = append(rows, row) } - return rows, fields, true + return rows, fields, nil } func columnName(col any, i int) string { diff --git a/pkg/registry/coretransforms_test.go b/pkg/registry/coretransforms_test.go index 158626f..d7a44fe 100644 --- a/pkg/registry/coretransforms_test.go +++ b/pkg/registry/coretransforms_test.go @@ -22,9 +22,9 @@ func sampleColumnsRows() map[string]any { } func TestExpandColumnsRows(t *testing.T) { - rows, fields, ok := expandColumnsRows(sampleColumnsRows()) - if !ok { - t.Fatal("expected ok") + rows, fields, err := expandColumnsRows(sampleColumnsRows()) + if err != nil { + t.Fatalf("unexpected error: %v", err) } if len(fields) != 2 || fields[0].ID != "name" || fields[1].ID != "is_deleted" { t.Fatalf("fields = %+v", fields) @@ -42,14 +42,14 @@ func TestExpandColumnsRows(t *testing.T) { } func TestExpandColumnsRows_NotShape(t *testing.T) { - if _, _, ok := expandColumnsRows(map[string]any{"foo": 1}); ok { - t.Fatal("expected !ok") + if _, _, err := expandColumnsRows(map[string]any{"foo": 1}); err == nil { + t.Fatal("expected error") } - if _, _, ok := expandColumnsRows([]any{}); ok { - t.Fatal("expected !ok for slice") + if _, _, err := expandColumnsRows([]any{}); err == nil { + t.Fatal("expected error for slice") } - if _, _, ok := expandColumnsRows(nil); ok { - t.Fatal("expected !ok for nil") + if _, _, err := expandColumnsRows(nil); err == nil { + t.Fatal("expected error for nil") } } @@ -64,9 +64,9 @@ func TestExpandColumnsRows_DuplicateNames(t *testing.T) { map[string]any{"values": []any{"a", "b", "c"}}, }, } - rows, fields, ok := expandColumnsRows(data) - if !ok { - t.Fatal("expected ok") + rows, fields, err := expandColumnsRows(data) + if err != nil { + t.Fatalf("unexpected error: %v", err) } if fields[0].ID != "x" || fields[1].ID != "x_2" || fields[2].ID != "x_3" { t.Fatalf("fields = %+v", fields) @@ -82,9 +82,9 @@ func TestExpandColumnsRows_EmptyResult(t *testing.T) { "columns": []any{}, "rows": []any{}, } - rows, fields, ok := expandColumnsRows(data) - if !ok { - t.Fatal("expected empty columns/rows result to be recognized") + rows, fields, err := expandColumnsRows(data) + if err != nil { + t.Fatalf("expected empty columns/rows result to be recognized, got error: %v", err) } if len(rows) != 0 || len(fields) != 0 { t.Fatalf("rows=%v fields=%v", rows, fields) From a1cc80136a93220364920cfec450967058116123 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Wed, 5 Aug 2026 16:05:02 -0700 Subject: [PATCH 10/10] update help to reflect the table output, and move examples to Long text so short descriptions dont wrap --- pkg/spec/kg.spec.yaml | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/pkg/spec/kg.spec.yaml b/pkg/spec/kg.spec.yaml index d7235da..af7f6eb 100644 --- a/pkg/spec/kg.spec.yaml +++ b/pkg/spec/kg.spec.yaml @@ -216,7 +216,11 @@ commands: noun: hql noun_variant: grammar no_id: true - short: "Fetch the HQL ANTLR4 grammar: harness execute hql:grammar" + short: "Fetch the HQL ANTLR4 grammar" + long: | + Fetch the HQL ANTLR4 grammar. + + harness execute hql:grammar handler_type: endpoint endpoint: method: POST @@ -231,7 +235,11 @@ commands: noun: hql noun_variant: validate no_id: true - short: "Validate an HQL query: harness execute hql:validate --query 'find entity \"platform:project\" | select { * }'" + short: "Validate an HQL query --query " + long: | + Validate an HQL query without executing it. + + harness execute hql:validate --query 'find entity "platform:project" | select { * }' handler_type: endpoint flags: - name: query @@ -252,7 +260,13 @@ commands: noun: hql noun_variant: run no_id: true - short: "Execute an HQL query: harness execute hql:run --query 'find entity \"platform:project\" | select { * } | limit 10'" + short: "Execute an HQL query --query " + long: | + Execute an HQL query and render the results as rows in a table. + + Supports all standard table outputs: table (default), csv, tsv, json, yaml, jsonl. + + harness execute hql:run --query 'find entity "platform:project" | select { * } | limit 10' handler_type: endpoint flags: - name: query @@ -278,7 +292,11 @@ commands: noun: hql noun_variant: explain no_id: true - short: "Explain an HQL query execution plan: harness execute hql:explain --query 'find entity \"platform:project\" | select { * }'" + short: "Explain an HQL query execution plan --query " + long: | + Explain an HQL query execution plan. + + harness execute hql:explain --query 'find entity "platform:project" | select { * }' handler_type: endpoint flags: - name: query