From 21b43e907bab8cdfab4829b53ac90f037582944a Mon Sep 17 00:00:00 2001 From: Michael Finson Date: Tue, 11 Aug 2026 16:48:11 +0300 Subject: [PATCH] feat: add human-friendly report presentation [CMP-48642] --- AGENTS.md | 2 +- Formula/dci.rb | 4 +- README.md | 4 +- body_validation.go | 48 ++++- body_validation_test.go | 27 +++ bucket/dci.json | 2 +- error_contract.go | 87 ++++++-- error_contract_test.go | 40 ++++ main.go | 220 ++++++++++++++++++-- main_test.go | 144 ++++++++++++- packaging/homebrew/dci.rb.tmpl | 4 +- packaging/scoop/dci.json.tmpl | 2 +- packaging/winget/dci.locale.en-US.yaml.tmpl | 2 +- pivot.go | 89 ++++++-- pivot_test.go | 122 ++++++++++- response_transform.go | 88 +++++++- skills/dci-cli/SKILL.md | 7 +- skills/dci-cli/agents/openai.yaml | 4 +- 18 files changed, 814 insertions(+), 82 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a1864b0..3b4bd82 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,7 @@ This repo is one piece of a much larger system. `dci` wraps restish, sits in fro ### What Is This -`dci` is the CLI for the DoiT Cloud Intelligence (DCI) API. It wraps [restish](https://github.com/rest-sh/restish) with DCI-specific configuration — auto-configured API base, OAuth2 via the DoiT Console, table-first output, and a locked-down command surface that exposes only DCI API operations. The entire CLI is a single `main.go` file. It ships as a Go binary distributed via Homebrew, Scoop, WinGet, and `.deb`/`.rpm` packages. +`dci` is the CLI for the Cloud Intelligence™ (DCI) API. It wraps [restish](https://github.com/rest-sh/restish) with DCI-specific configuration — auto-configured API base, OAuth2 via the DoiT Console, table-first output, and a locked-down command surface that exposes only DCI API operations. The entire CLI is a single `main.go` file. It ships as a Go binary distributed via Homebrew, Scoop, WinGet, and `.deb`/`.rpm` packages. ### Restish Version (don't upgrade to v2) diff --git a/Formula/dci.rb b/Formula/dci.rb index bcaaed9..d4d8bf7 100644 --- a/Formula/dci.rb +++ b/Formula/dci.rb @@ -1,5 +1,5 @@ class Dci < Formula - desc "DoiT Cloud Intelligence CLI" + desc "Cloud Intelligence™ CLI" homepage "https://github.com/doitintl/dci-cli" version "1.6.0" @@ -29,6 +29,6 @@ def install test do output = shell_output("#{bin}/dci --help") - assert_match "DoiT Cloud Intelligence", output + assert_match "Cloud Intelligence™", output end end diff --git a/README.md b/README.md index 21b2f24..3bf5540 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/doitintl/dci-cli) -# DoiT Cloud Intelligence CLI +# Cloud Intelligence™ CLI -`dci` is the command-line interface for the [DoiT Cloud Intelligence](https://www.doit.com/) API. Manage budgets, reports, alerts, and run analytics queries directly from your terminal. +`dci` is the command-line interface for the [Cloud Intelligence™](https://www.doit.com/) API. Manage budgets, reports, alerts, and run analytics queries directly from your terminal. ## Installation diff --git a/body_validation.go b/body_validation.go index 9a652ed..b4b483b 100644 --- a/body_validation.go +++ b/body_validation.go @@ -46,21 +46,25 @@ func (validationError requestBodyValidationError) AgentErrorRetryable() bool { var shorthandBodyFieldPattern = regexp.MustCompile(`^([A-Za-z_][A-Za-z0-9_-]*)\s*[.\[:{]`) var schemaBodyFieldPattern = regexp.MustCompile(`^ ([A-Za-z_][A-Za-z0-9_-]*)\*?:`) +var currencyBodyFieldPattern = regexp.MustCompile(`(?:^|[,\s])config\.currency:\s*"?([A-Za-z]{3})"?`) +var bufferedRequestBody []byte func validateRequestBody(command *cobra.Command, args []string) error { validFields := requestSchemaTopLevelFields(command.Long) if len(validFields) == 0 { return nil } - if skip, _ := parseBoolish(os.Getenv("DCI_SKIP_BODY_VALIDATION")); skip { - return nil - } bodyArguments := args pathParameterCount := len(strings.Fields(command.Use)) - 1 if pathParameterCount > 0 && pathParameterCount <= len(args) { bodyArguments = args[pathParameterCount:] } + stdinFields, stdinBuffered := bufferStdinTopLevelFields() + requestReportCurrency = extractRequestCurrency(validFields, bodyArguments, bufferedRequestBody) + if skip, _ := parseBoolish(os.Getenv("DCI_SKIP_BODY_VALIDATION")); skip { + return nil + } unknownFields := make([]string, 0) for _, argument := range bodyArguments { if strings.HasPrefix(argument, "@") || strings.HasPrefix(argument, "<") { @@ -80,7 +84,7 @@ func validateRequestBody(command *cobra.Command, args []string) error { } } - if stdinFields, buffered := bufferStdinTopLevelFields(); buffered { + if stdinBuffered { for _, field := range stdinFields { if !validFields[field] { unknownFields = append(unknownFields, field) @@ -152,6 +156,7 @@ func bufferStdinTopLevelFields() ([]string, bool) { return nil, false } cli.Stdin = &bufferedBodyInput{Reader: bytes.NewReader(data), info: inputInfo} + bufferedRequestBody = data trimmedData := bytes.TrimSpace(data) if len(trimmedData) == 0 || trimmedData[0] != '{' { return nil, true @@ -159,6 +164,41 @@ func bufferStdinTopLevelFields() ([]string, bool) { return jsonTopLevelFields(trimmedData), true } +func extractRequestCurrency(validFields map[string]bool, bodyArguments []string, stdinBody []byte) string { + if !validFields["config"] { + return "" + } + for _, argument := range bodyArguments { + if match := currencyBodyFieldPattern.FindStringSubmatch(argument); match != nil { + return strings.ToUpper(match[1]) + } + trimmedArgument := strings.TrimSpace(argument) + if currency := currencyFromJSONBody([]byte(trimmedArgument)); currency != "" { + return currency + } + if len(trimmedArgument) > 1 && (trimmedArgument[0] == '@' || trimmedArgument[0] == '<') { + if data, err := os.ReadFile(trimmedArgument[1:]); err == nil { + if currency := currencyFromJSONBody(data); currency != "" { + return currency + } + } + } + } + return currencyFromJSONBody(stdinBody) +} + +func currencyFromJSONBody(data []byte) string { + var body struct { + Config struct { + Currency string `json:"currency"` + } `json:"config"` + } + if err := json.Unmarshal(data, &body); err == nil && body.Config.Currency != "" { + return strings.ToUpper(body.Config.Currency) + } + return "" +} + type bufferedBodyInput struct { *bytes.Reader info fs.FileInfo diff --git a/body_validation_test.go b/body_validation_test.go index a56031c..43bb60b 100644 --- a/body_validation_test.go +++ b/body_validation_test.go @@ -112,3 +112,30 @@ func TestValidateRequestBodyCanBeBypassed(t *testing.T) { t.Fatalf("bypass rejected body: %v", err) } } + +func TestExtractRequestCurrency(t *testing.T) { + validFields := map[string]bool{"config": true} + if currency := extractRequestCurrency(validFields, nil, nil); currency != "" { + t.Errorf("unspecified currency = %q, want empty", currency) + } + if currency := extractRequestCurrency(validFields, []string{`config.currency: EUR`}, nil); currency != "EUR" { + t.Errorf("shorthand currency = %q, want EUR", currency) + } + if currency := extractRequestCurrency(validFields, []string{`{"config":{"currency":"gbp"}}`}, nil); currency != "GBP" { + t.Errorf("inline JSON currency = %q, want GBP", currency) + } + bodyFile := t.TempDir() + "/query.json" + if err := os.WriteFile(bodyFile, []byte(`{"config":{"currency":"cad"}}`), 0o600); err != nil { + t.Fatal(err) + } + if currency := extractRequestCurrency(validFields, []string{"@" + bodyFile}, nil); currency != "CAD" { + t.Errorf("file currency = %q, want CAD", currency) + } + stdinBody := []byte(`{"config":{"currency":"ils"}}`) + if currency := extractRequestCurrency(validFields, nil, stdinBody); currency != "ILS" { + t.Errorf("stdin currency = %q, want ILS", currency) + } + if currency := extractRequestCurrency(map[string]bool{"body": true}, nil, nil); currency != "" { + t.Errorf("non-report currency = %q, want empty", currency) + } +} diff --git a/bucket/dci.json b/bucket/dci.json index a972676..1e8c2b8 100644 --- a/bucket/dci.json +++ b/bucket/dci.json @@ -1,6 +1,6 @@ { "version": "1.6.0", - "description": "DoiT Cloud Intelligence CLI", + "description": "Cloud Intelligence™ CLI", "homepage": "https://github.com/doitintl/dci-cli", "license": "SEE REPOSITORY", "architecture": { diff --git a/error_contract.go b/error_contract.go index 5d7065e..0d20453 100644 --- a/error_contract.go +++ b/error_contract.go @@ -9,6 +9,7 @@ import ( "net" "net/url" "os" + "regexp" "strings" "github.com/rest-sh/restish/cli" @@ -311,22 +312,84 @@ func executeCLI() error { } func executeCLIWith(run func() error) error { - if !agentErrorContractEnabled() { - return run() + if agentErrorContractEnabled() { + cli.Root.SilenceErrors = true + cli.Root.SilenceUsage = true + + originalStderr := cli.Stderr + var capturedStderr bytes.Buffer + cli.Stderr = &capturedStderr + err := run() + cli.Stderr = originalStderr + + if err == nil || agentErrorWritten { + _, _ = io.Copy(originalStderr, &capturedStderr) + } + + return err } - cli.Root.SilenceErrors = true - cli.Root.SilenceUsage = true + if agentUAMode == uaModeInteractive { + // Human at a terminal: without this, a failed command prints the same + // error three times (cobra's "Error:" + usage dump, restish's + // "ERROR:" log line, and the final reporter). Silence cobra, capture + // restish's stderr, and drop lines duplicating the returned error — + // reportExecutionError then prints it exactly once. + cli.Root.SilenceErrors = true + cli.Root.SilenceUsage = true - originalStderr := cli.Stderr - var capturedStderr bytes.Buffer - cli.Stderr = &capturedStderr - err := run() - cli.Stderr = originalStderr + originalStderr := cli.Stderr + var capturedStderr bytes.Buffer + cli.Stderr = &capturedStderr + err := run() + cli.Stderr = originalStderr - if err == nil || agentErrorWritten { - _, _ = io.Copy(originalStderr, &capturedStderr) + emitStderrWithoutDuplicateError(originalStderr, capturedStderr.String(), err) + return err } - return err + // Non-interactive without the agent contract (pipes, CI): preserve the + // framework output untouched. + return run() +} + +// emitStderrWithoutDuplicateError re-emits captured stderr, skipping lines +// that merely repeat the returned error (restish logs "ERROR: Error: " +// for every failed run). Other stderr content — warnings, API error details — +// passes through unchanged. +func emitStderrWithoutDuplicateError(writer io.Writer, captured string, err error) { + if captured == "" { + return + } + if err == nil { + _, _ = io.WriteString(writer, captured) + return + } + message := err.Error() + for _, line := range strings.Split(strings.TrimRight(captured, "\n"), "\n") { + trimmed := strings.TrimSpace(stripANSI(line)) + if trimmed != "" && strings.HasSuffix(trimmed, message) { + if rest := strings.TrimSuffix(trimmed, message); strings.TrimSpace(rest) == "" || isErrorPrefix(rest) { + continue + } + } + _, _ = io.WriteString(writer, line+"\n") + } +} + +func isErrorPrefix(s string) bool { + s = strings.ToLower(strings.TrimSpace(s)) + for _, prefix := range []string{"error:", "error: error:", "!"} { + if s == prefix { + return true + } + } + return false +} + +// ansiPattern matches terminal escape sequences (restish colors its log tags). +var ansiPattern = regexp.MustCompile("\x1b\\[[0-9;]*m") + +func stripANSI(s string) string { + return ansiPattern.ReplaceAllString(s, "") } diff --git a/error_contract_test.go b/error_contract_test.go index 1c74164..9e383f4 100644 --- a/error_contract_test.go +++ b/error_contract_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "errors" + "fmt" "io" "strings" "testing" @@ -200,6 +201,45 @@ func TestExecuteCLISuppressesFrameworkErrorsInAgentMode(t *testing.T) { } } +func TestInteractiveExecutionPrintsErrorOnce(t *testing.T) { + oldAgentMode := agentMode + oldAgentUAMode := agentUAMode + oldStderr := cli.Stderr + oldRoot := cli.Root + agentMode = false + agentUAMode = uaModeInteractive + cli.Root = &cobra.Command{} + var stderr bytes.Buffer + cli.Stderr = &stderr + t.Cleanup(func() { + agentMode = oldAgentMode + agentUAMode = oldAgentUAMode + cli.Stderr = oldStderr + cli.Root = oldRoot + }) + + bootErr := errors.New(`customerContext "foo" does not look like a customer domain`) + err := executeCLIWith(func() error { + // restish logs the error itself (with color codes) before returning it. + _, _ = fmt.Fprintf(cli.Stderr, "\x1b[48;5;204mERROR:\x1b[0m Error: %v\n", bootErr) + _, _ = fmt.Fprintln(cli.Stderr, "warning: something unrelated") + return bootErr + }) + if err != bootErr { + t.Fatalf("err = %v, want the original error", err) + } + output := stderr.String() + if strings.Contains(output, "customerContext") { + t.Errorf("duplicate error line re-emitted: %q", output) + } + if !strings.Contains(output, "warning: something unrelated") { + t.Errorf("unrelated stderr content dropped: %q", output) + } + if !cli.Root.SilenceErrors || !cli.Root.SilenceUsage { + t.Error("cobra error/usage output not silenced in interactive mode") + } +} + func TestNonInteractiveResponsePreservesFormatterOutput(t *testing.T) { oldAgentMode := agentMode oldAgentUAMode := agentUAMode diff --git a/main.go b/main.go index da19609..9f823f3 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "io" + "math" "net/url" "os" "path/filepath" @@ -207,7 +208,7 @@ func printFirstRunOnboarding(configured bool) { return } - fmt.Fprintln(os.Stderr, "DoiT Cloud Intelligence CLI is ready.") + fmt.Fprintln(os.Stderr, "Cloud Intelligence™ CLI is ready.") fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, "Next steps:") fmt.Fprintln(os.Stderr, " dci status") @@ -224,6 +225,8 @@ func run() (exitCode int) { // Reset per-invocation state so repeated calls (e.g. in tests) start clean. customerContextFlagValue = "" resolvedCustomerContext = "" + requestReportCurrency = "" + bufferedRequestBody = nil nonJSONErrorResponse = false resetErrorContractState() resetDestructiveContractState() @@ -356,7 +359,7 @@ func reportExecutionError(err error, status int, configDir string) int { if code == exitSuccess && isSilentExecutionError(err) { return exitSuccess } - fmt.Fprintf(os.Stderr, "%v\n", err) + fmt.Fprintf(os.Stderr, "Error: %v\n", err) maybeHintDoerContext(code, status, configDir) return code } @@ -818,7 +821,7 @@ Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}} {{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}} ` -const dciLongDescription = "Command-line interface for the DoiT Cloud Intelligence API.\n\n" + +const dciLongDescription = "Command-line interface for the Cloud Intelligence™ API.\n\n" + "Documentation: https://help.doit.com/docs/cli or run `dci docs` for every entry point." var rootExamples = []string{ @@ -877,7 +880,7 @@ func applyCommandBranding(cmd *cobra.Command, short string, examples []string) { } func brandRootCommand() { - applyCommandBranding(cli.Root, "DoiT Cloud Intelligence CLI", rootExamples) + applyCommandBranding(cli.Root, "Cloud Intelligence™ CLI", rootExamples) cli.Root.SetUsageTemplate(dciUsageTemplate) } @@ -1115,7 +1118,7 @@ func validateCustomerContextValue(token string) error { } func brandDCIRootCommand() { - applyCommandBranding(findDCICommand(), "DoiT Cloud Intelligence API CLI", apiExamples) + applyCommandBranding(findDCICommand(), "Cloud Intelligence™ API CLI", apiExamples) } func registerStatusCommands(configDir string) { @@ -1135,7 +1138,7 @@ func registerStatusCommands(configDir string) { return err } - fmt.Fprintln(os.Stdout, "DoiT Cloud Intelligence") + _, _ = fmt.Fprintln(os.Stdout, "Cloud Intelligence™") if os.Getenv("DCI_API_BASE_URL") != "" { fmt.Fprintf(os.Stdout, "API Base: %s (DCI_API_BASE_URL)\n", base) } else { @@ -1455,7 +1458,8 @@ func addOutputFlag() { dciCmd.PersistentFlags().Bool("dry-run", false, "Preview a destructive operation without executing it") dciCmd.PersistentFlags().Int("max-rows", -1, "Maximum report result rows to output (default: 500 in agent mode, unlimited otherwise; 0 = unlimited)") dciCmd.PersistentFlags().String("rows", "", "Report row encoding: positional (default) or keyed (schema-named objects)") - dciCmd.PersistentFlags().Bool("pivot", false, "Pivot report results: groups as rows, time periods as columns, with totals") + dciCmd.PersistentFlags().Bool("pivot", false, "Force the pivot report view (groups as rows, time periods as columns, with totals) for any output format or mode") + dciCmd.PersistentFlags().Bool("flat", false, "Render report results as flat rows instead of the default interactive pivot view") dciCmd.PersistentFlags().Bool("include-empty-rows", false, "Keep null-group, zero-metric report rows (dropped by default)") dciCmd.PersistentFlags().Bool("raw-numbers", false, "Print numbers unformatted in table output (no digit grouping or rounding)") @@ -1504,9 +1508,14 @@ func addOutputFlag() { } } viper.Set("rows-mode", rowsMode) + viper.Set("report-currency", "") + viper.Set("money-columns", "") + viper.Set("report-hourly", false) + viper.Set("pivot-columns-auto", false) for flagName, configName := range map[string]string{ "pivot": "pivot-rows", + "flat": "flat-rows", "include-empty-rows": "include-empty-rows", "raw-numbers": "raw-numbers", } { @@ -2297,6 +2306,19 @@ func renderTable(rows []map[string]interface{}) ([]byte, error) { } terminalWidth := detectTerminalWidth(opts.width) + + // Wide responses (e.g. anomalies with 16 columns) would otherwise squeeze + // every column into unreadable "…" stubs. Keep only as many columns as + // render readably and report the rest through the same hidden-columns + // hint used for object columns. An explicit -C selection or wrap mode + // keeps every requested column; the pivot's auto-generated column order + // is not a user selection, so it stays fit-eligible. + if (len(opts.columns) == 0 || viper.GetBool("pivot-columns-auto")) && opts.mode == "fit" { + var hiddenForWidth []string + keys, hiddenForWidth = fitColumnsToTerminal(rows, keys, terminalWidth) + hidden = append(hidden, hiddenForWidth...) + } + contentW := measureContentWidths(rows, keys) colWidths := computeColumnWidths(contentW, terminalWidth, maxColWidth) @@ -2306,8 +2328,8 @@ func renderTable(rows []map[string]interface{}) ([]byte, error) { } if len(hidden) > 0 { - out += fmt.Sprintf("\nHidden columns (object values): %s\n", strings.Join(hidden, ", ")) - out += fmt.Sprintf("Use -C to include them, e.g.: -C %s\n", strings.Join(append(keys, hidden...), ",")) + out += fmt.Sprintf("\nHidden columns (nested objects, or too many to fit): %s\n", strings.Join(hidden, ", ")) + out += fmt.Sprintf("Use -C to choose columns (e.g.: -C %s), -M wrap to wrap, or -W to widen\n", strings.Join(append(keys, hidden...), ",")) } return []byte(out), nil } @@ -2386,7 +2408,7 @@ func measureContentWidths(rows []map[string]interface{}, keys []string) []int { } for _, row := range rows { for i, k := range keys { - w := runewidth.StringWidth(tableCellText(row[k])) + w := runewidth.StringWidth(renderCellText(row, k)) if w > widths[i] { widths[i] = w } @@ -2395,9 +2417,65 @@ func measureContentWidths(rows []map[string]interface{}, keys []string) []int { return widths } +// renderCellText renders a cell with full row context: monetary cells get the +// currency symbol and whole-unit rounding when the currency is known (from +// the row itself, e.g. budgets, or from the report request config). +func renderCellText(row map[string]interface{}, key string) string { + val := row[key] + if !viper.GetBool("raw-numbers") { + if amount, ok := numericCell(val); ok { + if currency := cellCurrency(row, key); currency != "" { + return formatMoney(amount, currency) + } + } + } + return tableCellText(key, val) +} + +// cellCurrency decides whether a cell is monetary and in which currency: +// either the transform marked the column (report metrics, pivot periods), or +// the row itself carries a currency field next to a money-named column. +func cellCurrency(row map[string]interface{}, key string) string { + reportCurrency := strings.TrimSpace(viper.GetString("report-currency")) + for _, column := range strings.Split(viper.GetString("money-columns"), ",") { + if column != "" && column == key { + return reportCurrency + } + } + if rowCurrency, ok := row["currency"].(string); ok && rowCurrency != "" && moneyNamedColumn(key) { + return rowCurrency + } + return "" +} + +// currencySymbols maps ISO codes to their conventional signs; unknown codes +// prefix the code itself ("SEK 1,234"). +var currencySymbols = map[string]string{ + "USD": "$", "EUR": "€", "GBP": "£", "ILS": "₪", "JPY": "¥", + "AUD": "A$", "CAD": "C$", "BRL": "R$", "MXN": "MX$", "SGD": "S$", "TWD": "NT$", +} + +// formatMoney renders a monetary amount for humans: currency sign, digit +// grouping, rounded to whole units (cents are noise at cloud-bill scale). +func formatMoney(amount float64, currency string) string { + rounded := int64(math.Round(math.Abs(amount))) + grouped := groupDigits(strconv.FormatInt(rounded, 10)) + sign := "" + if amount < 0 && rounded != 0 { + sign = "-" + } + if symbol, ok := currencySymbols[strings.ToUpper(currency)]; ok { + return sign + symbol + grouped + } + return sign + strings.ToUpper(currency) + " " + grouped +} + // tableCellText renders a raw row value as table cell text, joining arrays -// the same way toon cells do. -func tableCellText(val interface{}) string { +// the same way toon cells do. The column name lets epoch-second values in +// time-named columns render as dates (millisecond epochs are recognized by +// magnitude alone; second epochs overlap plausible numeric data, so they +// convert only when the column name says "time"). +func tableCellText(key string, val interface{}) string { if s, ok := val.([]interface{}); ok { converted := make([]string, len(s)) for j := range s { @@ -2405,9 +2483,19 @@ func tableCellText(val interface{}) string { } return strings.Join(converted, ", ") } + if !viper.GetBool("raw-numbers") && timeNamedColumn(key) { + if sec, ok := numericCell(val); ok && sec >= 1e9 && sec < 4.1e9 { + return prettifyTimestamp(time.Unix(int64(sec), 0).UTC().Format(time.RFC3339)) + } + } return formatTableValue(val) } +func timeNamedColumn(key string) bool { + lower := strings.ToLower(key) + return strings.Contains(lower, "time") || strings.Contains(lower, "date") +} + // computeColumnWidths distributes terminal width across columns. Columns that // fit within an equal share get exactly their content width, freeing surplus // space for columns that need more. This repeats until stable, so narrow @@ -2514,6 +2602,9 @@ func tableOverhead(cols int) int { // 2001–2099) are formatted as ISO 8601 in UTC. Both float64 and int64 are // handled: integral response numbers are normalized to int64. func formatValue(val interface{}) string { + if val == nil { + return "" // an empty cell, not a literal "" + } if ms, ok := numericCell(val); ok && ms >= 1e12 && ms < 4.1e12 { sec := int64(ms) / 1000 rem := int64(ms) % 1000 @@ -2561,15 +2652,50 @@ func displayTimestampFields(row map[string]interface{}, schema []reportColumn) m return out } -// formatTableValue renders a cell for table output: floats get digit grouping -// and two decimals (unless --raw-numbers), everything else follows -// formatValue. +// formatTableValue renders a cell for table output: decimal numbers get digit +// grouping and two decimals, integral numbers group without decimals, and +// epoch-millisecond timestamps become ISO dates (the table pipeline +// roundtrips through JSON, so int64 normalization does not survive here). +// --raw-numbers disables all of it. func formatTableValue(val interface{}) string { - f, ok := val.(float64) - if !ok || viper.GetBool("raw-numbers") { + if viper.GetBool("raw-numbers") { return formatValue(val) } - return groupDigits(fmt.Sprintf("%.2f", f)) + switch v := val.(type) { + case string: + return prettifyTimestamp(v) + case float64: + if v >= 1e12 && v < 4.1e12 { + return prettifyTimestamp(formatValue(v)) // epoch milliseconds + } + if v == math.Trunc(v) && math.Abs(v) < 1<<53 { + return groupDigits(strconv.FormatInt(int64(v), 10)) + } + return groupDigits(fmt.Sprintf("%.2f", v)) + default: + return prettifyTimestamp(formatValue(val)) + } +} + +// prettifyTimestamp renders RFC3339 strings for human eyes: midnight UTC +// becomes a bare date (daily/monthly report grain carries no time +// information), anything else keeps minute precision. Hourly report results +// keep the time even at midnight — the resolution is part of the data. +// Non-timestamp strings pass through untouched; machine formats (json, yaml, +// csv, toon) never see this — they keep full RFC3339. +func prettifyTimestamp(s string) string { + if len(s) < 20 || s[4] != '-' || s[10] != 'T' { + return s // cheap pre-check before parsing + } + parsed, err := time.Parse(time.RFC3339, s) + if err != nil { + return s + } + utc := parsed.UTC() + if !viper.GetBool("report-hourly") && utc.Hour() == 0 && utc.Minute() == 0 && utc.Second() == 0 { + return utc.Format("2006-01-02") + } + return utc.Format("2006-01-02 15:04") } // groupDigits inserts thousands separators into the integer part of a @@ -2625,7 +2751,7 @@ func buildTableString(rows []map[string]interface{}, keys []string, colWidths [] body := make([]*simpletable.Cell, 0, len(keys)) for i, k := range keys { val := row[k] - cellText := tableCellText(val) + cellText := renderCellText(row, k) cellText = formatCell(cellText, colWidths[i], mode) // Numbers align right for magnitude comparison; text reads left. align := simpletable.AlignLeft @@ -2660,6 +2786,62 @@ func padCell(s string, width int) string { return s + strings.Repeat("\u2800", width-cur) } +// fitColumnsToTerminal keeps the leading columns that can render at a +// readable width within the terminal, hiding the rest. Column content width +// is capped for the fit decision so one very wide column (a URL, a long +// name) doesn't evict everything after it. +const fitColumnContentCap = 28 + +// fitPriorityColumns always survive the fit before other columns are +// considered — hiding what a row is (id, name) or when it happened +// (startTime, createTime) helps nobody. +var fitPriorityColumns = map[string]bool{"id": true, "name": true, "startTime": true, "createTime": true, "total": true} + +func fitColumnsToTerminal(rows []map[string]interface{}, keys []string, terminalWidth int) (visible, hidden []string) { + if terminalWidth <= 0 { + terminalWidth = 120 + } + contentW := measureContentWidths(rows, keys) + cappedWidth := func(i int) int { + if contentW[i] > fitColumnContentCap { + return fitColumnContentCap + } + return contentW[i] + } + + kept := map[string]bool{} + used := 0 + count := 0 + allocate := func(i int, key string) { + width := cappedWidth(i) + if count > 0 && used+width+tableOverhead(count+1) > terminalWidth { + return + } + kept[key] = true + used += width + count++ + } + for i, key := range keys { + if fitPriorityColumns[key] { + allocate(i, key) + } + } + for i, key := range keys { + if !fitPriorityColumns[key] { + allocate(i, key) + } + } + + for _, key := range keys { + if kept[key] { + visible = append(visible, key) + } else { + hidden = append(hidden, key) + } + } + return visible, hidden +} + // filterObjectColumns splits keys into visible and hidden. A column is hidden // if any row contains a nested object (map) either directly or inside an array. func filterObjectColumns(rows []map[string]interface{}, keys []string) (visible, hidden []string) { diff --git a/main_test.go b/main_test.go index 007df03..f73acb5 100644 --- a/main_test.go +++ b/main_test.go @@ -255,7 +255,7 @@ func TestBrandRootAndDCICommands(t *testing.T) { brandRootCommand() brandDCIRootCommand() - if cli.Root.Short != "DoiT Cloud Intelligence CLI" { + if cli.Root.Short != "Cloud Intelligence™ CLI" { t.Fatalf("root short = %q", cli.Root.Short) } if cli.Root.Long != dciLongDescription { @@ -268,7 +268,7 @@ func TestBrandRootAndDCICommands(t *testing.T) { t.Fatalf("root usage template mismatch") } - if dciCmd.Short != "DoiT Cloud Intelligence API CLI" { + if dciCmd.Short != "Cloud Intelligence™ API CLI" { t.Fatalf("dci short = %q", dciCmd.Short) } if dciCmd.Long != dciLongDescription { @@ -648,7 +648,7 @@ func TestCLIIntegrationBehavior(t *testing.T) { if res.exitCode != 0 { t.Fatalf("exit code = %d, want 0; output:\n%s", res.exitCode, res.output) } - if !strings.Contains(res.output, "DoiT Cloud Intelligence") { + if !strings.Contains(res.output, "Cloud Intelligence™") { t.Fatalf("status output missing expected text:\n%s", res.output) } @@ -906,7 +906,7 @@ func assertRootHelpBranded(t *testing.T, out string) { if strings.Contains(out, "A generic client for REST-ish APIs") { t.Fatalf("unexpected stock restish root help:\n%s", out) } - if !strings.Contains(out, "Command-line interface for the DoiT Cloud Intelligence API.") { + if !strings.Contains(out, "Command-line interface for the Cloud Intelligence™ API.") { t.Fatalf("missing DCI root branding in help output:\n%s", out) } } @@ -1039,7 +1039,7 @@ func TestFormatValue(t *testing.T) { {"unix ms timestamp 2", 1.774010451448e+12, time.UnixMilli(1774010451448).UTC().Format(time.RFC3339)}, {"below timestamp range", 9.99e+11, "9.99e+11"}, {"above timestamp range", 5e+12, "5e+12"}, - {"nil value", nil, ""}, + {"nil value", nil, ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -1158,9 +1158,9 @@ func TestMeasureContentWidthsFormatsTimestamps(t *testing.T) { rows, _ := mockAlertRows() keys := []string{"createTime"} widths := measureContentWidths(rows, keys) - // ISO 8601 timestamp "2024-03-04T12:28:41Z" is 20 chars - if widths[0] != 20 { - t.Errorf("timestamp width = %d, want 20", widths[0]) + // Human-readable timestamp "2024-03-04 12:28" is 16 chars + if widths[0] != 16 { + t.Errorf("timestamp width = %d, want 16", widths[0]) } } @@ -1331,6 +1331,134 @@ func TestBuildTableStringWithHiddenColumnsIncluded(t *testing.T) { } } +func TestFormatValueNilIsEmptyCell(t *testing.T) { + if got := formatValue(nil); got != "" { + t.Errorf("formatValue(nil) = %q, want empty", got) + } +} + +func TestFormatTableValueRendering(t *testing.T) { + viper.Set("raw-numbers", false) + t.Cleanup(func() { viper.Set("raw-numbers", nil) }) + + if got := formatTableValue(1786356000000.0); got != "2026-08-10 10:00" { + t.Errorf("epoch-ms float = %q, want human-readable timestamp", got) + } + if got := formatTableValue("2026-06-01T00:00:00Z"); got != "2026-06-01" { + t.Errorf("midnight timestamp = %q, want bare date", got) + } + if got := formatTableValue("2026-08-10T09:16:14Z"); got != "2026-08-10 09:16" { + t.Errorf("timestamp = %q, want minute precision", got) + } + if got := formatTableValue("not a timestamp"); got != "not a timestamp" { + t.Errorf("plain string = %q, want untouched", got) + } + + // Hourly report resolution keeps the time even at midnight. + viper.Set("report-hourly", true) + t.Cleanup(func() { viper.Set("report-hourly", nil) }) + if got := formatTableValue("2026-06-01T00:00:00Z"); got != "2026-06-01 00:00" { + t.Errorf("hourly midnight = %q, want time kept", got) + } + if got := formatTableValue(55.0); got != "55" { + t.Errorf("integral float = %q, want 55 (no decimals)", got) + } + if got := formatTableValue(291018.6548470196); got != "291,018.65" { + t.Errorf("decimal float = %q, want grouped 2dp", got) + } + viper.Set("raw-numbers", true) + if got := formatTableValue(291018.6548470196); got != "291018.6548470196" { + t.Errorf("--raw-numbers float = %q, want unformatted", got) + } +} + +func TestFitColumnsToTerminalKeepsPriorityAndHidesOverflow(t *testing.T) { + rows := []map[string]interface{}{{ + "alpha": strings.Repeat("a", 28), + "beta": strings.Repeat("b", 28), + "gamma": strings.Repeat("c", 28), + "delta": strings.Repeat("d", 28), + "id": "b0f3c260-3df3-4270-b946-df31ddee6a92", + "status": "active", + }} + keys := []string{"alpha", "beta", "gamma", "delta", "id", "status"} + visible, hidden := fitColumnsToTerminal(rows, keys, 80) + if len(hidden) == 0 { + t.Fatal("expected overflow columns to be hidden at width 80") + } + foundID := false + for _, k := range visible { + if k == "id" { + foundID = true + } + } + if !foundID { + t.Errorf("id column not kept: visible=%v hidden=%v", visible, hidden) + } + if len(visible)+len(hidden) != len(keys) { + t.Errorf("columns lost: visible=%v hidden=%v", visible, hidden) + } + + // Everything fits on a wide terminal. + visible, hidden = fitColumnsToTerminal(rows, keys, 500) + if len(hidden) != 0 { + t.Errorf("nothing should hide at width 500: hidden=%v", hidden) + } + if len(visible) != len(keys) { + t.Errorf("visible=%v, want all keys", visible) + } +} + +func TestFormatMoney(t *testing.T) { + if got := formatMoney(239927.13841529994, "USD"); got != "$239,927" { + t.Errorf("USD = %q, want $239,927", got) + } + if got := formatMoney(1400, "EUR"); got != "€1,400" { + t.Errorf("EUR = %q, want €1,400", got) + } + if got := formatMoney(1234.56, "SEK"); got != "SEK 1,235" { + t.Errorf("unknown code = %q, want SEK 1,235", got) + } + if got := formatMoney(-500.4, "USD"); got != "-$500" { + t.Errorf("negative = %q, want -$500", got) + } +} + +func TestRenderCellTextCurrency(t *testing.T) { + viper.Set("raw-numbers", false) + viper.Set("report-currency", "USD") + viper.Set("money-columns", "cost,total") + t.Cleanup(func() { + for _, key := range []string{"raw-numbers", "report-currency", "money-columns"} { + viper.Set(key, nil) + } + }) + + row := map[string]interface{}{"cost": 135616.704056, "usage": 42.5} + if got := renderCellText(row, "cost"); got != "$135,617" { + t.Errorf("marked money column = %q, want $135,617", got) + } + if got := renderCellText(row, "usage"); got != "42.50" { + t.Errorf("non-money metric = %q, want plain number", got) + } + + // Per-row currency (budgets shape) formats money-named columns. + budget := map[string]interface{}{"amount": 4900.0, "currency": "EUR", "currentUtilization": 0.0} + viper.Set("money-columns", "") + viper.Set("report-currency", "") + if got := renderCellText(budget, "amount"); got != "€4,900" { + t.Errorf("row-currency amount = %q, want €4,900", got) + } + + // --raw-numbers disables everything. + viper.Set("raw-numbers", true) + viper.Set("money-columns", "cost") + viper.Set("report-currency", "USD") + if got := renderCellText(row, "cost"); got != "135616.704056" { + t.Errorf("raw mode = %q, want unformatted", got) + } +} + func TestBuildTableStringNoU2800InOutput(t *testing.T) { // Verify that U+2800 padding placeholder is replaced with spaces. rows, keys := mockSimpleRows() diff --git a/packaging/homebrew/dci.rb.tmpl b/packaging/homebrew/dci.rb.tmpl index aa171a1..397d62c 100644 --- a/packaging/homebrew/dci.rb.tmpl +++ b/packaging/homebrew/dci.rb.tmpl @@ -1,5 +1,5 @@ class Dci < Formula - desc "DoiT Cloud Intelligence CLI" + desc "Cloud Intelligence™ CLI" homepage "https://github.com/doitintl/dci-cli" version "__VERSION__" @@ -29,6 +29,6 @@ class Dci < Formula test do output = shell_output("#{bin}/dci --help") - assert_match "DoiT Cloud Intelligence", output + assert_match "Cloud Intelligence™", output end end diff --git a/packaging/scoop/dci.json.tmpl b/packaging/scoop/dci.json.tmpl index 5e23d93..40e5059 100644 --- a/packaging/scoop/dci.json.tmpl +++ b/packaging/scoop/dci.json.tmpl @@ -1,6 +1,6 @@ { "version": "__VERSION__", - "description": "DoiT Cloud Intelligence CLI", + "description": "Cloud Intelligence™ CLI", "homepage": "https://github.com/doitintl/dci-cli", "license": "SEE REPOSITORY", "architecture": { diff --git a/packaging/winget/dci.locale.en-US.yaml.tmpl b/packaging/winget/dci.locale.en-US.yaml.tmpl index 36026f1..eb02174 100644 --- a/packaging/winget/dci.locale.en-US.yaml.tmpl +++ b/packaging/winget/dci.locale.en-US.yaml.tmpl @@ -5,7 +5,7 @@ Publisher: DoiT PublisherUrl: https://github.com/doitintl PublisherSupportUrl: https://github.com/doitintl/dci-cli/issues PackageName: dci -ShortDescription: DoiT Cloud Intelligence CLI +ShortDescription: Cloud Intelligence™ CLI Moniker: dci Tags: - cli diff --git a/pivot.go b/pivot.go index 21d3618..d16a935 100644 --- a/pivot.go +++ b/pivot.go @@ -5,14 +5,20 @@ import ( "sort" "strings" + "github.com/rest-sh/restish/cli" "github.com/spf13/viper" ) +// maxDefaultPivotPeriods bounds the default pivot: beyond two weeks of daily +// (or a day of hourly) periods the matrix stops being scannable, so the +// default view stays flat and only an explicit --pivot forces the matrix. +const maxDefaultPivotPeriods = 14 + // pivotReportBody reshapes flat report rows (one row per group × time period) // into a report-style pivot: groups as rows, time periods as columns, with a // row total column and a per-period totals row — the way the DoiT console // presents a report table. -func pivotReportBody(rows []interface{}, schema []reportColumn) (interface{}, bool) { +func pivotReportBody(rows []interface{}, schema []reportColumn, forced bool) (interface{}, bool) { if len(schema) == 0 || len(rows) == 0 { return nil, false } @@ -28,10 +34,10 @@ func pivotReportBody(rows []interface{}, schema []reportColumn) (interface{}, bo } values := map[pivotKey]map[string]float64{} rowTotals := map[pivotKey]float64{} - periodTotals := map[string]float64{} + periodTotals := map[string]map[string]float64{} + metricTotals := map[string]float64{} periodSet := map[string]bool{} groupOrder := []pivotKey{} - grandTotal := 0.0 multiMetric := len(metricIdx) > 1 for _, raw := range rows { @@ -52,9 +58,10 @@ func pivotReportBody(rows []interface{}, schema []reportColumn) (interface{}, bo if !ok { continue } + metric := schema[mi].Name key := pivotKey{group: group} if multiMetric { - key.metric = schema[mi].Name + key.metric = metric } if values[key] == nil { values[key] = map[string]float64{} @@ -62,8 +69,11 @@ func pivotReportBody(rows []interface{}, schema []reportColumn) (interface{}, bo } values[key][period] += n rowTotals[key] += n - periodTotals[period] += n - grandTotal += n + if periodTotals[metric] == nil { + periodTotals[metric] = map[string]float64{} + } + periodTotals[metric][period] += n + metricTotals[metric] += n periodSet[period] = true } } @@ -78,13 +88,20 @@ func pivotReportBody(rows []interface{}, schema []reportColumn) (interface{}, bo } sort.Strings(periods) + if !forced && len(periods) > maxDefaultPivotPeriods { + if cli.Stderr != nil { + _, _ = fmt.Fprintf(cli.Stderr, "note: %d time periods — showing flat rows (pass --pivot to force the pivot view)\n", len(periods)) + } + return nil, false + } + // Highest row total first, matching how report tables rank groups. sort.SliceStable(groupOrder, func(i, j int) bool { return rowTotals[groupOrder[i]] > rowTotals[groupOrder[j]] }) groupHeader := pivotGroupHeader(groupIdx, schema) - out := make([]interface{}, 0, len(groupOrder)+1) + out := make([]interface{}, 0, len(groupOrder)+len(metricIdx)) for _, key := range groupOrder { row := map[string]interface{}{groupHeader: key.group} if multiMetric { @@ -97,18 +114,25 @@ func pivotReportBody(rows []interface{}, schema []reportColumn) (interface{}, bo out = append(out, row) } - totals := map[string]interface{}{groupHeader: "TOTAL"} - if multiMetric { - totals["metric"] = "" - } - for _, p := range periods { - totals[p] = periodTotals[p] + for _, mi := range metricIdx { + metric := schema[mi].Name + totals := map[string]interface{}{groupHeader: "TOTAL"} + if multiMetric { + totals["metric"] = metric + } + for _, p := range periods { + totals[p] = periodTotals[metric][p] + } + totals["total"] = metricTotals[metric] + out = append(out, totals) } - totals["total"] = grandTotal - out = append(out, totals) // Give the renderer an explicit column order (group, periods, total) — // alphabetical ordering would sort the group column after the periods. + // Marked as auto-set so the width fit still applies (unlike a user's -C, + // this is not an explicit selection): a forced pivot over many periods + // keeps the group, the leading periods, and the total, with the rest + // reported through the hidden-columns hint. if strings.TrimSpace(viper.GetString("table-columns")) == "" { order := []string{groupHeader} if multiMetric { @@ -117,11 +141,27 @@ func pivotReportBody(rows []interface{}, schema []reportColumn) (interface{}, bo order = append(order, periods...) order = append(order, "total") viper.Set("table-columns", strings.Join(order, ",")) + viper.Set("pivot-columns-auto", true) + } + + // When the pivoted metric is monetary and the currency is known, the + // period and total cells are money for the renderer. + if requestCurrencyContext() != "" && allMetricsMoney(metricIdx, schema) { + viper.Set("money-columns", strings.Join(append(append([]string{}, periods...), "total"), ",")) } return out, true } +func allMetricsMoney(metricIdx []int, schema []reportColumn) bool { + for _, i := range metricIdx { + if !moneyNamedColumn(schema[i].Name) { + return false + } + } + return len(metricIdx) > 0 +} + // pivotTimeParts orders the recognized time dimensions from coarse to fine so // period keys compose correctly (e.g. 2026-05-04). var pivotTimeParts = []string{"year", "quarter", "month", "week", "day", "hour"} @@ -150,14 +190,29 @@ func classifyPivotColumns(schema []reportColumn) (timeIdx map[string]int, groupI func pivotPeriod(cells []interface{}, timeIdx map[string]int, schema []reportColumn) string { parts := []string{} + hourPart := "" for _, name := range pivotTimeParts { i, ok := timeIdx[name] if !ok || i >= len(cells) || cells[i] == nil { continue } - parts = append(parts, fmt.Sprintf("%v", cells[i])) + value := fmt.Sprintf("%v", cells[i]) + if name == "hour" { + // The API delivers hours as zero-padded "HH:MM" strings; a space + // separator reads as a time, a dash would read as another date part. + if n, ok := numericCell(cells[i]); ok { + value = fmt.Sprintf("%02d:00", int(n)) + } + hourPart = value + continue + } + parts = append(parts, value) + } + period := strings.Join(parts, "-") + if hourPart != "" { + period += " " + hourPart } - return strings.Join(parts, "-") + return period } func pivotGroup(cells []interface{}, groupIdx []int) string { diff --git a/pivot_test.go b/pivot_test.go index 3e34768..84f9bee 100644 --- a/pivot_test.go +++ b/pivot_test.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "strings" "testing" @@ -27,7 +28,7 @@ func TestPivotReportBodyTimeAsColumns(t *testing.T) { []interface{}{"svc-b", "2026", "06", 100.0, float64(1780272000)}, []interface{}{"svc-b", "2026", "07", 200.0, float64(1782864000)}, } - result, ok := pivotReportBody(rows, pivotSchema()) + result, ok := pivotReportBody(rows, pivotSchema(), true) if !ok { t.Fatal("pivot not applied") } @@ -68,7 +69,7 @@ func TestPivotReportBodyNullGroups(t *testing.T) { rows := []interface{}{ []interface{}{nil, "2026", "06", 5.0, float64(1780272000)}, } - result, ok := pivotReportBody(rows, pivotSchema()) + result, ok := pivotReportBody(rows, pivotSchema(), true) if !ok { t.Fatal("pivot not applied") } @@ -78,16 +79,127 @@ func TestPivotReportBodyNullGroups(t *testing.T) { } } +func TestPivotReportBodyKeepsMetricTotalsSeparate(t *testing.T) { + viper.Set("table-columns", "") + t.Cleanup(func() { viper.Set("table-columns", nil) }) + + schema := []reportColumn{ + {Name: "service_description", Type: "string"}, + {Name: "year", Type: "string"}, + {Name: "month", Type: "string"}, + {Name: "cost", Type: "float"}, + {Name: "usage", Type: "float"}, + } + rows := []interface{}{ + []interface{}{"svc", "2026", "06", 10.0, 100.0}, + []interface{}{"svc", "2026", "07", 20.0, 200.0}, + } + result, ok := pivotReportBody(rows, schema, true) + if !ok { + t.Fatal("pivot not applied") + } + pivoted := result.([]interface{}) + if len(pivoted) != 4 { + t.Fatalf("pivot rows = %d, want 2 metric rows + 2 totals", len(pivoted)) + } + costTotal := pivoted[2].(map[string]interface{}) + usageTotal := pivoted[3].(map[string]interface{}) + if costTotal["metric"] != "cost" || costTotal["total"] != 30.0 { + t.Errorf("cost total = %#v, want 30", costTotal) + } + if usageTotal["metric"] != "usage" || usageTotal["total"] != 300.0 { + t.Errorf("usage total = %#v, want 300", usageTotal) + } +} + +func TestPivotDefaultFallsBackOnManyPeriods(t *testing.T) { + viper.Set("table-columns", "") + t.Cleanup(func() { + viper.Set("table-columns", nil) + viper.Set("pivot-columns-auto", nil) + }) + + rows := []interface{}{} + for day := 1; day <= maxDefaultPivotPeriods+3; day++ { + rows = append(rows, []interface{}{"svc", "2026", "07", fmt.Sprintf("%02d", day), 1.0, float64(1780272000 + day*86400)}) + } + schema := []reportColumn{ + {Name: "service_description", Type: "string"}, + {Name: "year", Type: "string"}, + {Name: "month", Type: "string"}, + {Name: "day", Type: "string"}, + {Name: "cost", Type: "float"}, + {Name: "timestamp", Type: "timestamp"}, + } + if _, ok := pivotReportBody(rows, schema, false); ok { + t.Error("default pivot applied despite too many periods") + } + if _, ok := pivotReportBody(rows, schema, true); !ok { + t.Error("forced pivot refused") + } +} + +func TestPivotPeriodHourly(t *testing.T) { + schema := append(pivotSchema()[:3], reportColumn{Name: "day", Type: "string"}, reportColumn{Name: "hour", Type: "string"}, reportColumn{Name: "cost", Type: "float"}) + timeIdx, _, _ := classifyPivotColumns(schema) + cells := []interface{}{"svc", "2026", "08", "09", "01:00", 5.0} + if got := pivotPeriod(cells, timeIdx, schema); got != "2026-08-09 01:00" { + t.Errorf("hourly period = %q, want 2026-08-09 01:00", got) + } +} + +func TestShouldPivotReportRowsDefaults(t *testing.T) { + oldAgentMode := agentMode + t.Cleanup(func() { + agentMode = oldAgentMode + for _, key := range []string{"pivot-rows", "flat-rows", "rsh-output-format", "table-columns"} { + viper.Set(key, nil) + } + }) + reset := func(agent bool, output, columns string, pivot, flat bool) { + agentMode = agent + viper.Set("rsh-output-format", output) + viper.Set("table-columns", columns) + viper.Set("pivot-rows", pivot) + viper.Set("flat-rows", flat) + } + + reset(false, "table", "", false, false) + if !shouldPivotReportRows() { + t.Error("human table view should pivot by default") + } + reset(false, "table", "", false, true) + if shouldPivotReportRows() { + t.Error("--flat must disable the default pivot") + } + reset(false, "table", "cost,month", false, false) + if shouldPivotReportRows() { + t.Error("-C column selection must keep the flat layout") + } + reset(false, "json", "", false, false) + if shouldPivotReportRows() { + t.Error("machine formats must stay flat by default") + } + reset(true, "toon", "", false, false) + if shouldPivotReportRows() { + t.Error("agent mode must stay flat by default") + } + reset(true, "toon", "", true, false) + if !shouldPivotReportRows() { + t.Error("--pivot must force the pivot even in agent mode") + } +} + func TestPivotReportBodySkipsNonReportShapes(t *testing.T) { - if _, ok := pivotReportBody([]interface{}{"not-a-row"}, pivotSchema()); ok { + if _, ok := pivotReportBody([]interface{}{"not-a-row"}, pivotSchema(), true); ok { t.Error("pivot applied to malformed rows") } - if _, ok := pivotReportBody(nil, nil); ok { + if _, ok := pivotReportBody(nil, nil, true); ok { t.Error("pivot applied to empty input") } // No time columns → nothing to pivot. schema := []reportColumn{{Name: "service_description", Type: "string"}, {Name: "cost", Type: "float"}} - if _, ok := pivotReportBody([]interface{}{[]interface{}{"svc", 1.0}}, schema); ok { + if _, ok := pivotReportBody([]interface{}{[]interface{}{"svc", 1.0}}, schema, true); ok { t.Error("pivot applied without time columns") } } diff --git a/response_transform.go b/response_transform.go index 7b0d320..87b4cea 100644 --- a/response_transform.go +++ b/response_transform.go @@ -33,6 +33,24 @@ func transformSuccessBody(body interface{}) interface{} { return body } + // Hourly reports must keep the time-of-day everywhere: without this flag + // the table renderer would collapse the midnight row to a bare date while + // its siblings show hours. + viper.Set("report-hourly", hasHourColumn(schema)) + + // Currency context travels with the result: agents reading TOON/JSON get + // an explicit `currency` field, and the table renderer knows which + // symbol to print. Resolved from the query request config (the response + // itself carries no currency — an API gap tracked in the improvement + // plan). + if currency := requestCurrencyContext(); currency != "" { + if _, present := container["currency"]; !present { + container["currency"] = currency + } + viper.Set("report-currency", currency) + viper.Set("money-columns", strings.Join(moneyMetricColumns(schema), ",")) + } + sortReportRows(rows, schema) if !viper.GetBool("include-empty-rows") { @@ -44,8 +62,9 @@ func transformSuccessBody(body interface{}) interface{} { } } - if viper.GetBool("pivot-rows") { - if pivoted, ok := pivotReportBody(rows, schema); ok { + if shouldPivotReportRows() { + forced := viper.GetBool("pivot-rows") + if pivoted, ok := pivotReportBody(rows, schema, forced); ok { return pivoted } } @@ -68,6 +87,71 @@ func transformSuccessBody(body interface{}) interface{} { return body } +// requestReportCurrency is the currency resolved from the request body of the +// current invocation (set by preflight for query-style commands; "" when the +// command carries no report config). +var requestReportCurrency string + +func requestCurrencyContext() string { + return requestReportCurrency +} + +// moneyMetricColumns returns the schema columns that carry monetary values — +// float metrics whose name denotes money (cost, amortized_cost, amount, …); +// "usage" and other unit metrics stay plain numbers. +func moneyMetricColumns(schema []reportColumn) []string { + money := []string{} + for _, col := range schema { + if col.Type != "float" && col.Type != "number" { + continue + } + if moneyNamedColumn(col.Name) { + money = append(money, col.Name) + } + } + return money +} + +func hasHourColumn(schema []reportColumn) bool { + for _, col := range schema { + if strings.EqualFold(col.Name, "hour") { + return true + } + } + return false +} + +func moneyNamedColumn(name string) bool { + lower := strings.ToLower(name) + return strings.Contains(lower, "cost") || lower == "amount" || strings.Contains(lower, "spend") || strings.Contains(lower, "savings") +} + +// shouldPivotReportRows decides whether report rows render as a pivot. +// Explicit flags always win (--pivot forces it anywhere, --flat disables). +// Otherwise the pivot is the default *human* report view: table output in +// human mode with no explicit column selection (a -C selection addresses the +// flat columns, so it keeps the flat layout). Machine formats (json, yaml, +// csv, toon) and agent mode stay flat. +func shouldPivotReportRows() bool { + if viper.GetBool("pivot-rows") { + return true + } + if viper.GetBool("flat-rows") { + return false + } + if agentMode { + return false + } + // PreRun always resolves the output format; an empty value means the + // pipeline is running outside a normal command (tests, internal calls) + // where surprising a consumer with a pivot is worse than staying flat. + output := strings.TrimSpace(viper.GetString("rsh-output-format")) + if output != "table" && output != "auto" { + return false + } + return strings.TrimSpace(viper.GetString("table-columns")) == "" +} + // effectiveMaxRows resolves the report-row cap: an explicit --max-rows wins // (0 disables), otherwise agent mode defaults to 500 and human mode to // unlimited. diff --git a/skills/dci-cli/SKILL.md b/skills/dci-cli/SKILL.md index 3110d78..c47e200 100644 --- a/skills/dci-cli/SKILL.md +++ b/skills/dci-cli/SKILL.md @@ -1,13 +1,13 @@ --- name: dci-cli -description: Operate the DoiT Cloud Intelligence CLI (`dci`) for DoiT Cloud Intelligence workflows. Use when the agent needs to install or verify the CLI, authenticate, troubleshoot auth or `customerContext`, inspect capabilities, run read-only list/get/report/query commands, compose `dci query` JSON payloads, analyze cost/report output, or draft safe create/update/delete commands and payloads. +description: Operate the Cloud Intelligence™ CLI (`dci`) for Cloud Intelligence™ workflows. Use when the agent needs to install or verify the CLI, authenticate, troubleshoot auth or `customerContext`, inspect capabilities, run read-only list/get/report/query commands, compose `dci query` JSON payloads, analyze cost/report output, or draft safe create/update/delete commands and payloads. --- # DCI CLI ## Overview -Use `dci` as the primary interface for DoiT Cloud Intelligence CLI tasks. Prefer read-only discovery first, prefer `--output toon` (compact and token-efficient; `--output json` when you need standard JSON) for agent work, and use env-scoped `DCI_CUSTOMER_CONTEXT=` when switching customer context temporarily. +Use `dci` as the primary interface for Cloud Intelligence™ CLI tasks. Prefer read-only discovery first, prefer `--output toon` (compact and token-efficient; `--output json` when you need standard JSON) for agent work, and use env-scoped `DCI_CUSTOMER_CONTEXT=` when switching customer context temporarily. Set `DCI_AGENT_MODE=1` (or pass `--agent`) to run in agent mode: output defaults to compact TOON, terminal decoration is disabled, and banners/hints are routed to stderr so stdout stays parseable. `dci` also auto-detects common agent environments, so this is usually already on — run `dci status` to confirm. @@ -24,9 +24,10 @@ Use `--fields id,name` to project list or detail responses before output, and us - Report/query results are capped at 500 rows in agent mode. When the output contains `rowsOmitted`, the result was truncated: narrow the query with a group `limit` and a `metricFilter`, or pass `--max-rows ` (`--max-rows 0` for unlimited) when you genuinely need everything. Check `rowCount`/`rowsTotal` before dumping results into context. - Always include a group `limit` in query configs (e.g. top 10 by cost); unlimited grouped queries can return thousands of rows. - Use `--rows keyed` to receive `result.rows` as schema-named objects instead of positional arrays — no manual zipping with `result.schema`. -- Use `--pivot` for a report-style view: groups as rows, time periods as columns, with totals (best with `--output table` for humans). +- The interactive human table view pivots report results by default (groups × time periods with totals). Agent mode and machine formats stay flat; pass `--pivot` to force the pivot when presenting to a human, or `--flat` when a human-mode invocation needs flat rows. - Use `--output csv` to export list or report results for spreadsheets. - Rows with a null group and zero metrics are dropped by default; pass `--include-empty-rows` to keep them (`emptyRowsDropped` marks how many were removed). +- Report results include a `currency` field when the query config specifies one — always report monetary values with their currency. Human tables render known-currency amounts with the currency sign rounded to whole units; `--raw-numbers` restores exact values. ## Quick Start diff --git a/skills/dci-cli/agents/openai.yaml b/skills/dci-cli/agents/openai.yaml index f52d03d..4baf46e 100644 --- a/skills/dci-cli/agents/openai.yaml +++ b/skills/dci-cli/agents/openai.yaml @@ -3,5 +3,5 @@ # an agent config file. interface: display_name: "DCI CLI" - short_description: "Operate the DoiT Cloud Intelligence CLI" - default_prompt: "Use $dci-cli to inspect DoiT Cloud Intelligence CLI capabilities, troubleshoot auth or customer context, and generate safe dci commands and queries." + short_description: "Operate the Cloud Intelligence™ CLI" + default_prompt: "Use $dci-cli to inspect Cloud Intelligence™ CLI capabilities, troubleshoot auth or customer context, and generate safe dci commands and queries."