From 828d49f4dd1790274180fe74cc03170f436880b9 Mon Sep 17 00:00:00 2001 From: Michael Finson Date: Tue, 11 Aug 2026 16:52:54 +0300 Subject: [PATCH 1/3] feat: add CLI discovery and FinOps workflows [CMP-48642] --- docs_command_test.go | 59 +++++ main.go | 66 +++++- main_test.go | 29 +++ open_command.go | 219 +++++++++++++++++++ open_command_test.go | 139 ++++++++++++ skills/dci-cli/SKILL.md | 10 +- skills/dci-cli/references/examples.md | 9 + skills/dci-cli/references/finops-baseline.md | 79 +++++++ 8 files changed, 608 insertions(+), 2 deletions(-) create mode 100644 open_command.go create mode 100644 open_command_test.go create mode 100644 skills/dci-cli/references/finops-baseline.md diff --git a/docs_command_test.go b/docs_command_test.go index 6526414..ace85dc 100644 --- a/docs_command_test.go +++ b/docs_command_test.go @@ -2,6 +2,8 @@ package main import ( "bytes" + "os" + "path/filepath" "strings" "testing" ) @@ -37,3 +39,60 @@ func TestDocsCommandRejectsArguments(t *testing.T) { t.Fatal("docs command accepted an argument") } } + +func TestAgentOnboardingHintShowsOnce(t *testing.T) { + oldAgentMode := agentMode + oldEnvDetected := agentEnvDetected + agentMode = true + agentEnvDetected = "TEST_AGENT" + t.Cleanup(func() { + agentMode = oldAgentMode + agentEnvDetected = oldEnvDetected + }) + + configDir := t.TempDir() + + captureStderr := func(fn func()) string { + old := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + fn() + if err := w.Close(); err != nil { + t.Fatalf("close stderr writer: %v", err) + } + os.Stderr = old + buf := make([]byte, 4096) + n, _ := r.Read(buf) + return string(buf[:n]) + } + + first := captureStderr(func() { maybeAgentOnboardingHint(configDir) }) + if !strings.Contains(first, "dci skill") || !strings.Contains(first, "llms.txt") { + t.Fatalf("first hint = %q, want skill and llms.txt pointers", first) + } + if _, err := os.Stat(filepath.Join(configDir, "agent_onboarding_shown")); err != nil { + t.Fatalf("marker file not written: %v", err) + } + + second := captureStderr(func() { maybeAgentOnboardingHint(configDir) }) + if second != "" { + t.Fatalf("second run printed %q, want silence", second) + } +} + +func TestAgentOnboardingHintSkippedInHumanMode(t *testing.T) { + oldAgentMode := agentMode + oldEnvDetected := agentEnvDetected + agentMode = false + agentEnvDetected = "" + t.Cleanup(func() { + agentMode = oldAgentMode + agentEnvDetected = oldEnvDetected + }) + + configDir := t.TempDir() + maybeAgentOnboardingHint(configDir) + if _, err := os.Stat(filepath.Join(configDir, "agent_onboarding_shown")); err == nil { + t.Fatal("marker written in human mode") + } +} diff --git a/main.go b/main.go index 9f823f3..af0e476 100644 --- a/main.go +++ b/main.go @@ -225,6 +225,7 @@ func run() (exitCode int) { // Reset per-invocation state so repeated calls (e.g. in tests) start clean. customerContextFlagValue = "" resolvedCustomerContext = "" + helpFullRequested = false requestReportCurrency = "" bufferedRequestBody = nil nonJSONErrorResponse = false @@ -307,6 +308,7 @@ func run() (exitCode int) { registerUpgradeCommand(configDir) registerVersionCommand() registerDocsCommand() + registerOpenCommand(configDir) registerSkillCommands() registerCommandCatalog() if cachedTokenIsDoer() { @@ -323,6 +325,7 @@ func run() (exitCode int) { applyCustomerContext(configDir) lockToDCI() setupCompletion() + os.Args = rewriteHelpFullFlag(os.Args) os.Args = normalizeArgs(os.Args) if err := preflightAPIInvocation(os.Args); err != nil { return reportExecutionError(err, 0, configDir) @@ -341,6 +344,10 @@ func run() (exitCode int) { code = exitServer } maybeHintDoerContext(code, cli.GetLastStatus(), configDir) + if code == 0 { + // Success only: failure stderr must stay a single parseable envelope. + maybeAgentOnboardingHint(configDir) + } return code } @@ -419,6 +426,33 @@ func rejectProfileFlags(args []string) error { return nil } +var helpFullRequested bool + +func rewriteHelpFullFlag(args []string) []string { + out := make([]string, 0, len(args)) + for _, arg := range args { + if arg == "--help-full" { + helpFullRequested = true + out = append(out, "--help") + continue + } + out = append(out, arg) + } + return out +} + +func terseHelpText(long string) (string, bool) { + idx := strings.Index(long, "## ") + if idx < 0 { + return long, false + } + head := strings.TrimSpace(long[:idx]) + if head == "" { + head = "(no description)" + } + return head + "\n\nSchemas and examples: add --help-full", true +} + func normalizeArgs(args []string) []string { if len(args) <= 1 { return []string{args[0], "--help"} @@ -792,6 +826,28 @@ func maybeHintAgentMode() { fmt.Fprintln(os.Stderr, "Tip: set DCI_AGENT_MODE=1 (or pass --agent) for compact, parse-friendly output.") } +// maybeAgentOnboardingHint prints a one-time stderr pointer when an agent +// environment is first seen with this config dir, so agents discover the +// embedded skill, the machine-readable catalog, and the docs without being +// told. Stderr only — stdout must stay parseable — and marker-gated so it +// never becomes per-command chatter. +func maybeAgentOnboardingHint(configDir string) { + if !agentMode || agentEnvDetected == "" { + return + } + marker := filepath.Join(configDir, "agent_onboarding_shown") + if _, err := os.Stat(marker); err == nil { + return + } + if err := os.WriteFile(marker, []byte(time.Now().UTC().Format(time.RFC3339)+"\n"), 0o600); err != nil { + return // cannot persist the marker; stay silent rather than repeat forever + } + fmt.Fprintln(os.Stderr, "Agent mode is active. Useful entry points:") + fmt.Fprintln(os.Stderr, " dci skill install CLI usage guidance for this agent (claude, codex, cursor, gemini, kiro, opencode)") + fmt.Fprintln(os.Stderr, " dci commands --json machine-readable command catalog (args, flags, destructive metadata)") + fmt.Fprintln(os.Stderr, " dci docs documentation entry points, incl. https://help.doit.com/llms.txt") +} + const dciUsageTemplate = `Usage:{{if .Runnable}} {{.Use}}{{if .HasAvailableFlags}} [flags]{{end}}{{end}}{{if .HasAvailableSubCommands}} dci [command] @@ -822,7 +878,8 @@ Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}} ` 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." + "Documentation: https://help.doit.com/docs/cli or run `dci docs` for every entry point.\n" + + "AI agents: `dci skill ` installs usage guidance; `dci commands --json` prints the machine-readable catalog." var rootExamples = []string{ " dci status", @@ -994,6 +1051,13 @@ func setupCompletion() { defaultHelp := cli.Root.HelpFunc() cli.Root.SetHelpFunc(func(cmd *cobra.Command, args []string) { sanitizeFlagPlaceholders(cmd) + if !helpFullRequested { + if terse, truncated := terseHelpText(cmd.Long); truncated { + originalLong := cmd.Long + cmd.Long = terse + defer func() { cmd.Long = originalLong }() + } + } hasAPICommands := false if cmd == cli.Root { loadAPI() diff --git a/main_test.go b/main_test.go index f73acb5..cb19a7b 100644 --- a/main_test.go +++ b/main_test.go @@ -1331,6 +1331,34 @@ func TestBuildTableStringWithHiddenColumnsIncluded(t *testing.T) { } } +func TestTerseHelpText(t *testing.T) { + long := "Runs a report query.\n## Input Example\n```json\n{}\n```\n## Request Schema\n..." + terse, truncated := terseHelpText(long) + if !truncated { + t.Fatal("schema-bearing help not truncated") + } + if strings.Contains(terse, "## ") || !strings.Contains(terse, "--help-full") { + t.Errorf("terse = %q, want schemas stripped and --help-full pointer", terse) + } + if _, truncated := terseHelpText("plain description"); truncated { + t.Error("plain help truncated") + } +} + +func TestRewriteHelpFullFlag(t *testing.T) { + oldRequested := helpFullRequested + helpFullRequested = false + t.Cleanup(func() { helpFullRequested = oldRequested }) + + args := rewriteHelpFullFlag([]string{"dci", "query", "--help-full"}) + if !helpFullRequested { + t.Error("--help-full not detected") + } + if args[2] != "--help" { + t.Errorf("args = %v, want --help substituted", args) + } +} + func TestFormatValueNilIsEmptyCell(t *testing.T) { if got := formatValue(nil); got != "" { t.Errorf("formatValue(nil) = %q, want empty", got) @@ -1945,6 +1973,7 @@ var expectedSkillFiles = []string{ "skills/dci-cli/references/cost-optimization.md", "skills/dci-cli/references/evals.md", "skills/dci-cli/references/examples.md", + "skills/dci-cli/references/finops-baseline.md", "skills/dci-cli/references/query-patterns.md", } diff --git a/open_command.go b/open_command.go new file mode 100644 index 0000000..3ecf574 --- /dev/null +++ b/open_command.go @@ -0,0 +1,219 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/url" + "os" + "os/exec" + "runtime" + "sort" + "strings" + "time" + + "github.com/rest-sh/restish/cli" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "golang.org/x/term" +) + +// consoleBaseURL is where deep links land. The API host is configurable via +// DCI_API_BASE_URL for testing, but console links always target production. +const consoleBaseURL = "https://console.doit.com" + +var consoleResourcePaths = map[string]string{ + "report": "analyze/reports", + "budget": "monitor/budgets", + "allocation": "operate/allocations", +} + +var consoleCustomerIDResolver = resolveConsoleCustomerID +var consoleHTTPClient = &http.Client{Timeout: 10 * time.Second} + +func registerOpenCommand(configDir string) { + resources := make([]string, 0, len(consoleResourcePaths)) + for r := range consoleResourcePaths { + resources = append(resources, r) + } + sort.Strings(resources) + + cmd := &cobra.Command{ + Use: "open [resource] [id]", + Short: "Open the DoiT console (optionally a specific report, budget, or allocation)", + Long: "Deep-links into the DoiT console for the active customer: `dci open` lands on the console home, " + + "`dci open report ` (also: budget, allocation) opens the resource. " + + "Opens a browser in interactive use; prints the URL in agent or non-interactive mode.", + Args: cobra.RangeArgs(0, 2), + RunE: func(cmd *cobra.Command, args []string) error { + customerID, err := consoleCustomerID(configDir) + if err != nil { + return err + } + + consoleURL := fmt.Sprintf("%s/customers/%s", consoleBaseURL, customerID) + switch len(args) { + case 1: + return fmt.Errorf("usage: dci open <%s> ", strings.Join(resources, "|")) + case 2: + resourceURL, ok := consoleResourceURL(customerID, args[0], args[1]) + if !ok { + return fmt.Errorf("unknown resource %q (supported: %s)", args[0], strings.Join(resources, ", ")) + } + consoleURL = resourceURL + } + + if agentMode || !term.IsTerminal(int(os.Stdout.Fd())) { + _, err := fmt.Fprintln(cmd.OutOrStdout(), consoleURL) + return err + } + if err := openInBrowser(consoleURL); err != nil { + _, writeErr := fmt.Fprintln(cmd.OutOrStdout(), consoleURL) + return writeErr + } + return nil + }, + } + cli.Root.AddCommand(cmd) +} + +func consoleResourceURL(customerID, resource, resourceID string) (string, bool) { + path, ok := consoleResourcePaths[strings.ToLower(resource)] + if !ok { + return "", false + } + return fmt.Sprintf("%s/customers/%s/%s/%s", consoleBaseURL, customerID, path, resourceID), true +} + +func consoleCustomerID(configDir string) (string, error) { + context := activeCustomerContext() + if context == "" { + context = readCustomerContext(configDir) + } + if context != "" { + if looksLikeCustomerID(context) { + return context, nil + } + return consoleCustomerIDResolver(context) + } + if customerID := tokenCustomerID(); customerID != "" { + return customerID, nil + } + return consoleCustomerIDResolver("") +} + +func looksLikeCustomerID(s string) bool { + return len(s) >= 16 && !strings.Contains(s, ".") +} + +func tokenCustomerID() string { + token := authenticationToken() + parts := strings.Split(token, ".") + if len(parts) != 3 { + return "" + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return "" + } + var claims struct { + CustomerID string `json:"CustomerID"` + } + if err := json.Unmarshal(payload, &claims); err != nil { + return "" + } + return claims.CustomerID +} + +func authenticationToken() string { + if token := os.Getenv("DCI_API_KEY"); token != "" { + return token + } + if cli.Cache == nil { + return "" + } + profile := viper.GetString("rsh-profile") + if profile == "" { + profile = "default" + } + return cli.Cache.GetString("dci:" + profile + ".token") +} + +func resolveConsoleCustomerID(context string) (string, error) { + token := authenticationToken() + if token == "" { + return "", fmt.Errorf("cannot determine the customer for console links: authenticate first") + } + base, err := apiBase() + if err != nil { + return "", err + } + requestURL, err := url.Parse(base + "/analytics/v1/reports") + if err != nil { + return "", err + } + query := requestURL.Query() + query.Set("maxResults", "1") + if context != "" { + query.Set("customerContext", context) + } + requestURL.RawQuery = query.Encode() + + request, err := http.NewRequest(http.MethodGet, requestURL.String(), nil) + if err != nil { + return "", err + } + request.Header.Set("Authorization", "Bearer "+token) + request.Header.Set("User-Agent", buildUserAgent(agentUAMode)) + if context != "" { + request.Header.Set("X-Tenant-Id", context) + } + response, err := consoleHTTPClient.Do(request) + if err != nil { + return "", fmt.Errorf("cannot resolve the active customer: %w", err) + } + defer func() { _ = response.Body.Close() }() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return "", fmt.Errorf("cannot resolve the active customer: API returned %s", response.Status) + } + var body struct { + Reports []struct { + URLUI string `json:"urlUI"` + } `json:"reports"` + } + if err := json.NewDecoder(response.Body).Decode(&body); err != nil { + return "", fmt.Errorf("cannot resolve the active customer: %w", err) + } + for _, report := range body.Reports { + if customerID := customerIDFromConsoleURL(report.URLUI); customerID != "" { + return customerID, nil + } + } + return "", fmt.Errorf("cannot resolve the active customer: no report console URL was returned; set a customer-ID context with dci customer-context set ") +} + +func customerIDFromConsoleURL(rawURL string) string { + parsedURL, err := url.Parse(rawURL) + if err != nil { + return "" + } + parts := strings.Split(strings.Trim(parsedURL.Path, "/"), "/") + for index := 0; index+1 < len(parts); index++ { + if parts[index] == "customers" && looksLikeCustomerID(parts[index+1]) { + return parts[index+1] + } + } + return "" +} + +func openInBrowser(url string) error { + switch runtime.GOOS { + case "darwin": + return exec.Command("open", url).Start() + case "windows": + return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + default: + return exec.Command("xdg-open", url).Start() + } +} diff --git a/open_command_test.go b/open_command_test.go new file mode 100644 index 0000000..b90a5b0 --- /dev/null +++ b/open_command_test.go @@ -0,0 +1,139 @@ +package main + +import ( + "encoding/base64" + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +func TestLooksLikeCustomerID(t *testing.T) { + if !looksLikeCustomerID("RSTDkHhaoGWwOEvlYlHyBUhm") { + t.Error("customer-ID shaped context rejected") + } + if looksLikeCustomerID("acme.com") { + t.Error("domain context accepted as customer ID") + } + if looksLikeCustomerID("foo") { + t.Error("short token accepted as customer ID") + } +} + +func TestTokenCustomerID(t *testing.T) { + payload := base64.RawURLEncoding.EncodeToString([]byte(`{"CustomerID":"AbCdEfGhIjKlMnOpQrSt","sub":"user@example.com"}`)) + t.Setenv("DCI_API_KEY", "header."+payload+".signature") + if got := tokenCustomerID(); got != "AbCdEfGhIjKlMnOpQrSt" { + t.Errorf("tokenCustomerID = %q, want claim value", got) + } + + t.Setenv("DCI_API_KEY", "not-a-jwt") + if got := tokenCustomerID(); got != "" { + t.Errorf("malformed token produced %q, want empty", got) + } +} + +func TestConsoleCustomerIDResolvesOAuthSession(t *testing.T) { + oldContext := resolvedCustomerContext + oldFlag := customerContextFlagValue + oldResolver := consoleCustomerIDResolver + resolvedCustomerContext = "" + customerContextFlagValue = "" + t.Cleanup(func() { + resolvedCustomerContext = oldContext + customerContextFlagValue = oldFlag + consoleCustomerIDResolver = oldResolver + }) + + payload := base64.RawURLEncoding.EncodeToString([]byte(`{"Key":"access-key","UserID":"user-id","DoitOwner":true,"DoitEmployee":false}`)) + t.Setenv("DCI_API_KEY", "header."+payload+".signature") + consoleCustomerIDResolver = func(context string) (string, error) { + if context != "" { + t.Fatalf("resolver context = %q, want empty", context) + } + return "ResolvedCustomerID123", nil + } + customerID, err := consoleCustomerID(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if customerID != "ResolvedCustomerID123" { + t.Errorf("consoleCustomerID = %q, want resolved customer", customerID) + } +} + +func TestConsoleCustomerIDResolvesDomainContext(t *testing.T) { + oldContext := resolvedCustomerContext + oldFlag := customerContextFlagValue + oldResolver := consoleCustomerIDResolver + resolvedCustomerContext = "acme.com" + customerContextFlagValue = "" + t.Cleanup(func() { + resolvedCustomerContext = oldContext + customerContextFlagValue = oldFlag + consoleCustomerIDResolver = oldResolver + }) + + payload := base64.RawURLEncoding.EncodeToString([]byte(`{"CustomerID":"DifferentCustomerID123"}`)) + t.Setenv("DCI_API_KEY", "header."+payload+".signature") + consoleCustomerIDResolver = func(context string) (string, error) { + if context != "acme.com" { + t.Fatalf("resolver context = %q, want acme.com", context) + } + return "ContextCustomerID123", nil + } + got, err := consoleCustomerID(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if got != "ContextCustomerID123" { + t.Errorf("consoleCustomerID = %q, want context customer", got) + } +} + +func TestConsoleResourcePaths(t *testing.T) { + want := map[string]string{ + "report": "https://console.doit.com/customers/CustomerIdentifier123/analyze/reports/resource-id", + "budget": "https://console.doit.com/customers/CustomerIdentifier123/monitor/budgets/resource-id", + "allocation": "https://console.doit.com/customers/CustomerIdentifier123/operate/allocations/resource-id", + } + for resource, expectedURL := range want { + actualURL, ok := consoleResourceURL("CustomerIdentifier123", resource, "resource-id") + if !ok { + t.Fatalf("resource %q not supported", resource) + } + if actualURL != expectedURL { + t.Errorf("%s URL = %q, want %q", resource, actualURL, expectedURL) + } + } +} + +func TestResolveConsoleCustomerID(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Header.Get("Authorization") != "Bearer oauth-token" { + t.Errorf("authorization = %q", request.Header.Get("Authorization")) + } + if request.Header.Get("X-Tenant-Id") != "acme.com" { + t.Errorf("tenant header = %q", request.Header.Get("X-Tenant-Id")) + } + if request.URL.Query().Get("customerContext") != "acme.com" || request.URL.Query().Get("maxResults") != "1" { + t.Errorf("query = %v", request.URL.Query()) + } + _, _ = fmt.Fprint(writer, `{"reports":[{"urlUI":"https://console.doit.com/customers/ResolvedCustomerID123/analyze/reports/report-id"}]}`) + })) + t.Cleanup(server.Close) + t.Setenv("DCI_API_BASE_URL", server.URL) + t.Setenv("DCI_API_KEY", "oauth-token") + + oldClient := consoleHTTPClient + consoleHTTPClient = server.Client() + t.Cleanup(func() { consoleHTTPClient = oldClient }) + + customerID, err := resolveConsoleCustomerID("acme.com") + if err != nil { + t.Fatal(err) + } + if customerID != "ResolvedCustomerID123" { + t.Errorf("customer ID = %q", customerID) + } +} diff --git a/skills/dci-cli/SKILL.md b/skills/dci-cli/SKILL.md index c47e200..9b346fc 100644 --- a/skills/dci-cli/SKILL.md +++ b/skills/dci-cli/SKILL.md @@ -33,7 +33,7 @@ Use `--fields id,name` to project list or detail responses before output, and us 1. Confirm the CLI exists and is runnable: `dci --version` 2. Check session and active context: `dci status`; confirm identity and permissions with `dci validate` -3. Discover command shape before drafting or running commands: `dci --help` and `dci --help` +3. Discover command shape before drafting or running commands: `dci --help` and `dci --help` (terse; add `--help-full` when you need the request/response schemas) 4. Prefer `list-*`, `get-*`, `get-report`, and `query` before `create-*`, `update-*`, or `delete-*` Use `dci skill list` to inspect the files embedded in the installed CLI. Use `dci skill update ` to refresh one installed copy, or omit the agent to update every detected installation; locally edited managed files require an explicit `--force` overwrite and are saved in a uniquely named sibling backup directory first. @@ -59,10 +59,18 @@ Load [query-patterns.md](references/query-patterns.md) for payload examples. - When a command may fail because of permissions or context, explain that `dci login` proves authentication but not authorization; `dci validate` confirms both identity and access. - In CI or headless environments, always set `DCI_API_KEY`: without credentials the CLI fails fast with `AUTHENTICATION_REQUIRED` instead of opening a browser. +## Documentation + +- CLI guide: https://help.doit.com/docs/cli (append `.md` to any Help Center URL for plain Markdown, e.g. https://help.doit.com/docs/cli.md) +- Machine-readable Help Center index: https://help.doit.com/llms.txt (full corpus: https://help.doit.com/llms-full.txt) +- API reference: https://developer.doit.com/ +- From the terminal: `dci docs` prints these entry points; `dci --help` is terse by default (`--help-full` adds the complete request/response schemas); `dci commands --json` is the machine-readable catalog. + ## Reference Map - Load [capabilities.md](references/capabilities.md) for the capability tree, command families, and invocation patterns. - Load [examples.md](references/examples.md) for generalized install/auth, discovery, report, query, and mutation examples. - Load [query-patterns.md](references/query-patterns.md) for JSON query workflows. - Load [cost-optimization.md](references/cost-optimization.md) for an anonymized 30-day cost analysis example. +- Load [finops-baseline.md](references/finops-baseline.md) for the greenfield workflow: bring an account from unmanaged spend to budgets, alerts, and allocations in one session. - Load [evals.md](references/evals.md) to validate the skill against realistic user prompts. diff --git a/skills/dci-cli/references/examples.md b/skills/dci-cli/references/examples.md index 61fe8cf..ad9e690 100644 --- a/skills/dci-cli/references/examples.md +++ b/skills/dci-cli/references/examples.md @@ -93,6 +93,15 @@ dci query --rows keyed --output json < query.json | jq '.result.rows[0].cost' } ``` +## Answering Cost Questions + +Map the user's question to the read commands before reaching for anything else: + +- "Why did our bill spike yesterday?" → `dci list-anomalies`, `dci get-anomaly `, then a scoped `dci query` +- "Are we on track for this month's budgets?" → `dci list-budgets` (utilization, forecast) +- "What does service X cost per environment?" → `dci query` grouped by the label, `--pivot` for humans +- "Hand this to a human" → `dci open report ` (prints the console deep link in agent mode) + ## Report Drill-Down ```bash diff --git a/skills/dci-cli/references/finops-baseline.md b/skills/dci-cli/references/finops-baseline.md new file mode 100644 index 0000000..777cf67 --- /dev/null +++ b/skills/dci-cli/references/finops-baseline.md @@ -0,0 +1,79 @@ +# FinOps Baseline Workflow + +Use this file when asked to "set up FinOps basics", "bring this account up to standard", +"create budgets for our main services", or any greenfield cost-governance request. The goal: +take a customer from unmanaged spend to a working baseline — visibility, budgets, anomaly +awareness, alerts, and showback — in one agent session. + +Work read-first, propose before mutating, and get explicit approval before every `create-*` +command (they are side-effectful; deletes additionally require `--yes`). + +## Step 1 — Understand the spend + +```bash +dci query --rows keyed --output json < top-services.json # 30d cost by service, top 10, with a metricFilter +dci list-anomalies --max-results 10 +dci list-budgets +dci list-allocations +``` + +Summarize: top services and their monthly run rate (include the `currency`), existing +budgets/allocations coverage, recent anomalies. Gaps = the plan. + +## Step 2 — Budgets + +Check for API-suggested budgets first. A suggestion supplies the shape, but accepting it +requires an existing matching budget: + +```bash +dci list-budget-suggestions +dci create-budget < budget.json +dci accept-budget-suggestion budgetId: +``` + +Present both mutations for approval before running them. For services without suggestions, +draft `create-budget` payloads from observed spend (e.g. last full month × 1.1, +`type: recurring`, `timeInterval: month`). Include alert thresholds (50/75/90%) so budgets notify. + +## Step 3 — Anomaly and cost alerts + +Anomaly detection runs automatically; make sure someone hears it: + +```bash +dci create-alert < alert.json # e.g. month-over-month cost increase > N% on top services +``` + +Draft alert configs per top service or per cloud provider; run `dci create-alert --help` +for the schema. + +## Step 4 — Showback structure + +If spend isn't attributable, propose allocations (by project/label/account) so future +reports and budgets can be scoped: + +```bash +dci create-allocation < allocation.json +``` + +## Step 5 — Report and hand off + +Produce a short summary: what exists now (budgets with links from `url`/`urlUI` fields), +what was created, what needs a human decision. Deep-link each created resource: + +```bash +dci open budget # prints the console URL in agent mode +dci open report +``` + +## The as-code loop + +Every resource supports pull → edit → push, which keeps changes reviewable: + +```bash +dci get-report-config > report.json # pull +# edit report.json with the user +dci update-report < report.json # push (validate first with --dry-run) +``` + +The same pattern works for budgets (`get-budget` / `update-budget`), alerts, and +allocations. Prefer this loop over describing changes in prose — files diff, prose doesn't. From 83cc13d5dd1368ec93feb4035fc24dd3279da957 Mon Sep 17 00:00:00 2001 From: Michael Finson Date: Tue, 11 Aug 2026 17:08:59 +0300 Subject: [PATCH 2/3] fix: resolve console customer without report data --- open_command.go | 102 ++++++++++++++++++++++++------------------- open_command_test.go | 63 +++++++++++++++++++++++++- 2 files changed, 118 insertions(+), 47 deletions(-) diff --git a/open_command.go b/open_command.go index 3ecf574..ca35dea 100644 --- a/open_command.go +++ b/open_command.go @@ -33,12 +33,6 @@ var consoleCustomerIDResolver = resolveConsoleCustomerID var consoleHTTPClient = &http.Client{Timeout: 10 * time.Second} func registerOpenCommand(configDir string) { - resources := make([]string, 0, len(consoleResourcePaths)) - for r := range consoleResourcePaths { - resources = append(resources, r) - } - sort.Strings(resources) - cmd := &cobra.Command{ Use: "open [resource] [id]", Short: "Open the DoiT console (optionally a specific report, budget, or allocation)", @@ -47,23 +41,11 @@ func registerOpenCommand(configDir string) { "Opens a browser in interactive use; prints the URL in agent or non-interactive mode.", Args: cobra.RangeArgs(0, 2), RunE: func(cmd *cobra.Command, args []string) error { - customerID, err := consoleCustomerID(configDir) + consoleURL, err := consoleURLForArgs(configDir, args) if err != nil { return err } - consoleURL := fmt.Sprintf("%s/customers/%s", consoleBaseURL, customerID) - switch len(args) { - case 1: - return fmt.Errorf("usage: dci open <%s> ", strings.Join(resources, "|")) - case 2: - resourceURL, ok := consoleResourceURL(customerID, args[0], args[1]) - if !ok { - return fmt.Errorf("unknown resource %q (supported: %s)", args[0], strings.Join(resources, ", ")) - } - consoleURL = resourceURL - } - if agentMode || !term.IsTerminal(int(os.Stdout.Fd())) { _, err := fmt.Fprintln(cmd.OutOrStdout(), consoleURL) return err @@ -78,6 +60,29 @@ func registerOpenCommand(configDir string) { cli.Root.AddCommand(cmd) } +func consoleURLForArgs(configDir string, args []string) (string, error) { + if len(args) == 0 { + return consoleBaseURL, nil + } + resources := make([]string, 0, len(consoleResourcePaths)) + for resource := range consoleResourcePaths { + resources = append(resources, resource) + } + sort.Strings(resources) + if len(args) == 1 { + return "", fmt.Errorf("usage: dci open <%s> ", strings.Join(resources, "|")) + } + customerID, err := consoleCustomerID(configDir) + if err != nil { + return "", err + } + resourceURL, ok := consoleResourceURL(customerID, args[0], args[1]) + if !ok { + return "", fmt.Errorf("unknown resource %q (supported: %s)", args[0], strings.Join(resources, ", ")) + } + return resourceURL, nil +} + func consoleResourceURL(customerID, resource, resourceID string) (string, bool) { path, ok := consoleResourcePaths[strings.ToLower(resource)] if !ok { @@ -149,12 +154,11 @@ func resolveConsoleCustomerID(context string) (string, error) { if err != nil { return "", err } - requestURL, err := url.Parse(base + "/analytics/v1/reports") + requestURL, err := url.Parse(base + "/auth/v1/validate") if err != nil { return "", err } query := requestURL.Query() - query.Set("maxResults", "1") if context != "" { query.Set("customerContext", context) } @@ -175,36 +179,44 @@ func resolveConsoleCustomerID(context string) (string, error) { } defer func() { _ = response.Body.Close() }() if response.StatusCode < 200 || response.StatusCode >= 300 { - return "", fmt.Errorf("cannot resolve the active customer: API returned %s", response.Status) + return "", consoleCustomerResolutionError(response) } - var body struct { - Reports []struct { - URLUI string `json:"urlUI"` - } `json:"reports"` - } - if err := json.NewDecoder(response.Body).Decode(&body); err != nil { - return "", fmt.Errorf("cannot resolve the active customer: %w", err) - } - for _, report := range body.Reports { - if customerID := customerIDFromConsoleURL(report.URLUI); customerID != "" { - return customerID, nil - } + if customerID := strings.TrimSpace(response.Header.Get("X-DoiT-Customer-ID")); looksLikeCustomerID(customerID) { + return customerID, nil } - return "", fmt.Errorf("cannot resolve the active customer: no report console URL was returned; set a customer-ID context with dci customer-context set ") + return "", fmt.Errorf("cannot resolve the active customer: the API did not return a customer ID; set a customer-ID context with dci customer-context set ") } -func customerIDFromConsoleURL(rawURL string) string { - parsedURL, err := url.Parse(rawURL) - if err != nil { - return "" - } - parts := strings.Split(strings.Trim(parsedURL.Path, "/"), "/") - for index := 0; index+1 < len(parts); index++ { - if parts[index] == "customers" && looksLikeCustomerID(parts[index+1]) { - return parts[index+1] +type consoleAPIError struct { + status int + message string + headers map[string]string +} + +func (err consoleAPIError) Error() string { + return err.message +} + +func (err consoleAPIError) ExitCode() int { + return exitCodeForHTTPStatus(err.status) +} + +func (err consoleAPIError) StructuredError() structuredError { + return structuredErrorForStatus(err.status, err.message, err.headers) +} + +func consoleCustomerResolutionError(response *http.Response) error { + headers := make(map[string]string) + for _, name := range []string{"X-Request-Id", "X-Doit-Trace", "Cf-Ray", "X-Cloud-Trace-Context", "Traceparent", "Retry-After", "X-Retry-In"} { + if value := response.Header.Get(name); value != "" { + headers[name] = value } } - return "" + return consoleAPIError{ + status: response.StatusCode, + message: fmt.Sprintf("cannot resolve the active customer: API returned %s", response.Status), + headers: headers, + } } func openInBrowser(url string) error { diff --git a/open_command_test.go b/open_command_test.go index b90a5b0..8b9fc12 100644 --- a/open_command_test.go +++ b/open_command_test.go @@ -108,18 +108,39 @@ func TestConsoleResourcePaths(t *testing.T) { } } +func TestConsoleURLForArgsDoesNotResolveHome(t *testing.T) { + oldResolver := consoleCustomerIDResolver + consoleCustomerIDResolver = func(context string) (string, error) { + t.Fatalf("customer resolver called with context %q", context) + return "", nil + } + t.Cleanup(func() { consoleCustomerIDResolver = oldResolver }) + + consoleURL, err := consoleURLForArgs(t.TempDir(), nil) + if err != nil { + t.Fatal(err) + } + if consoleURL != consoleBaseURL { + t.Errorf("console URL = %q, want %q", consoleURL, consoleBaseURL) + } +} + func TestResolveConsoleCustomerID(t *testing.T) { server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/auth/v1/validate" { + t.Errorf("path = %q", request.URL.Path) + } if request.Header.Get("Authorization") != "Bearer oauth-token" { t.Errorf("authorization = %q", request.Header.Get("Authorization")) } if request.Header.Get("X-Tenant-Id") != "acme.com" { t.Errorf("tenant header = %q", request.Header.Get("X-Tenant-Id")) } - if request.URL.Query().Get("customerContext") != "acme.com" || request.URL.Query().Get("maxResults") != "1" { + if request.URL.Query().Get("customerContext") != "acme.com" { t.Errorf("query = %v", request.URL.Query()) } - _, _ = fmt.Fprint(writer, `{"reports":[{"urlUI":"https://console.doit.com/customers/ResolvedCustomerID123/analyze/reports/report-id"}]}`) + writer.Header().Set("X-DoiT-Customer-ID", "ResolvedCustomerID123") + _, _ = fmt.Fprint(writer, `{}`) })) t.Cleanup(server.Close) t.Setenv("DCI_API_BASE_URL", server.URL) @@ -137,3 +158,41 @@ func TestResolveConsoleCustomerID(t *testing.T) { t.Errorf("customer ID = %q", customerID) } } + +func TestResolveConsoleCustomerIDPreservesHTTPClassification(t *testing.T) { + tests := []struct { + status int + exitCode int + errorCode string + }{ + {status: http.StatusUnauthorized, exitCode: exitAuthentication, errorCode: "AUTHENTICATION_FAILED"}, + {status: http.StatusForbidden, exitCode: exitAuthorization, errorCode: "PERMISSION_DENIED"}, + {status: http.StatusInternalServerError, exitCode: exitServer, errorCode: "API_SERVER_ERROR"}, + } + + for _, test := range tests { + t.Run(http.StatusText(test.status), func(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.WriteHeader(test.status) + })) + t.Cleanup(server.Close) + t.Setenv("DCI_API_BASE_URL", server.URL) + t.Setenv("DCI_API_KEY", "oauth-token") + + oldClient := consoleHTTPClient + consoleHTTPClient = server.Client() + t.Cleanup(func() { consoleHTTPClient = oldClient }) + + _, err := resolveConsoleCustomerID("") + if err == nil { + t.Fatal("expected HTTP error") + } + if got := exitCodeForExecutionError(err, 0); got != test.exitCode { + t.Errorf("exit code = %d, want %d", got, test.exitCode) + } + if got := structuredErrorForExecution(err, 0).Code; got != test.errorCode { + t.Errorf("error code = %q, want %q", got, test.errorCode) + } + }) + } +} From 633db970aea0670c077850a53fa10177bd544694 Mon Sep 17 00:00:00 2001 From: Michael Finson Date: Tue, 11 Aug 2026 17:11:48 +0300 Subject: [PATCH 3/3] fix: support canonical customer id claim --- open_command.go | 6 +++++- open_command_test.go | 8 +++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/open_command.go b/open_command.go index ca35dea..08fb5a3 100644 --- a/open_command.go +++ b/open_command.go @@ -123,11 +123,15 @@ func tokenCustomerID() string { return "" } var claims struct { - CustomerID string `json:"CustomerID"` + CustomerID string `json:"customerId"` + LegacyCustomerID string `json:"CustomerID"` } if err := json.Unmarshal(payload, &claims); err != nil { return "" } + if claims.CustomerID == "" { + return claims.LegacyCustomerID + } return claims.CustomerID } diff --git a/open_command_test.go b/open_command_test.go index 8b9fc12..2047aa0 100644 --- a/open_command_test.go +++ b/open_command_test.go @@ -21,12 +21,18 @@ func TestLooksLikeCustomerID(t *testing.T) { } func TestTokenCustomerID(t *testing.T) { - payload := base64.RawURLEncoding.EncodeToString([]byte(`{"CustomerID":"AbCdEfGhIjKlMnOpQrSt","sub":"user@example.com"}`)) + payload := base64.RawURLEncoding.EncodeToString([]byte(`{"customerId":"AbCdEfGhIjKlMnOpQrSt","sub":"user@example.com"}`)) t.Setenv("DCI_API_KEY", "header."+payload+".signature") if got := tokenCustomerID(); got != "AbCdEfGhIjKlMnOpQrSt" { t.Errorf("tokenCustomerID = %q, want claim value", got) } + payload = base64.RawURLEncoding.EncodeToString([]byte(`{"CustomerID":"LegacyCustomerID123"}`)) + t.Setenv("DCI_API_KEY", "header."+payload+".signature") + if got := tokenCustomerID(); got != "LegacyCustomerID123" { + t.Errorf("legacy tokenCustomerID = %q, want claim value", got) + } + t.Setenv("DCI_API_KEY", "not-a-jwt") if got := tokenCustomerID(); got != "" { t.Errorf("malformed token produced %q, want empty", got)