From 460dfe74a0dbae093b1c1cd1786ea91b52a016f5 Mon Sep 17 00:00:00 2001 From: Michael Finson Date: Mon, 3 Aug 2026 19:25:16 +0300 Subject: [PATCH] feat: add stable agent error contract --- README.md | 1 + error_contract.go | 293 ++++++++++++++++++++++++++++++++++++++++ error_contract_test.go | 245 +++++++++++++++++++++++++++++++++ main.go | 88 ++++++++++-- main_test.go | 29 ++++ skills/dci-cli/SKILL.md | 2 + 6 files changed, 643 insertions(+), 15 deletions(-) create mode 100644 error_contract.go create mode 100644 error_contract_test.go diff --git a/README.md b/README.md index 71e15d9..6149238 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,7 @@ that is cheap to parse and free of decoration: - Default `--output` becomes `toon` (compact, token-efficient) instead of `table` - No color, spinners, or other terminal decoration - Banners, tips, and status chatter go to **stderr**, leaving **stdout** for data only +- Explicit agent sessions emit machine-readable JSON errors with stable error codes, retry guidance, and distinct process exit codes - The request `User-Agent` carries a `mode=` token — `agent` (explicit flag/env or a known AI-agent environment), `noninteractive` (piped/redirected output or CI/CD), or `interactive` (human at a terminal) — so API traffic can be segmented by interface ### How agent mode is detected diff --git a/error_contract.go b/error_contract.go new file mode 100644 index 0000000..22048f6 --- /dev/null +++ b/error_contract.go @@ -0,0 +1,293 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/url" + "strings" + + "github.com/rest-sh/restish/cli" +) + +const ( + exitSuccess = 0 + exitGenericFailure = 1 + exitUsage = 2 + exitAuthentication = 10 + exitAuthorization = 11 + exitNotFound = 20 + exitConflict = 21 + exitValidation = 30 + exitServer = 40 + exitNetwork = 41 + exitRateLimited = 50 +) + +var ( + responseExitCode int + agentErrorWritten bool +) + +func resetErrorContractState() { + responseExitCode = 0 + agentErrorWritten = false +} + +type structuredErrorEnvelope struct { + Error structuredError `json:"error"` +} + +type structuredError struct { + Code string `json:"code"` + Message string `json:"message"` + Hint string `json:"hint,omitempty"` + Retryable bool `json:"retryable"` + HTTPStatus int `json:"http_status,omitempty"` + RequestID string `json:"request_id,omitempty"` + RetryAfter string `json:"retry_after,omitempty"` +} + +type agentErrorDescriptor interface { + AgentErrorCode() string + AgentErrorHint() string + AgentErrorRetryable() bool +} + +func exitCodeForHTTPStatus(status int) int { + switch status { + case 0: + return exitSuccess + case 400, 422: + return exitValidation + case 401: + return exitAuthentication + case 403: + return exitAuthorization + case 404: + return exitNotFound + case 409: + return exitConflict + case 429: + return exitRateLimited + } + if status >= 500 { + return exitServer + } + if status >= 400 { + return exitGenericFailure + } + return exitSuccess +} + +func exitCodeForExecutionError(err error, status int) int { + var codedError interface{ ExitCode() int } + if errors.As(err, &codedError) { + return codedError.ExitCode() + } + if code := exitCodeForHTTPStatus(status); code != exitSuccess { + return code + } + var networkError net.Error + var urlError *url.Error + if errors.As(err, &networkError) || errors.As(err, &urlError) { + return exitNetwork + } + if isUsageError(err) { + return exitUsage + } + return exitGenericFailure +} + +func isSilentExecutionError(err error) bool { + var silentError interface{ Silent() bool } + return errors.As(err, &silentError) && silentError.Silent() +} + +func isUsageError(err error) bool { + if err == nil { + return false + } + message := strings.ToLower(err.Error()) + for _, fragment := range []string{ + "unknown command", + "unknown flag", + "unknown shorthand flag", + "invalid argument", + "invalid --output", + "required flag", + "requires at least", + "requires exactly", + "accepts ", + } { + if strings.Contains(message, fragment) { + return true + } + } + return false +} + +func structuredErrorForExecution(err error, status int) structuredError { + var errorProvider interface{ StructuredError() structuredError } + if errors.As(err, &errorProvider) { + return errorProvider.StructuredError() + } + var descriptor agentErrorDescriptor + if errors.As(err, &descriptor) { + return structuredError{ + Code: descriptor.AgentErrorCode(), + Message: err.Error(), + Hint: descriptor.AgentErrorHint(), + Retryable: descriptor.AgentErrorRetryable(), + } + } + if status >= 400 { + return structuredErrorForStatus(status, err.Error(), nil) + } + if exitCodeForExecutionError(err, status) == exitNetwork { + return structuredError{ + Code: "NETWORK_ERROR", + Message: err.Error(), + Hint: "Check network connectivity and retry", + Retryable: true, + } + } + if isUsageError(err) { + return structuredError{ + Code: "USAGE_ERROR", + Message: err.Error(), + Hint: "Run the command with --help to inspect its arguments and flags", + Retryable: false, + } + } + return structuredError{ + Code: "CLI_ERROR", + Message: err.Error(), + Retryable: false, + } +} + +func structuredErrorForResponse(resp cli.Response) structuredError { + message := responseErrorMessage(resp.Body) + if message == "" { + message = fmt.Sprintf("DoiT API request failed with HTTP status %d", resp.Status) + } + return structuredErrorForStatus(resp.Status, message, resp.Headers) +} + +func structuredErrorForStatus(status int, message string, headers map[string]string) structuredError { + result := structuredError{ + Code: "API_ERROR", + Message: message, + HTTPStatus: status, + RequestID: requestID(headers), + } + switch status { + case 400, 422: + result.Code = "VALIDATION_ERROR" + result.Hint = "Review the request arguments and payload" + case 401: + result.Code = "AUTHENTICATION_FAILED" + result.Hint = "Run: dci login" + case 403: + result.Code = "PERMISSION_DENIED" + result.Hint = "Check the active customer context and your DoiT permissions" + case 404: + result.Code = "RESOURCE_NOT_FOUND" + case 409: + result.Code = "RESOURCE_CONFLICT" + case 429: + result.Code = "RATE_LIMITED" + result.Hint = "Retry after the server-provided delay" + result.Retryable = true + result.RetryAfter = firstHeaderValue(headers, "Retry-After", "X-Retry-In") + default: + if status >= 500 { + result.Code = "API_SERVER_ERROR" + result.Hint = "Retry the request; contact DoiT support if the error persists" + result.Retryable = true + } + } + return result +} + +func responseErrorMessage(body interface{}) string { + switch value := body.(type) { + case string: + return strings.TrimSpace(value) + case []byte: + return strings.TrimSpace(string(value)) + case map[string]interface{}: + for _, key := range []string{"message", "detail", "error_description"} { + if message, ok := value[key].(string); ok && strings.TrimSpace(message) != "" { + return strings.TrimSpace(message) + } + } + switch nested := value["error"].(type) { + case string: + return strings.TrimSpace(nested) + case map[string]interface{}: + for _, key := range []string{"message", "detail", "code"} { + if message, ok := nested[key].(string); ok && strings.TrimSpace(message) != "" { + return strings.TrimSpace(message) + } + } + } + } + return "" +} + +func requestID(headers map[string]string) string { + for _, name := range []string{"X-Request-Id", "X-Doit-Trace", "Cf-Ray", "X-Cloud-Trace-Context", "Traceparent"} { + if value := strings.TrimSpace(headerValue(headers, name)); value != "" { + return value + } + } + return "" +} + +func firstHeaderValue(headers map[string]string, names ...string) string { + for _, name := range names { + if value := strings.TrimSpace(headerValue(headers, name)); value != "" { + return value + } + } + return "" +} + +func writeStructuredError(writer io.Writer, detail structuredError) { + agentErrorWritten = true + _ = json.NewEncoder(writer).Encode(structuredErrorEnvelope{Error: detail}) +} + +func agentErrorContractEnabled() bool { + return agentMode && agentUAMode != uaModeNonInteractive +} + +func executeCLI() error { + return executeCLIWith(cli.Run) +} + +func executeCLIWith(run func() error) error { + if !agentErrorContractEnabled() { + return run() + } + + 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 +} diff --git a/error_contract_test.go b/error_contract_test.go new file mode 100644 index 0000000..9b95bc9 --- /dev/null +++ b/error_contract_test.go @@ -0,0 +1,245 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "testing" + + "github.com/rest-sh/restish/cli" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +type describedAgentError struct{} + +func (describedAgentError) Error() string { return "described failure" } +func (describedAgentError) AgentErrorCode() string { return "DESCRIBED_FAILURE" } +func (describedAgentError) AgentErrorHint() string { return "review the operation" } +func (describedAgentError) AgentErrorRetryable() bool { return false } + +func TestExitCodeForHTTPStatus(t *testing.T) { + tests := []struct { + status int + want int + }{ + {status: 200, want: exitSuccess}, + {status: 400, want: exitValidation}, + {status: 401, want: exitAuthentication}, + {status: 403, want: exitAuthorization}, + {status: 404, want: exitNotFound}, + {status: 409, want: exitConflict}, + {status: 429, want: exitRateLimited}, + {status: 503, want: exitServer}, + } + for _, test := range tests { + if got := exitCodeForHTTPStatus(test.status); got != test.want { + t.Errorf("exitCodeForHTTPStatus(%d) = %d, want %d", test.status, got, test.want) + } + } +} + +func TestStructuredErrorForResponse(t *testing.T) { + response := cli.Response{ + Status: 429, + Headers: map[string]string{ + "Retry-After": "30", + "X-Request-Id": "request-123", + }, + Body: map[string]interface{}{"message": "too many requests"}, + } + detail := structuredErrorForResponse(response) + if detail.Code != "RATE_LIMITED" || !detail.Retryable { + t.Fatalf("unexpected error classification: %+v", detail) + } + if detail.RetryAfter != "30" || detail.RequestID != "request-123" { + t.Fatalf("unexpected retry metadata: %+v", detail) + } + if detail.Message != "too many requests" { + t.Fatalf("message = %q", detail.Message) + } +} + +func TestStructuredErrorForExecutionUsesPortableDescriptor(t *testing.T) { + detail := structuredErrorForExecution(describedAgentError{}, 0) + if detail.Code != "DESCRIBED_FAILURE" || detail.Message != "described failure" { + t.Fatalf("unexpected error detail: %+v", detail) + } + if detail.Hint != "review the operation" || detail.Retryable { + t.Fatalf("unexpected descriptor metadata: %+v", detail) + } +} + +func TestUnknownShorthandFlagIsUsageError(t *testing.T) { + err := errors.New("unknown shorthand flag: 'z' in -z") + if got := exitCodeForExecutionError(err, 0); got != exitUsage { + t.Fatalf("exit code = %d, want %d", got, exitUsage) + } + if detail := structuredErrorForExecution(err, 0); detail.Code != "USAGE_ERROR" { + t.Fatalf("error code = %q, want USAGE_ERROR", detail.Code) + } +} + +func TestAcceptedDoerLoginClearsValidationFailure(t *testing.T) { + responseExitCode = exitAuthorization + agentErrorWritten = true + viper.Set("rsh-ignore-status-code", false) + t.Cleanup(func() { + resetErrorContractState() + viper.Set("rsh-ignore-status-code", false) + }) + + acceptDoerLoginValidation() + + if responseExitCode != exitSuccess { + t.Fatalf("response exit code = %d, want %d", responseExitCode, exitSuccess) + } + if agentErrorWritten { + t.Fatal("agent error remains marked as written") + } + if got := exitCodeForProcessStatus(403); got != exitSuccess { + t.Fatalf("process exit code = %d, want %d", got, exitSuccess) + } +} + +func TestAgentResponseGuardWritesOneStructuredError(t *testing.T) { + oldAgentMode := agentMode + oldAgentUAMode := agentUAMode + oldStderr := cli.Stderr + agentMode = true + agentUAMode = uaModeAgent + agentErrorWritten = false + responseExitCode = 0 + t.Cleanup(func() { + agentMode = oldAgentMode + agentUAMode = oldAgentUAMode + cli.Stderr = oldStderr + agentErrorWritten = false + responseExitCode = 0 + }) + + var stderr bytes.Buffer + cli.Stderr = &stderr + next := &recordingFormatter{} + guard := dciResponseGuard{next: next} + err := guard.Format(cli.Response{ + Status: 403, + Headers: map[string]string{"X-Request-Id": "request-403"}, + Body: map[string]interface{}{"message": "access denied"}, + }) + if err != nil { + t.Fatal(err) + } + if next.called { + t.Fatal("formatter received an agent-mode error response") + } + if responseExitCode != exitAuthorization { + t.Fatalf("responseExitCode = %d", responseExitCode) + } + var envelope structuredErrorEnvelope + if err := json.Unmarshal(stderr.Bytes(), &envelope); err != nil { + t.Fatalf("invalid JSON error %q: %v", stderr.String(), err) + } + if envelope.Error.Code != "PERMISSION_DENIED" || envelope.Error.RequestID != "request-403" { + t.Fatalf("unexpected envelope: %+v", envelope) + } +} + +func TestExecuteCLISuppressesFrameworkErrorsInAgentMode(t *testing.T) { + oldAgentMode := agentMode + oldAgentUAMode := agentUAMode + oldRoot := cli.Root + oldStderr := cli.Stderr + agentMode = true + agentUAMode = uaModeAgent + agentErrorWritten = false + cli.Root = &cobra.Command{} + var stderr bytes.Buffer + cli.Stderr = &stderr + t.Cleanup(func() { + agentMode = oldAgentMode + agentUAMode = oldAgentUAMode + cli.Root = oldRoot + cli.Stderr = oldStderr + agentErrorWritten = false + }) + + wantErr := errors.New("blocked") + err := executeCLIWith(func() error { + _, _ = io.WriteString(cli.Stderr, "framework noise") + return wantErr + }) + if !errors.Is(err, wantErr) { + t.Fatalf("error = %v, want %v", err, wantErr) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } + if !cli.Root.SilenceErrors || !cli.Root.SilenceUsage { + t.Fatal("cobra errors and usage remain enabled") + } +} + +func TestNonInteractiveResponsePreservesFormatterOutput(t *testing.T) { + oldAgentMode := agentMode + oldAgentUAMode := agentUAMode + oldStderr := cli.Stderr + agentMode = true + agentUAMode = uaModeNonInteractive + agentErrorWritten = false + responseExitCode = 0 + var stderr bytes.Buffer + cli.Stderr = &stderr + t.Cleanup(func() { + agentMode = oldAgentMode + agentUAMode = oldAgentUAMode + cli.Stderr = oldStderr + resetErrorContractState() + }) + + next := &recordingFormatter{} + guard := dciResponseGuard{next: next} + if err := guard.Format(cli.Response{ + Status: 403, + Body: map[string]interface{}{"message": "access denied"}, + }); err != nil { + t.Fatal(err) + } + if !next.called { + t.Fatal("non-interactive response body was not sent to the formatter") + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q", stderr.String()) + } + if responseExitCode != 0 || agentErrorWritten { + t.Fatal("agent error contract changed non-interactive response state") + } +} + +func TestNonInteractiveExecutionKeepsFrameworkOutput(t *testing.T) { + oldAgentMode := agentMode + oldAgentUAMode := agentUAMode + oldStderr := cli.Stderr + agentMode = true + agentUAMode = uaModeNonInteractive + var stderr bytes.Buffer + cli.Stderr = &stderr + t.Cleanup(func() { + agentMode = oldAgentMode + agentUAMode = oldAgentUAMode + cli.Stderr = oldStderr + }) + + wantErr := errors.New("blocked") + err := executeCLIWith(func() error { + _, _ = io.WriteString(cli.Stderr, "framework error") + return wantErr + }) + if !errors.Is(err, wantErr) { + t.Fatalf("error = %v, want %v", err, wantErr) + } + if stderr.String() != "framework error" { + t.Fatalf("stderr = %q", stderr.String()) + } +} diff --git a/main.go b/main.go index fc3ad90..13cd20c 100644 --- a/main.go +++ b/main.go @@ -222,6 +222,7 @@ func run() (exitCode int) { // Reset per-invocation state so repeated calls (e.g. in tests) start clean. customerContextFlagValue = "" nonJSONErrorResponse = false + resetErrorContractState() // Resolve agent mode once up front. Downstream behavior — color, default // output format, stderr routing, and the User-Agent mode token — all key off @@ -255,8 +256,7 @@ func run() (exitCode int) { configDir := dciConfigDir() configured, err := ensureConfig(configDir) if err != nil { - fmt.Fprintf(os.Stderr, "failed to initialize config: %v\n", err) - return 1 + return reportExecutionError(fmt.Errorf("failed to initialize config: %w", err), 0, configDir) } // Kick off the update check now so it runs in parallel with the command; @@ -276,8 +276,7 @@ func run() (exitCode int) { cli.AddAuth("oauth-authorization-code", &oauth.AuthorizationCodeHandler{}) if err := rejectProfileFlags(os.Args); err != nil { - fmt.Fprintf(os.Stderr, "%v\n", err) - return 1 + return reportExecutionError(err, 0, configDir) } // Keep profile fixed until we support multi-profile UX. os.Setenv("RSH_PROFILE", "default") @@ -316,20 +315,51 @@ func run() (exitCode int) { setupCompletion() os.Args = normalizeArgs(os.Args) - if err := cli.Run(); err != nil { - fmt.Fprintf(os.Stderr, "%v\n", err) - maybeHintDoerContext(1, cli.GetLastStatus(), configDir) - return 1 + if err := executeCLI(); err != nil { + return reportExecutionError(err, cli.GetLastStatus(), configDir) } code := cli.GetExitCode() - // Force a non-zero exit when a 2xx response carried an error page/body. + if agentErrorContractEnabled() { + code = exitCodeForProcessStatus(cli.GetLastStatus()) + if responseExitCode != 0 { + code = responseExitCode + } + } if code == 0 && nonJSONErrorResponse { - code = 1 + if agentErrorContractEnabled() { + code = exitServer + } else { + code = 1 + } } maybeHintDoerContext(code, cli.GetLastStatus(), configDir) return code } +func reportExecutionError(err error, status int, configDir string) int { + if !agentErrorContractEnabled() { + fmt.Fprintf(os.Stderr, "%v\n", err) + maybeHintDoerContext(1, status, configDir) + return 1 + } + code := exitCodeForExecutionError(err, status) + if code == exitSuccess && isSilentExecutionError(err) { + return exitSuccess + } + if !agentErrorWritten { + writeStructuredError(os.Stderr, structuredErrorForExecution(err, status)) + } + maybeHintDoerContext(code, status, configDir) + return code +} + +func exitCodeForProcessStatus(status int) int { + if viper.GetBool("rsh-ignore-status-code") { + return exitSuccess + } + return exitCodeForHTTPStatus(status) +} + func rejectProfileFlags(args []string) error { flags := cli.Root.PersistentFlags() @@ -339,7 +369,7 @@ func rejectProfileFlags(args []string) error { return nil } if arg == "--profile" || arg == "--rsh-profile" || strings.HasPrefix(arg, "--profile=") || strings.HasPrefix(arg, "--rsh-profile=") { - return fmt.Errorf("profile selection is currently disabled") + return fmt.Errorf("invalid argument: profile selection is currently disabled") } if !strings.HasPrefix(arg, "-") || strings.HasPrefix(arg, "--") || arg == "-" { continue @@ -1160,7 +1190,7 @@ func authSource() string { // without a customer context set — covering both interactive and CI/CD usage. // status is the HTTP status code from the last request (pass cli.GetLastStatus()). func maybeHintDoerContext(exitCode int, status int, configDir string) { - if exitCode == 0 || (status != 401 && status != 403) { + if agentErrorContractEnabled() || exitCode == 0 || (status != 401 && status != 403) { return } if !cachedTokenIsDoer() { @@ -1262,9 +1292,8 @@ func registerAuthCommands(configDir string) { // token exchange succeeds (token is cached) even when validate returns 403, // so we can inspect the token here and fix the chicken-and-egg problem. if applyDoerContext(configDir) { - err = nil // the 403 was due to missing context; auth itself succeeded - // Reset the HTTP status so GetExitCode() returns 0 for this process. - viper.Set("rsh-ignore-status-code", true) + err = nil + acceptDoerLoginValidation() } if err != nil { @@ -1299,6 +1328,12 @@ func registerAuthCommands(configDir string) { }) } +func acceptDoerLoginValidation() { + responseExitCode = 0 + agentErrorWritten = false + viper.Set("rsh-ignore-status-code", true) +} + // customerContextPath returns the path to the custom file that stores the // default customer context. We use a dedicated file instead of restish's // apis.json profile query params because restish's config internals are @@ -1504,11 +1539,29 @@ type dciResponseGuard struct { func (g dciResponseGuard) Format(resp cli.Response) error { if isHTMLErrorPage(resp) { nonJSONErrorResponse = true + if agentErrorContractEnabled() { + responseExitCode = exitServer + detail := structuredErrorForResponse(resp) + detail.Code = "UPSTREAM_NON_JSON_RESPONSE" + detail.Message = "The DoiT API returned a non-JSON response" + detail.Hint = "Retry the request; contact DoiT support with the request ID if it persists" + detail.Retryable = true + writeStructuredError(cli.Stderr, detail) + return nil + } printNonJSONError(resp) return nil } if msg, ok := jsonApplicationError(resp); ok { nonJSONErrorResponse = true + if agentErrorContractEnabled() { + responseExitCode = exitServer + detail := structuredErrorForResponse(resp) + detail.Code = "APPLICATION_ERROR" + detail.Message = msg + writeStructuredError(cli.Stderr, detail) + return nil + } if err := g.next.Format(resp); err != nil { return err } @@ -1516,6 +1569,11 @@ func (g dciResponseGuard) Format(resp cli.Response) error { fmt.Fprintf(cli.Stderr, "Error: the DoiT API returned an application error: %s\n", msg) return nil } + if agentErrorContractEnabled() && resp.Status >= 400 { + responseExitCode = exitCodeForHTTPStatus(resp.Status) + writeStructuredError(cli.Stderr, structuredErrorForResponse(resp)) + return nil + } return g.next.Format(resp) } diff --git a/main_test.go b/main_test.go index 6910ccd..606d8b0 100644 --- a/main_test.go +++ b/main_test.go @@ -680,6 +680,21 @@ func TestCLIIntegrationBehavior(t *testing.T) { } }) + t.Run("agent profile rejection is structured", func(t *testing.T) { + res := runCLIWithEnv(t, bin, t.TempDir(), []string{"DCI_AGENT_MODE=1"}, "--profile", "other", "status") + assertStructuredCLIError(t, res, exitUsage, "USAGE_ERROR") + }) + + t.Run("agent config rejection is structured", func(t *testing.T) { + res := runCLIWithEnv(t, bin, t.TempDir(), []string{"DCI_AGENT_MODE=1", "DCI_API_BASE_URL=http://example.com"}, "status") + assertStructuredCLIError(t, res, exitGenericFailure, "CLI_ERROR") + }) + + t.Run("agent shorthand flag rejection is usage error", func(t *testing.T) { + res := runCLIWithEnv(t, bin, t.TempDir(), []string{"DCI_AGENT_MODE=1"}, "-z") + assertStructuredCLIError(t, res, exitUsage, "USAGE_ERROR") + }) + t.Run("completion help stays offline", func(t *testing.T) { res := runCLI(t, bin, "completion", "--help") if res.timedOut { @@ -821,6 +836,20 @@ func assertNoOAuthOrPanic(t *testing.T, out string) { } } +func assertStructuredCLIError(t *testing.T, result cliResult, expectedExit int, expectedCode string) { + t.Helper() + if result.exitCode != expectedExit { + t.Fatalf("exit code = %d, want %d; output:\n%s", result.exitCode, expectedExit, result.output) + } + var envelope structuredErrorEnvelope + if err := json.Unmarshal([]byte(strings.TrimSpace(result.output)), &envelope); err != nil { + t.Fatalf("output is not one JSON envelope: %v\n%s", err, result.output) + } + if envelope.Error.Code != expectedCode { + t.Fatalf("error code = %q, want %q", envelope.Error.Code, expectedCode) + } +} + func assertRootHelpBranded(t *testing.T, out string) { t.Helper() if strings.Contains(out, "A generic client for REST-ish APIs") { diff --git a/skills/dci-cli/SKILL.md b/skills/dci-cli/SKILL.md index 061a09a..96b39cc 100644 --- a/skills/dci-cli/SKILL.md +++ b/skills/dci-cli/SKILL.md @@ -11,6 +11,8 @@ Use `dci` as the primary interface for DoiT Cloud Intelligence CLI tasks. Prefer 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. +In explicit agent mode, failures are written to stderr as a JSON `error` envelope with a stable `code`, `message`, and `retryable` value. Inspect optional `hint`, `http_status`, `request_id`, and `retry_after` fields before deciding whether to correct the request or retry it. + TOON list output folds rows into a compact table, and columns whose values are nested objects (e.g. `labels` on reports, `alertThresholds` on budgets) are omitted by default. To include one, request it explicitly: `-C id,labels` selects exactly those columns, or a custom `-f` filter keeps every field it projects. Explicitly requested object values arrive as compact JSON strings inside the cell. For the complete nested structure, use the item's `get-*` command or `--output json`. ## Quick Start