Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
4 changes: 2 additions & 2 deletions Formula/dci.rb
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
48 changes: 44 additions & 4 deletions body_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, "<") {
Expand All @@ -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)
Expand Down Expand Up @@ -152,13 +156,49 @@ 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
}
return jsonTopLevelFields(trimmedData), true
}

func extractRequestCurrency(validFields map[string]bool, bodyArguments []string, stdinBody []byte) string {
if !validFields["config"] {
return ""
}
for _, argument := range bodyArguments {
Comment thread
chaim0m marked this conversation as resolved.
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
Expand Down
27 changes: 27 additions & 0 deletions body_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
2 changes: 1 addition & 1 deletion bucket/dci.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
87 changes: 75 additions & 12 deletions error_contract.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"net"
"net/url"
"os"
"regexp"
"strings"

"github.com/rest-sh/restish/cli"
Expand Down Expand Up @@ -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: <msg>"
// 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, "")
}
40 changes: 40 additions & 0 deletions error_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"testing"
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading