diff --git a/cmd/agent.go b/cmd/agent.go new file mode 100644 index 00000000..3f4be82f --- /dev/null +++ b/cmd/agent.go @@ -0,0 +1,114 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2024-present Datadog, Inc. + +package cmd + +import ( + "encoding/json" + "fmt" + + "github.com/DataDog/pup/pkg/agenthelp" + "github.com/spf13/cobra" +) + +var agentSchemaCompact bool + +var agentCmd = &cobra.Command{ + Use: "agent", + Short: "Agent tooling: schema, guide, and diagnostics for AI coding assistants", + Long: `Commands for AI coding assistants to interact with pup efficiently. + +In agent mode (auto-detected or via --agent / FORCE_AGENT_MODE=1), +--help returns structured JSON schema instead of human-readable text. + +COMMANDS: + schema Output the complete command schema as JSON + guide Output the comprehensive steering guide + +EXAMPLES: + # Get full JSON schema (all commands, flags, query syntax) + pup agent schema + + # Get compact schema (command names and flags only, fewer tokens) + pup agent schema --compact + + # Get the steering guide + pup agent guide + + # Get guide for a specific domain + pup agent guide logs`, +} + +var agentSchemaCmd = &cobra.Command{ + Use: "schema", + Short: "Output command schema as JSON", + Long: `Output the complete pup command schema as structured JSON. + +Includes all commands, flags, query syntax, time formats, workflows, +best practices, and anti-patterns. This is the same output returned +by --help when running in agent mode. + +FLAGS: + --compact Output minimal schema (command names and flags only) + +EXAMPLES: + pup agent schema + pup agent schema --compact`, + RunE: runAgentSchema, +} + +var agentGuideCmd = &cobra.Command{ + Use: "guide [domain]", + Short: "Output the comprehensive steering guide", + Long: `Output the pup steering guide for AI coding assistants. + +Without arguments, outputs the full guide. With a domain argument, +outputs only the section relevant to that domain. + +EXAMPLES: + pup agent guide + pup agent guide logs + pup agent guide metrics + pup agent guide monitors + pup agent guide apm`, + Args: cobra.MaximumNArgs(1), + RunE: runAgentGuide, +} + +func init() { + agentSchemaCmd.Flags().BoolVar(&agentSchemaCompact, "compact", false, "Output minimal schema (names + flags only)") + + agentCmd.AddCommand(agentSchemaCmd) + agentCmd.AddCommand(agentGuideCmd) +} + +func runAgentSchema(cmd *cobra.Command, args []string) error { + root := cmd.Root() + + var data interface{} + if agentSchemaCompact { + data = agenthelp.GenerateCompactSchema(root) + } else { + data = agenthelp.GenerateSchema(root) + } + + out, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal schema: %w", err) + } + + printOutput("%s\n", string(out)) + return nil +} + +func runAgentGuide(cmd *cobra.Command, args []string) error { + if len(args) == 1 { + printOutput("%s\n", agenthelp.GetGuideSection(args[0])) + return nil + } + printOutput("%s\n", agenthelp.GetGuide()) + return nil +} + diff --git a/cmd/agent_test.go b/cmd/agent_test.go new file mode 100644 index 00000000..20daa5fb --- /dev/null +++ b/cmd/agent_test.go @@ -0,0 +1,389 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2024-present Datadog, Inc. + +package cmd + +import ( + "bytes" + "encoding/json" + "os" + "strings" + "testing" + + "github.com/DataDog/pup/pkg/agenthelp" + "github.com/DataDog/pup/pkg/config" +) + +func TestAgentSchema(t *testing.T) { + // Save and restore originals + origWriter := outputWriter + origCfg := cfg + origClient := ddClient + defer func() { + outputWriter = origWriter + cfg = origCfg + ddClient = origClient + }() + + var buf bytes.Buffer + outputWriter = &buf + cfg = &config.Config{Site: "datadoghq.com"} + ddClient = nil + + err := ExecuteWithArgs([]string{"agent", "schema"}) + if err != nil { + t.Fatalf("agent schema error: %v", err) + } + + output := buf.String() + if output == "" { + t.Fatal("agent schema should produce output") + } + + // Verify it's valid JSON + var schema agenthelp.Schema + if err := json.Unmarshal([]byte(output), &schema); err != nil { + t.Fatalf("agent schema output is not valid JSON: %v", err) + } + + if schema.Version == "" { + t.Error("schema version should not be empty") + } + if len(schema.Commands) == 0 { + t.Error("schema commands should not be empty") + } + if len(schema.QuerySyntax) == 0 { + t.Error("schema query_syntax should not be empty") + } +} + +func TestAgentSchemaCompact(t *testing.T) { + origWriter := outputWriter + origCfg := cfg + origClient := ddClient + defer func() { + outputWriter = origWriter + cfg = origCfg + ddClient = origClient + }() + + var buf bytes.Buffer + outputWriter = &buf + cfg = &config.Config{Site: "datadoghq.com"} + ddClient = nil + + err := ExecuteWithArgs([]string{"agent", "schema", "--compact"}) + if err != nil { + t.Fatalf("agent schema --compact error: %v", err) + } + + output := buf.String() + + var compact agenthelp.CompactSchema + if err := json.Unmarshal([]byte(output), &compact); err != nil { + t.Fatalf("agent schema --compact output is not valid JSON: %v", err) + } + + if compact.Version == "" { + t.Error("compact schema version should not be empty") + } + if len(compact.Commands) == 0 { + t.Error("compact schema commands should not be empty") + } +} + +func TestAgentGuide(t *testing.T) { + origWriter := outputWriter + origCfg := cfg + origClient := ddClient + defer func() { + outputWriter = origWriter + cfg = origCfg + ddClient = origClient + }() + + var buf bytes.Buffer + outputWriter = &buf + cfg = &config.Config{Site: "datadoghq.com"} + ddClient = nil + + err := ExecuteWithArgs([]string{"agent", "guide"}) + if err != nil { + t.Fatalf("agent guide error: %v", err) + } + + output := buf.String() + if !strings.Contains(output, "Pup Agent Guide") { + t.Error("agent guide should contain the title") + } +} + +func TestAgentGuideDomain(t *testing.T) { + origWriter := outputWriter + origCfg := cfg + origClient := ddClient + defer func() { + outputWriter = origWriter + cfg = origCfg + ddClient = origClient + }() + + var buf bytes.Buffer + outputWriter = &buf + cfg = &config.Config{Site: "datadoghq.com"} + ddClient = nil + + err := ExecuteWithArgs([]string{"agent", "guide", "logs"}) + if err != nil { + t.Fatalf("agent guide logs error: %v", err) + } + + output := buf.String() + if !strings.Contains(output, "Logs") { + t.Error("agent guide logs should contain Logs content") + } +} + +func TestForceAgentModeHelp(t *testing.T) { + origWriter := outputWriter + origCfg := cfg + origClient := ddClient + defer func() { + outputWriter = origWriter + cfg = origCfg + ddClient = origClient + os.Unsetenv("FORCE_AGENT_MODE") + }() + + var buf bytes.Buffer + outputWriter = &buf + cfg = &config.Config{Site: "datadoghq.com"} + ddClient = nil + + os.Setenv("FORCE_AGENT_MODE", "1") + + err := ExecuteWithArgs([]string{"--help"}) + if err != nil { + t.Fatalf("--help with FORCE_AGENT_MODE error: %v", err) + } + + output := buf.String() + + var schema agenthelp.Schema + if err := json.Unmarshal([]byte(output), &schema); err != nil { + t.Fatalf("--help with FORCE_AGENT_MODE should return JSON schema: %v", err) + } + + if len(schema.Commands) == 0 { + t.Error("schema commands should not be empty") + } +} + +func TestForceAgentModeHelpSubtree(t *testing.T) { + origWriter := outputWriter + origCfg := cfg + origClient := ddClient + defer func() { + outputWriter = origWriter + cfg = origCfg + ddClient = origClient + os.Unsetenv("FORCE_AGENT_MODE") + }() + + var buf bytes.Buffer + outputWriter = &buf + cfg = &config.Config{Site: "datadoghq.com"} + ddClient = nil + + os.Setenv("FORCE_AGENT_MODE", "1") + + err := ExecuteWithArgs([]string{"monitors", "--help"}) + if err != nil { + t.Fatalf("monitors --help with FORCE_AGENT_MODE error: %v", err) + } + + output := buf.String() + + var schema agenthelp.Schema + if err := json.Unmarshal([]byte(output), &schema); err != nil { + t.Fatalf("monitors --help with FORCE_AGENT_MODE should return JSON: %v", err) + } + + if len(schema.Commands) != 1 { + t.Errorf("monitors --help should have 1 command, got %d", len(schema.Commands)) + } + if schema.Commands[0].Name != "monitors" { + t.Errorf("monitors --help command name = %q, want 'monitors'", schema.Commands[0].Name) + } +} + +func TestAgentModeAutoDetect(t *testing.T) { + origCfg := cfg + origClient := ddClient + origWriter := outputWriter + defer func() { + cfg = origCfg + ddClient = origClient + outputWriter = origWriter + os.Unsetenv("CLAUDECODE") + }() + + var buf bytes.Buffer + outputWriter = &buf + ddClient = nil + + // Set CLAUDECODE to trigger agent mode + os.Setenv("CLAUDECODE", "1") + + // Reset cfg so initConfig runs with the env var + cfg = nil + + // initConfig is called by cobra.OnInitialize, simulate it + initConfig() + + if !cfg.AgentMode { + t.Error("AgentMode should be true when CLAUDECODE=1") + } + if !cfg.AutoApprove { + t.Error("AutoApprove should be true in agent mode") + } +} + +func TestAgentModeFlagOverride(t *testing.T) { + origCfg := cfg + origClient := ddClient + origWriter := outputWriter + origAgentFlag := agentFlag + defer func() { + cfg = origCfg + ddClient = origClient + outputWriter = origWriter + agentFlag = origAgentFlag + }() + + var buf bytes.Buffer + outputWriter = &buf + ddClient = nil + + agentFlag = true + cfg = nil + initConfig() + + if !cfg.AgentMode { + t.Error("AgentMode should be true when --agent flag is set") + } + if !cfg.AutoApprove { + t.Error("AutoApprove should be true when --agent flag is set") + } +} + +func TestHelpReturnsSchemaInAgentMode(t *testing.T) { + origWriter := outputWriter + origCfg := cfg + origClient := ddClient + defer func() { + outputWriter = origWriter + cfg = origCfg + ddClient = origClient + os.Unsetenv("CLAUDECODE") + }() + + var buf bytes.Buffer + outputWriter = &buf + cfg = &config.Config{Site: "datadoghq.com"} + ddClient = nil + + // Set agent env var so --help is intercepted + os.Setenv("CLAUDECODE", "1") + + err := ExecuteWithArgs([]string{"--help"}) + if err != nil { + t.Fatalf("--help in agent mode error: %v", err) + } + + output := buf.String() + + var schema agenthelp.Schema + if err := json.Unmarshal([]byte(output), &schema); err != nil { + t.Fatalf("--help in agent mode should return valid JSON schema, got: %s", output[:200]) + } + + if len(schema.Commands) == 0 { + t.Error("--help schema should have commands") + } +} + +func TestHelpReturnsSchemaSubtreeInAgentMode(t *testing.T) { + origWriter := outputWriter + origCfg := cfg + origClient := ddClient + defer func() { + outputWriter = origWriter + cfg = origCfg + ddClient = origClient + os.Unsetenv("CLAUDECODE") + }() + + var buf bytes.Buffer + outputWriter = &buf + cfg = &config.Config{Site: "datadoghq.com"} + ddClient = nil + + os.Setenv("CLAUDECODE", "1") + + err := ExecuteWithArgs([]string{"logs", "--help"}) + if err != nil { + t.Fatalf("logs --help in agent mode error: %v", err) + } + + output := buf.String() + + var schema agenthelp.Schema + if err := json.Unmarshal([]byte(output), &schema); err != nil { + t.Fatalf("logs --help in agent mode should return valid JSON schema, got: %s", output[:200]) + } + + if len(schema.Commands) != 1 { + t.Errorf("logs --help should have 1 command, got %d", len(schema.Commands)) + } + if schema.Commands[0].Name != "logs" { + t.Errorf("logs --help command = %q, want 'logs'", schema.Commands[0].Name) + } +} + +func TestHelpUnchangedInHumanMode(t *testing.T) { + origWriter := outputWriter + origCfg := cfg + origClient := ddClient + defer func() { + outputWriter = origWriter + cfg = origCfg + ddClient = origClient + // Clear any agent env vars + os.Unsetenv("CLAUDECODE") + os.Unsetenv("CLAUDE_CODE") + os.Unsetenv("FORCE_AGENT_MODE") + }() + + // Ensure no agent env vars are set + os.Unsetenv("CLAUDECODE") + os.Unsetenv("CLAUDE_CODE") + os.Unsetenv("FORCE_AGENT_MODE") + + var buf bytes.Buffer + outputWriter = &buf + cfg = &config.Config{Site: "datadoghq.com"} + ddClient = nil + + // --help in human mode should NOT return JSON + _ = ExecuteWithArgs([]string{"--help"}) + + output := buf.String() + + // Human mode help should contain "Usage:" not JSON + if strings.Contains(output, `"version"`) { + t.Error("--help in human mode should not return JSON schema") + } +} diff --git a/cmd/alias.go b/cmd/alias.go index 7689580f..501ea53a 100644 --- a/cmd/alias.go +++ b/cmd/alias.go @@ -284,6 +284,7 @@ func isReservedCommand(name string) bool { "usage", "cost", "data-governance", "obs-pipelines", "network", "cloud", "integrations", "misc", "investigations", "product-analytics", "cases", "apm", + "agent", } // Convert to lowercase for case-insensitive comparison diff --git a/cmd/api_keys.go b/cmd/api_keys.go index 36014b72..38cc9c00 100644 --- a/cmd/api_keys.go +++ b/cmd/api_keys.go @@ -9,7 +9,6 @@ import ( "fmt" "github.com/DataDog/datadog-api-client-go/v2/api/datadogV2" - "github.com/DataDog/pup/pkg/formatter" "github.com/spf13/cobra" ) @@ -112,12 +111,7 @@ func runAPIKeysList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list API keys: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runAPIKeysGet(cmd *cobra.Command, args []string) error { @@ -137,12 +131,7 @@ func runAPIKeysGet(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to get API key: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runAPIKeysCreate(cmd *cobra.Command, args []string) error { @@ -170,12 +159,7 @@ func runAPIKeysCreate(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to create API key: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runAPIKeysDelete(cmd *cobra.Command, args []string) error { diff --git a/cmd/apm.go b/cmd/apm.go index 8bd46b6b..55d087a7 100644 --- a/cmd/apm.go +++ b/cmd/apm.go @@ -11,7 +11,6 @@ import ( "strconv" "time" - "github.com/DataDog/pup/pkg/formatter" "github.com/spf13/cobra" ) @@ -580,12 +579,7 @@ func runAPMServicesList(cmd *cobra.Command, args []string) error { err, path, startTime, endTime, envFilter) } - output, err := formatter.FormatOutput(result, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(result, nil) } func runAPMServicesStats(cmd *cobra.Command, args []string) error { @@ -622,12 +616,7 @@ func runAPMServicesStats(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to get service stats: %w", err) } - output, err := formatter.FormatOutput(result, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(result, nil) } func runAPMServicesOperations(cmd *cobra.Command, args []string) error { @@ -670,12 +659,7 @@ func runAPMServicesOperations(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to get service operations: %w", err) } - output, err := formatter.FormatOutput(result, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(result, nil) } func runAPMServicesResources(cmd *cobra.Command, args []string) error { @@ -719,12 +703,7 @@ func runAPMServicesResources(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to get service resources: %w", err) } - output, err := formatter.FormatOutput(result, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(result, nil) } func runAPMEntitiesList(cmd *cobra.Command, args []string) error { @@ -773,12 +752,7 @@ func runAPMEntitiesList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list APM entities: %w", err) } - output, err := formatter.FormatOutput(result, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(result, nil) } func runAPMDependenciesList(cmd *cobra.Command, args []string) error { @@ -822,12 +796,7 @@ func runAPMDependenciesList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list service dependencies: %w", err) } - output, err := formatter.FormatOutput(result, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(result, nil) } func runAPMFlowMap(cmd *cobra.Command, args []string) error { @@ -860,10 +829,5 @@ func runAPMFlowMap(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to get flow map: %w", err) } - output, err := formatter.FormatOutput(result, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(result, nil) } diff --git a/cmd/app_keys.go b/cmd/app_keys.go index 10baefb0..33a9c9e8 100644 --- a/cmd/app_keys.go +++ b/cmd/app_keys.go @@ -7,7 +7,6 @@ package cmd import ( "github.com/DataDog/datadog-api-client-go/v2/api/datadogV2" - "github.com/DataDog/pup/pkg/formatter" "github.com/spf13/cobra" ) @@ -132,12 +131,7 @@ func runAppKeysList(cmd *cobra.Command, args []string) error { return formatAPIError("list app key registrations", err, r) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runAppKeysGet(cmd *cobra.Command, args []string) error { @@ -154,12 +148,7 @@ func runAppKeysGet(cmd *cobra.Command, args []string) error { return formatAPIError("get app key registration", err, r) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runAppKeysRegister(cmd *cobra.Command, args []string) error { @@ -176,12 +165,7 @@ func runAppKeysRegister(cmd *cobra.Command, args []string) error { return formatAPIError("register app key", err, r) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runAppKeysUnregister(cmd *cobra.Command, args []string) error { diff --git a/cmd/audit_logs.go b/cmd/audit_logs.go index 976126a1..121b7553 100644 --- a/cmd/audit_logs.go +++ b/cmd/audit_logs.go @@ -9,7 +9,6 @@ import ( "fmt" "github.com/DataDog/datadog-api-client-go/v2/api/datadogV2" - "github.com/DataDog/pup/pkg/formatter" "github.com/spf13/cobra" ) @@ -102,12 +101,7 @@ func runAuditLogsList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list audit logs: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(resp, nil) } func runAuditLogsSearch(cmd *cobra.Command, args []string) error { @@ -140,10 +134,5 @@ func runAuditLogsSearch(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to search audit logs: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(resp, nil) } diff --git a/cmd/auth.go b/cmd/auth.go index 400a7c05..80357294 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -17,7 +17,6 @@ import ( "github.com/DataDog/pup/pkg/auth/oauth" "github.com/DataDog/pup/pkg/auth/storage" "github.com/DataDog/pup/pkg/auth/types" - "github.com/DataDog/pup/pkg/formatter" "github.com/spf13/cobra" ) @@ -455,13 +454,7 @@ func runAuthStatus(cmd *cobra.Command, args []string) error { fmt.Printf(" Token expires in: %s\n", timeLeft.Round(time.Second)) } - output, err := formatter.FormatOutput(status, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - - fmt.Printf("\n%s\n", output) - return nil + return formatAndPrint(status, nil) } func runAuthLogout(cmd *cobra.Command, args []string) error { diff --git a/cmd/cases.go b/cmd/cases.go index b6153f0d..cb30b463 100644 --- a/cmd/cases.go +++ b/cmd/cases.go @@ -9,7 +9,6 @@ import ( "fmt" "github.com/DataDog/datadog-api-client-go/v2/api/datadogV2" - "github.com/DataDog/pup/pkg/formatter" "github.com/spf13/cobra" ) @@ -362,12 +361,7 @@ func runCasesSearch(cmd *cobra.Command, args []string) error { return formatAPIError("search cases", err, r) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runCasesGet(cmd *cobra.Command, args []string) error { @@ -384,12 +378,7 @@ func runCasesGet(cmd *cobra.Command, args []string) error { return formatAPIError("get case", err, r) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runCasesCreate(cmd *cobra.Command, args []string) error { @@ -422,12 +411,7 @@ func runCasesCreate(cmd *cobra.Command, args []string) error { return formatAPIError("create case", err, r) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runCasesArchive(cmd *cobra.Command, args []string) error { @@ -446,12 +430,7 @@ func runCasesArchive(cmd *cobra.Command, args []string) error { return formatAPIError("archive case", err, r) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runCasesUnarchive(cmd *cobra.Command, args []string) error { @@ -470,12 +449,7 @@ func runCasesUnarchive(cmd *cobra.Command, args []string) error { return formatAPIError("unarchive case", err, r) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runCasesAssign(cmd *cobra.Command, args []string) error { @@ -497,12 +471,7 @@ func runCasesAssign(cmd *cobra.Command, args []string) error { return formatAPIError("assign case", err, r) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runCasesUpdateTitle(cmd *cobra.Command, args []string) error { @@ -524,12 +493,7 @@ func runCasesUpdateTitle(cmd *cobra.Command, args []string) error { return formatAPIError("update case title", err, r) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runCasesUpdatePriority(cmd *cobra.Command, args []string) error { @@ -556,12 +520,7 @@ func runCasesUpdatePriority(cmd *cobra.Command, args []string) error { return formatAPIError("update case priority", err, r) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } // Project implementations @@ -577,12 +536,7 @@ func runCasesProjectsList(cmd *cobra.Command, args []string) error { return formatAPIError("list projects", err, r) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runCasesProjectsGet(cmd *cobra.Command, args []string) error { @@ -599,12 +553,7 @@ func runCasesProjectsGet(cmd *cobra.Command, args []string) error { return formatAPIError("get project", err, r) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runCasesProjectsCreate(cmd *cobra.Command, args []string) error { @@ -624,12 +573,7 @@ func runCasesProjectsCreate(cmd *cobra.Command, args []string) error { return formatAPIError("create project", err, r) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runCasesProjectsDelete(cmd *cobra.Command, args []string) error { diff --git a/cmd/cicd.go b/cmd/cicd.go index 9b816343..75a0976b 100644 --- a/cmd/cicd.go +++ b/cmd/cicd.go @@ -10,7 +10,6 @@ import ( "strings" "github.com/DataDog/datadog-api-client-go/v2/api/datadogV2" - "github.com/DataDog/pup/pkg/formatter" "github.com/spf13/cobra" ) @@ -166,12 +165,7 @@ func runCICDPipelinesList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list pipelines: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(resp, nil) } func runCICDPipelinesGet(cmd *cobra.Command, args []string) error { @@ -200,12 +194,7 @@ func runCICDPipelinesGet(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to get pipeline: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(resp, nil) } func runCICDEventsSearch(cmd *cobra.Command, args []string) error { @@ -248,12 +237,7 @@ func runCICDEventsSearch(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to search events: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(resp, nil) } func runCICDEventsAggregate(cmd *cobra.Command, args []string) error { @@ -302,12 +286,7 @@ func runCICDEventsAggregate(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to aggregate events: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(resp, nil) } func buildComputeAggregation(compute string) (*datadogV2.CIAppCompute, error) { diff --git a/cmd/cloud.go b/cmd/cloud.go index 0f3d4af5..433667ba 100644 --- a/cmd/cloud.go +++ b/cmd/cloud.go @@ -9,7 +9,6 @@ import ( "fmt" "github.com/DataDog/datadog-api-client-go/v2/api/datadogV1" - "github.com/DataDog/pup/pkg/formatter" "github.com/spf13/cobra" ) @@ -96,12 +95,7 @@ func runCloudAWSList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list AWS integrations: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(resp, nil) } func runCloudGCPList(cmd *cobra.Command, args []string) error { @@ -119,12 +113,7 @@ func runCloudGCPList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list GCP integrations: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(resp, nil) } func runCloudAzureList(cmd *cobra.Command, args []string) error { @@ -142,10 +131,5 @@ func runCloudAzureList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list Azure integrations: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(resp, nil) } diff --git a/cmd/cost.go b/cmd/cost.go index 13290a5e..5b928b6c 100644 --- a/cmd/cost.go +++ b/cmd/cost.go @@ -10,7 +10,6 @@ import ( "time" "github.com/DataDog/datadog-api-client-go/v2/api/datadogV2" - "github.com/DataDog/pup/pkg/formatter" "github.com/spf13/cobra" ) @@ -142,13 +141,7 @@ func runCostProjected(cmd *cobra.Command, args []string) error { return formatAPIError("get projected cost", err, r) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runCostAttribution(cmd *cobra.Command, args []string) error { @@ -183,13 +176,7 @@ func runCostAttribution(cmd *cobra.Command, args []string) error { return formatAPIError("get cost attribution", err, r) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runCostByOrg(cmd *cobra.Command, args []string) error { @@ -253,11 +240,5 @@ func runCostByOrg(cmd *cobra.Command, args []string) error { return formatAPIError(fmt.Sprintf("get %s cost by org", costView), err, r) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } diff --git a/cmd/dashboards.go b/cmd/dashboards.go index cbb8bb48..2c8fab0c 100644 --- a/cmd/dashboards.go +++ b/cmd/dashboards.go @@ -9,7 +9,6 @@ import ( "fmt" "github.com/DataDog/datadog-api-client-go/v2/api/datadogV1" - "github.com/DataDog/pup/pkg/formatter" "github.com/spf13/cobra" ) @@ -212,13 +211,7 @@ func runDashboardsList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list dashboards: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runDashboardsGet(cmd *cobra.Command, args []string) error { @@ -238,13 +231,7 @@ func runDashboardsGet(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to get dashboard: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runDashboardsDelete(cmd *cobra.Command, args []string) error { @@ -282,11 +269,5 @@ func runDashboardsDelete(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to delete dashboard: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } diff --git a/cmd/data_governance.go b/cmd/data_governance.go index 8d089662..845c4896 100644 --- a/cmd/data_governance.go +++ b/cmd/data_governance.go @@ -9,7 +9,6 @@ import ( "fmt" "github.com/DataDog/datadog-api-client-go/v2/api/datadogV2" - "github.com/DataDog/pup/pkg/formatter" "github.com/spf13/cobra" ) @@ -72,10 +71,5 @@ func runDataGovernanceScannerRulesList(cmd *cobra.Command, args []string) error return fmt.Errorf("failed to list scanning rules: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(resp, nil) } diff --git a/cmd/downtime.go b/cmd/downtime.go index 9145070e..9e311606 100644 --- a/cmd/downtime.go +++ b/cmd/downtime.go @@ -9,7 +9,6 @@ import ( "fmt" "github.com/DataDog/datadog-api-client-go/v2/api/datadogV2" - "github.com/DataDog/pup/pkg/formatter" "github.com/spf13/cobra" ) @@ -81,12 +80,7 @@ func runDowntimeList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list downtimes: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(resp, nil) } func runDowntimeGet(cmd *cobra.Command, args []string) error { @@ -105,12 +99,7 @@ func runDowntimeGet(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to get downtime: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(resp, nil) } func runDowntimeCancel(cmd *cobra.Command, args []string) error { diff --git a/cmd/error_tracking.go b/cmd/error_tracking.go index 5cdc66b9..12db778f 100644 --- a/cmd/error_tracking.go +++ b/cmd/error_tracking.go @@ -7,7 +7,6 @@ package cmd import ( "github.com/DataDog/datadog-api-client-go/v2/api/datadogV2" - "github.com/DataDog/pup/pkg/formatter" "github.com/DataDog/pup/pkg/util" "github.com/spf13/cobra" ) @@ -167,13 +166,7 @@ func runErrorTrackingIssuesSearch(cmd *cobra.Command, args []string) error { resp.Data = resp.Data[:etLimit] } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runErrorTrackingIssuesGet(cmd *cobra.Command, args []string) error { @@ -199,11 +192,5 @@ func runErrorTrackingIssuesGet(cmd *cobra.Command, args []string) error { return formatAPIError("get error tracking issue", err, r) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } diff --git a/cmd/events.go b/cmd/events.go index bd86ff8d..77b7122a 100644 --- a/cmd/events.go +++ b/cmd/events.go @@ -11,7 +11,6 @@ import ( "github.com/DataDog/datadog-api-client-go/v2/api/datadogV1" "github.com/DataDog/datadog-api-client-go/v2/api/datadogV2" - "github.com/DataDog/pup/pkg/formatter" "github.com/spf13/cobra" ) @@ -117,12 +116,7 @@ func runEventsList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list events: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(resp, nil) } func runEventsSearch(cmd *cobra.Command, args []string) error { @@ -170,12 +164,7 @@ func runEventsSearch(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to search events: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(resp, nil) } func runEventsGet(cmd *cobra.Command, args []string) error { @@ -194,10 +183,5 @@ func runEventsGet(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to get event: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(resp, nil) } diff --git a/cmd/incidents.go b/cmd/incidents.go index 845b2769..2a80eb1b 100644 --- a/cmd/incidents.go +++ b/cmd/incidents.go @@ -9,7 +9,6 @@ import ( "fmt" "github.com/DataDog/datadog-api-client-go/v2/api/datadogV2" - "github.com/DataDog/pup/pkg/formatter" "github.com/spf13/cobra" ) @@ -295,13 +294,7 @@ func runIncidentsList(cmd *cobra.Command, args []string) error { return formatAPIError("list incidents", err, r) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runIncidentsGet(cmd *cobra.Command, args []string) error { @@ -318,13 +311,7 @@ func runIncidentsGet(cmd *cobra.Command, args []string) error { return formatAPIError("get incident", err, r) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } // Attachment implementations @@ -342,13 +329,7 @@ func runIncidentsAttachmentsList(cmd *cobra.Command, args []string) error { return formatAPIError("list incident attachments", err, r) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runIncidentsAttachmentsDelete(cmd *cobra.Command, args []string) error { diff --git a/cmd/infrastructure.go b/cmd/infrastructure.go index 42cb04c9..e3f6e08e 100644 --- a/cmd/infrastructure.go +++ b/cmd/infrastructure.go @@ -9,7 +9,6 @@ import ( "fmt" "github.com/DataDog/datadog-api-client-go/v2/api/datadogV1" - "github.com/DataDog/pup/pkg/formatter" "github.com/spf13/cobra" ) @@ -97,12 +96,7 @@ func runInfrastructureHostsList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list hosts: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(resp, nil) } func runInfrastructureHostsGet(cmd *cobra.Command, args []string) error { @@ -122,10 +116,5 @@ func runInfrastructureHostsGet(cmd *cobra.Command, args []string) error { } _ = hostname // Use hostname for filtering in actual implementation - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(resp, nil) } diff --git a/cmd/integrations.go b/cmd/integrations.go index 8deb1923..720f93d5 100644 --- a/cmd/integrations.go +++ b/cmd/integrations.go @@ -9,7 +9,6 @@ import ( "fmt" "github.com/DataDog/datadog-api-client-go/v2/api/datadogV1" - "github.com/DataDog/pup/pkg/formatter" "github.com/spf13/cobra" ) @@ -96,12 +95,7 @@ func runIntegrationsSlackList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list Slack channels: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(resp, nil) } func runIntegrationsPagerDutyList(cmd *cobra.Command, args []string) error { @@ -125,10 +119,5 @@ func runIntegrationsWebhooksList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list webhooks: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(resp, nil) } diff --git a/cmd/investigations.go b/cmd/investigations.go index 96875d97..06ae6f20 100644 --- a/cmd/investigations.go +++ b/cmd/investigations.go @@ -12,7 +12,6 @@ import ( "io" "net/http" - "github.com/DataDog/pup/pkg/formatter" "github.com/spf13/cobra" ) @@ -117,12 +116,7 @@ func runInvestigationsTrigger(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to trigger investigation: %w", err) } - output, err := formatter.FormatOutput(result, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(result, nil) } func runInvestigationsGet(cmd *cobra.Command, args []string) error { @@ -143,12 +137,7 @@ func runInvestigationsGet(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to get investigation: %w", err) } - output, err := formatter.FormatOutput(result, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(result, nil) } func runInvestigationsList(cmd *cobra.Command, args []string) error { @@ -173,12 +162,7 @@ func runInvestigationsList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list investigations: %w", err) } - output, err := formatter.FormatOutput(result, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(result, nil) } func buildTriggerRequestBody() (map[string]any, error) { diff --git a/cmd/logs_simple.go b/cmd/logs_simple.go index b51b7167..a5161351 100644 --- a/cmd/logs_simple.go +++ b/cmd/logs_simple.go @@ -727,6 +727,11 @@ func parseComputeString(compute string) (aggregation string, metric string, err // Implementation functions func runLogsSearch(cmd *cobra.Command, args []string) error { + // In agent mode, use a larger default limit (200) unless explicitly set + if isAgentMode() && !cmd.Flags().Changed("limit") { + logsLimit = 200 + } + // Validate storage tier before creating client storageTier, err := validateAndConvertStorageTier(logsStorage) if err != nil { @@ -866,16 +871,25 @@ func runLogsSearch(cmd *cobra.Command, args []string) error { finalResp := resp if pageCount > 1 { finalResp.SetData(allLogs) - printOutput("Fetched %d logs across %d pages\n\n", len(allLogs), pageCount) + if !isAgentMode() { + printOutput("Fetched %d logs across %d pages\n\n", len(allLogs), pageCount) + } } - output, err := formatter.FormatOutput(finalResp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err + count := len(allLogs) + var meta *formatter.Metadata + if isAgentMode() { + meta = &formatter.Metadata{ + Count: &count, + Command: "logs search", + } + if count == logsLimit { + meta.Truncated = true + meta.NextAction = fmt.Sprintf("Results may be truncated at %d. Use --limit=%d or narrow the --query", logsLimit, logsLimit*2) + } } - printOutput("%s\n", output) - return nil + return formatAndPrint(finalResp, meta) } func runLogsList(cmd *cobra.Command, args []string) error { @@ -940,13 +954,7 @@ func runLogsList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list logs: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runLogsQuery(cmd *cobra.Command, args []string) error { @@ -1013,13 +1021,7 @@ func runLogsQuery(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to query logs: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runLogsAggregate(cmd *cobra.Command, args []string) error { @@ -1112,13 +1114,7 @@ func runLogsAggregate(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to aggregate logs: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runLogsArchivesList(cmd *cobra.Command, args []string) error { @@ -1137,13 +1133,7 @@ func runLogsArchivesList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list log archives: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runLogsArchivesGet(cmd *cobra.Command, args []string) error { @@ -1163,13 +1153,7 @@ func runLogsArchivesGet(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to get log archive: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runLogsArchivesDelete(cmd *cobra.Command, args []string) error { @@ -1227,13 +1211,7 @@ func runLogsCustomDestinationsList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list custom destinations: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runLogsCustomDestinationsGet(cmd *cobra.Command, args []string) error { @@ -1253,13 +1231,7 @@ func runLogsCustomDestinationsGet(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to get custom destination: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runLogsMetricsList(cmd *cobra.Command, args []string) error { @@ -1278,13 +1250,7 @@ func runLogsMetricsList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list log-based metrics: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runLogsMetricsGet(cmd *cobra.Command, args []string) error { @@ -1304,13 +1270,7 @@ func runLogsMetricsGet(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to get log-based metric: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runLogsMetricsDelete(cmd *cobra.Command, args []string) error { diff --git a/cmd/metrics.go b/cmd/metrics.go index df419333..f50e3b3e 100644 --- a/cmd/metrics.go +++ b/cmd/metrics.go @@ -583,13 +583,11 @@ func runMetricsQuery(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to query metrics: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err + var meta *formatter.Metadata + if isAgentMode() { + meta = &formatter.Metadata{Command: "metrics query"} } - - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, meta) } // runMetricsSearch executes the metrics search command using the v1 API @@ -625,13 +623,7 @@ func runMetricsSearch(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to search metrics: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } // matchMetricName checks if a metric name matches a wildcard pattern. @@ -756,13 +748,7 @@ func runMetricsList(cmd *cobra.Command, args []string) error { } } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } // runMetricsMetadataGet executes the metadata get command @@ -788,13 +774,7 @@ func runMetricsMetadataGet(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to get metric metadata: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } // runMetricsMetadataUpdate executes the metadata update command @@ -844,13 +824,7 @@ func runMetricsMetadataUpdate(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to update metric metadata: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } // runMetricsSubmit executes the metrics submit command @@ -945,13 +919,7 @@ func runMetricsSubmit(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to submit metrics: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } // runMetricsTagsList executes the tags list command diff --git a/cmd/miscellaneous.go b/cmd/miscellaneous.go index 22b93091..b2fce0a2 100644 --- a/cmd/miscellaneous.go +++ b/cmd/miscellaneous.go @@ -9,7 +9,6 @@ import ( "fmt" "github.com/DataDog/datadog-api-client-go/v2/api/datadogV1" - "github.com/DataDog/pup/pkg/formatter" "github.com/spf13/cobra" ) @@ -66,12 +65,7 @@ func runMiscIPRanges(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to get IP ranges: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runMiscStatus(cmd *cobra.Command, args []string) error { @@ -80,10 +74,5 @@ func runMiscStatus(cmd *cobra.Command, args []string) error { "message": "API is operational", } - output, err := formatter.FormatOutput(result, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(result, nil) } diff --git a/cmd/monitors.go b/cmd/monitors.go index 27dacf54..084b54dc 100644 --- a/cmd/monitors.go +++ b/cmd/monitors.go @@ -6,6 +6,8 @@ package cmd import ( + "fmt" + "github.com/DataDog/datadog-api-client-go/v2/api/datadogV1" "github.com/DataDog/pup/pkg/formatter" "github.com/spf13/cobra" @@ -282,8 +284,11 @@ func runMonitorsList(cmd *cobra.Command, args []string) error { opts.WithMonitorTags(monitorTags) } - // Set limit for results (default 200, max 1000) - // Users can increase limit or use filters to find specific monitors + // In agent mode, use a larger default limit (500) unless explicitly set + if isAgentMode() && !cmd.Flags().Changed("limit") { + monitorLimit = 500 + } + if monitorLimit > 1000 { monitorLimit = 1000 } @@ -316,18 +321,26 @@ func runMonitorsList(cmd *cobra.Command, args []string) error { resp = resp[:monitorLimit] } - // Convert to interface{} to ensure compatibility with formatter - var data interface{} = resp + count := len(resp) + truncated := originalCount > int(monitorLimit) + var meta *formatter.Metadata + if isAgentMode() { + meta = &formatter.Metadata{ + Count: &count, + Truncated: truncated, + Command: "monitors list", + } + if truncated { + meta.NextAction = fmt.Sprintf("Use --limit=%d or refine with --tags/--name filters", min(int(monitorLimit)*2, 1000)) + } + } - output, err := formatter.FormatOutput(data, formatter.OutputFormat(outputFormat)) - if err != nil { + if err := formatAndPrint(resp, meta); err != nil { return err } - printOutput("%s\n", output) - - // Show count info if we're truncating - if originalCount > int(monitorLimit) { + // Show count info if we're truncating (human mode only) + if !isAgentMode() && truncated { printOutput("\nShowing %d of %d monitors (use --limit to adjust)\n", monitorLimit, originalCount) } @@ -348,13 +361,11 @@ func runMonitorsGet(cmd *cobra.Command, args []string) error { return formatAPIError("get monitor", err, r) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err + var meta *formatter.Metadata + if isAgentMode() { + meta = &formatter.Metadata{Command: "monitors get"} } - - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, meta) } func runMonitorsDelete(cmd *cobra.Command, args []string) error { @@ -389,13 +400,7 @@ func runMonitorsDelete(cmd *cobra.Command, args []string) error { return formatAPIError("delete monitor", err, r) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runMonitorsSearch(cmd *cobra.Command, args []string) error { @@ -425,11 +430,5 @@ func runMonitorsSearch(cmd *cobra.Command, args []string) error { return formatAPIError("search monitors", err, r) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } diff --git a/cmd/network.go b/cmd/network.go index 32b87833..b2e90d19 100644 --- a/cmd/network.go +++ b/cmd/network.go @@ -6,9 +6,7 @@ package cmd import ( - "fmt" - "github.com/DataDog/pup/pkg/formatter" "github.com/spf13/cobra" ) @@ -73,12 +71,7 @@ func runNetworkFlowsList(cmd *cobra.Command, args []string) error { }, } - output, err := formatter.FormatOutput(result, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(result, nil) } func runNetworkDevicesList(cmd *cobra.Command, args []string) error { @@ -89,10 +82,5 @@ func runNetworkDevicesList(cmd *cobra.Command, args []string) error { }, } - output, err := formatter.FormatOutput(result, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(result, nil) } diff --git a/cmd/notebooks.go b/cmd/notebooks.go index 1818f27f..c291f79a 100644 --- a/cmd/notebooks.go +++ b/cmd/notebooks.go @@ -13,7 +13,6 @@ import ( "strings" "github.com/DataDog/datadog-api-client-go/v2/api/datadogV1" - "github.com/DataDog/pup/pkg/formatter" "github.com/spf13/cobra" ) @@ -154,12 +153,7 @@ func runNotebooksCreate(cmd *cobra.Command, args []string) error { return formatAPIError("create notebook", err, r) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runNotebooksUpdate(cmd *cobra.Command, args []string) error { @@ -187,12 +181,7 @@ func runNotebooksUpdate(cmd *cobra.Command, args []string) error { return formatAPIError("update notebook", err, r) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runNotebooksList(cmd *cobra.Command, args []string) error { @@ -207,12 +196,7 @@ func runNotebooksList(cmd *cobra.Command, args []string) error { return formatAPIError("list notebooks", err, r) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runNotebooksGet(cmd *cobra.Command, args []string) error { @@ -228,12 +212,7 @@ func runNotebooksGet(cmd *cobra.Command, args []string) error { return formatAPIError("get notebook", err, r) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runNotebooksDelete(cmd *cobra.Command, args []string) error { diff --git a/cmd/obs_pipelines.go b/cmd/obs_pipelines.go index 66279a2f..cd38e3a9 100644 --- a/cmd/obs_pipelines.go +++ b/cmd/obs_pipelines.go @@ -6,9 +6,7 @@ package cmd import ( - "fmt" - "github.com/DataDog/pup/pkg/formatter" "github.com/spf13/cobra" ) @@ -62,12 +60,7 @@ func runObsPipelinesList(cmd *cobra.Command, args []string) error { }, } - output, err := formatter.FormatOutput(result, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(result, nil) } func runObsPipelinesGet(cmd *cobra.Command, args []string) error { @@ -82,10 +75,5 @@ func runObsPipelinesGet(cmd *cobra.Command, args []string) error { }, } - output, err := formatter.FormatOutput(result, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(result, nil) } diff --git a/cmd/on_call.go b/cmd/on_call.go index f5f77a80..6350e642 100644 --- a/cmd/on_call.go +++ b/cmd/on_call.go @@ -9,7 +9,6 @@ import ( "fmt" "github.com/DataDog/datadog-api-client-go/v2/api/datadogV2" - "github.com/DataDog/pup/pkg/formatter" "github.com/spf13/cobra" ) @@ -312,12 +311,7 @@ func runOnCallTeamsList(cmd *cobra.Command, args []string) error { return formatAPIError("list teams", err, r) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runOnCallTeamsGet(cmd *cobra.Command, args []string) error { @@ -333,12 +327,7 @@ func runOnCallTeamsGet(cmd *cobra.Command, args []string) error { return formatAPIError("get team", err, r) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runOnCallTeamsCreate(cmd *cobra.Command, args []string) error { @@ -369,12 +358,7 @@ func runOnCallTeamsCreate(cmd *cobra.Command, args []string) error { return formatAPIError("create team", err, r) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runOnCallTeamsUpdate(cmd *cobra.Command, args []string) error { @@ -407,12 +391,7 @@ func runOnCallTeamsUpdate(cmd *cobra.Command, args []string) error { return formatAPIError("update team", err, r) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runOnCallTeamsDelete(cmd *cobra.Command, args []string) error { @@ -479,12 +458,7 @@ func runOnCallTeamsMembershipsList(cmd *cobra.Command, args []string) error { return formatAPIError("list team memberships", err, r) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runOnCallTeamsMembershipsAdd(cmd *cobra.Command, args []string) error { @@ -524,12 +498,7 @@ func runOnCallTeamsMembershipsAdd(cmd *cobra.Command, args []string) error { return formatAPIError("add team member", err, r) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runOnCallTeamsMembershipsUpdate(cmd *cobra.Command, args []string) error { @@ -558,12 +527,7 @@ func runOnCallTeamsMembershipsUpdate(cmd *cobra.Command, args []string) error { return formatAPIError("update team membership", err, r) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runOnCallTeamsMembershipsRemove(cmd *cobra.Command, args []string) error { diff --git a/cmd/organizations.go b/cmd/organizations.go index ab13638b..70ec2efa 100644 --- a/cmd/organizations.go +++ b/cmd/organizations.go @@ -9,7 +9,6 @@ import ( "fmt" "github.com/DataDog/datadog-api-client-go/v2/api/datadogV1" - "github.com/DataDog/pup/pkg/formatter" "github.com/spf13/cobra" ) @@ -66,12 +65,7 @@ func runOrganizationsGet(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to get organization: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(resp, nil) } func runOrganizationsList(cmd *cobra.Command, args []string) error { @@ -89,10 +83,5 @@ func runOrganizationsList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list organizations: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(resp, nil) } diff --git a/cmd/product_analytics.go b/cmd/product_analytics.go index c01c7b88..ce0c84ae 100644 --- a/cmd/product_analytics.go +++ b/cmd/product_analytics.go @@ -10,7 +10,6 @@ import ( "fmt" "github.com/DataDog/datadog-api-client-go/v2/api/datadogV2" - "github.com/DataDog/pup/pkg/formatter" "github.com/spf13/cobra" ) @@ -146,11 +145,5 @@ func runProductAnalyticsEventsSend(cmd *cobra.Command, args []string) error { return formatAPIError("submit product analytics event", err, r) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } diff --git a/cmd/root.go b/cmd/root.go index 0eb5520f..e4ca4efd 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -7,16 +7,21 @@ package cmd import ( "bufio" + "encoding/json" "errors" "fmt" "io" + "net/http" "os" "strings" "github.com/DataDog/datadog-api-client-go/v2/api/datadog" "github.com/DataDog/pup/internal/version" + "github.com/DataDog/pup/pkg/agenthelp" "github.com/DataDog/pup/pkg/client" "github.com/DataDog/pup/pkg/config" + "github.com/DataDog/pup/pkg/formatter" + "github.com/DataDog/pup/pkg/useragent" "github.com/spf13/cobra" ) @@ -41,6 +46,7 @@ var ( ddClient *client.Client outputFormat string autoApprove bool + agentFlag bool // Dependency injection points for testing clientFactory = defaultClientFactory @@ -64,8 +70,58 @@ func Execute() error { return ExecuteWithArgs(os.Args[1:]) } +// hasFlag checks if a flag is present in the args. +func hasFlag(args []string, flag string) bool { + for _, a := range args { + if a == flag { + return true + } + } + return false +} + +// firstNonFlagArg returns the first argument that isn't a flag. +func firstNonFlagArg(args []string) string { + for _, a := range args { + if !isFlag(a) { + return a + } + } + return "" +} + +// printAgentSchema generates and prints the JSON schema for the given subtree. +func printAgentSchema(subtree string) error { + var data interface{} + if subtree != "" { + schema := agenthelp.GenerateSubtreeSchema(rootCmd, subtree) + if schema != nil { + data = schema + } + } + if data == nil { + s := agenthelp.GenerateSchema(rootCmd) + data = &s + } + + out, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal schema: %w", err) + } + printOutput("%s\n", string(out)) + return nil +} + // ExecuteWithArgs executes the root command with the given arguments func ExecuteWithArgs(args []string) error { + // In agent mode, intercept --help/-h and return structured JSON schema instead + // of cobra's human-oriented help text. This lets agents run the natural + // "pup --help" or "pup logs --help" and get the machine-readable schema + // without needing to know about a special flag. + if (hasFlag(args, "--help") || hasFlag(args, "-h")) && useragent.IsAgentMode() { + return printAgentSchema(firstNonFlagArg(args)) + } + // IMPORTANT: Aliases are checked LAST to prevent overriding built-in commands. // This ensures that no alias can shadow an existing pup command, even if validation // is bypassed or a new command is added that conflicts with an existing alias. @@ -177,6 +233,7 @@ func init() { // Global flags rootCmd.PersistentFlags().StringVarP(&outputFormat, "output", "o", "json", "Output format (json, table, yaml)") rootCmd.PersistentFlags().BoolVarP(&autoApprove, "yes", "y", false, "Skip confirmation prompts (auto-approve all operations)") + rootCmd.PersistentFlags().BoolVar(&agentFlag, "agent", false, "Enable agent mode (auto-detected for AI coding assistants)") // Add subcommands rootCmd.AddCommand(versionCmd) @@ -221,6 +278,7 @@ func init() { rootCmd.AddCommand(productAnalyticsCmd) rootCmd.AddCommand(casesCmd) rootCmd.AddCommand(apmCmd) + rootCmd.AddCommand(agentCmd) } // initConfig reads in config file and ENV variables if set. @@ -239,6 +297,17 @@ func initConfig() { fmt.Fprintf(os.Stderr, "Warning: failed to set DD_CLI_AUTO_APPROVE: %v\n", err) } } + + // Determine agent mode: explicit flag takes precedence, then auto-detect + if agentFlag || useragent.IsAgentMode() { + cfg.AgentMode = true + cfg.AutoApprove = true + } +} + +// isAgentMode returns true if the current session is in agent mode. +func isAgentMode() bool { + return cfg != nil && cfg.AgentMode } // getClient returns a configured Datadog client @@ -283,6 +352,27 @@ func printOutput(format string, a ...any) { _, _ = fmt.Fprintf(outputWriter, format, a...) } +// formatAndPrint formats data with optional agent envelope and prints it. +// In agent mode, data is wrapped in an AgentEnvelope with metadata. +// In human mode, data is formatted using the standard formatter. +func formatAndPrint(data interface{}, meta *formatter.Metadata) error { + if isAgentMode() { + output, err := formatter.WrapForAgent(data, meta) + if err != nil { + return err + } + printOutput("%s\n", output) + return nil + } + + output, err := formatter.FormatOutput(data, formatter.OutputFormat(outputFormat)) + if err != nil { + return err + } + printOutput("%s\n", output) + return nil +} + // readConfirmation reads user confirmation from input func readConfirmation() (string, error) { scanner := bufio.NewScanner(inputReader) @@ -312,38 +402,53 @@ func extractAPIErrorBody(err error) string { // formatAPIError creates user-friendly error messages for API errors. // It extracts the API response body from GenericOpenAPIError when available // and appends contextual guidance based on the HTTP status code. +// In agent mode, returns a structured JSON error. func formatAPIError(operation string, err error, response any) error { - type httpResponse interface { - StatusCode() int + // Extract status code from the response. + // The datadog API client returns *http.Response (field) for some APIs + // and types with StatusCode() method for others. + statusCode := 0 + switch r := response.(type) { + case *http.Response: + if r != nil { + statusCode = r.StatusCode + } + case interface{ StatusCode() int }: + if r != nil { + statusCode = r.StatusCode() + } } - if r, ok := response.(httpResponse); ok && r != nil { - statusCode := r.StatusCode() + if statusCode > 0 { + apiBody := extractAPIErrorBody(err) + + // In agent mode, return structured JSON error + if isAgentMode() { + jsonErr, fmtErr := formatter.FormatAgentError(operation, statusCode, err.Error(), apiBody) + if fmtErr == nil { + return fmt.Errorf("%s", jsonErr) + } + } + baseMsg := fmt.Sprintf("failed to %s: %v (status: %d)", operation, err, statusCode) // Include API response body if available - if body := extractAPIErrorBody(err); body != "" { - baseMsg = fmt.Sprintf("failed to %s: %v (status: %d)\nAPI Response: %s", operation, err, statusCode, body) + if apiBody != "" { + baseMsg = fmt.Sprintf("failed to %s: %v (status: %d)\nAPI Response: %s", operation, err, statusCode, apiBody) } switch { case statusCode >= 500: - // 5xx Server errors return fmt.Errorf("%s\n\nThe Datadog API is experiencing issues. Please try again later or check https://status.datadoghq.com/", baseMsg) case statusCode == 429: - // Rate limiting return fmt.Errorf("%s\n\nYou are being rate limited. Please wait a moment and try again.", baseMsg) case statusCode == 403: - // Forbidden return fmt.Errorf("%s\n\nAccess denied. Verify your API/App keys have the required permissions.", baseMsg) case statusCode == 401: - // Unauthorized return fmt.Errorf("%s\n\nAuthentication failed. Run 'pup auth login' or verify your DD_API_KEY and DD_APP_KEY.", baseMsg) case statusCode == 404: - // Not found return fmt.Errorf("%s\n\nResource not found. Verify the ID or check if the resource was deleted.", baseMsg) case statusCode >= 400: - // Other 4xx client errors return fmt.Errorf("%s\n\nInvalid request. Check your parameters and try again.", baseMsg) default: return fmt.Errorf("%s", baseMsg) diff --git a/cmd/rum.go b/cmd/rum.go index 25265575..38c3d38f 100644 --- a/cmd/rum.go +++ b/cmd/rum.go @@ -11,7 +11,6 @@ import ( "github.com/DataDog/datadog-api-client-go/v2/api/datadog" "github.com/DataDog/datadog-api-client-go/v2/api/datadogV2" - "github.com/DataDog/pup/pkg/formatter" "github.com/DataDog/pup/pkg/util" "github.com/spf13/cobra" ) @@ -402,12 +401,7 @@ func runRumAppsList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list RUM applications: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(resp, nil) } func runRumAppsGet(cmd *cobra.Command, args []string) error { @@ -426,12 +420,7 @@ func runRumAppsGet(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to get RUM application: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(resp, nil) } func runRumAppsCreate(cmd *cobra.Command, args []string) error { @@ -465,12 +454,7 @@ func runRumAppsCreate(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to create RUM application: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(resp, nil) } func runRumAppsUpdate(cmd *cobra.Command, args []string) error { @@ -508,12 +492,7 @@ func runRumAppsUpdate(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to update RUM application: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(resp, nil) } func runRumAppsDelete(cmd *cobra.Command, args []string) error { @@ -563,12 +542,7 @@ func runRumMetricsList(cmd *cobra.Command, args []string) error { return formatAPIError("list RUM metrics", err, r) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runRumMetricsGet(cmd *cobra.Command, args []string) error { @@ -584,12 +558,7 @@ func runRumMetricsGet(cmd *cobra.Command, args []string) error { return formatAPIError("get RUM metric", err, r) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runRumMetricsCreate(cmd *cobra.Command, args []string) error { @@ -617,12 +586,7 @@ func runRumRetentionFiltersList(cmd *cobra.Command, args []string) error { return formatAPIError("list RUM retention filters", err, r) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runRumRetentionFiltersGet(cmd *cobra.Command, args []string) error { @@ -638,12 +602,7 @@ func runRumRetentionFiltersGet(cmd *cobra.Command, args []string) error { return formatAPIError("get RUM retention filter", err, r) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runRumRetentionFiltersCreate(cmd *cobra.Command, args []string) error { @@ -700,12 +659,7 @@ func runRumSessionsList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list RUM sessions: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(resp, nil) } func runRumSessionsSearch(cmd *cobra.Command, args []string) error { @@ -749,12 +703,7 @@ func runRumSessionsSearch(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to search RUM sessions: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(resp, nil) } // RUM Playlists (Placeholder) diff --git a/cmd/scorecards.go b/cmd/scorecards.go index dc11f729..276d7c32 100644 --- a/cmd/scorecards.go +++ b/cmd/scorecards.go @@ -6,9 +6,7 @@ package cmd import ( - "fmt" - "github.com/DataDog/pup/pkg/formatter" "github.com/spf13/cobra" ) @@ -62,12 +60,7 @@ func runScorecardsList(cmd *cobra.Command, args []string) error { }, } - output, err := formatter.FormatOutput(result, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(result, nil) } func runScorecardsGet(cmd *cobra.Command, args []string) error { @@ -82,10 +75,5 @@ func runScorecardsGet(cmd *cobra.Command, args []string) error { }, } - output, err := formatter.FormatOutput(result, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(result, nil) } diff --git a/cmd/security.go b/cmd/security.go index e00a832a..e94904ec 100644 --- a/cmd/security.go +++ b/cmd/security.go @@ -9,7 +9,6 @@ import ( "fmt" "github.com/DataDog/datadog-api-client-go/v2/api/datadogV2" - "github.com/DataDog/pup/pkg/formatter" "github.com/spf13/cobra" ) @@ -136,12 +135,7 @@ func runSecurityRulesList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list security rules: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(resp, nil) } func runSecurityRulesGet(cmd *cobra.Command, args []string) error { @@ -160,12 +154,7 @@ func runSecurityRulesGet(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to get security rule: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(resp, nil) } func runSecuritySignalsList(cmd *cobra.Command, args []string) error { @@ -183,12 +172,7 @@ func runSecuritySignalsList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list security signals: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(resp, nil) } func runSecurityFindingsSearch(cmd *cobra.Command, args []string) error { @@ -231,10 +215,5 @@ func runSecurityFindingsSearch(cmd *cobra.Command, args []string) error { return formatAPIError("search security findings", err, r) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } diff --git a/cmd/service_catalog.go b/cmd/service_catalog.go index 1d8c7488..16e9cc76 100644 --- a/cmd/service_catalog.go +++ b/cmd/service_catalog.go @@ -9,7 +9,6 @@ import ( "fmt" "github.com/DataDog/datadog-api-client-go/v2/api/datadogV2" - "github.com/DataDog/pup/pkg/formatter" "github.com/spf13/cobra" ) @@ -70,12 +69,7 @@ func runServiceCatalogList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list services: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(resp, nil) } func runServiceCatalogGet(cmd *cobra.Command, args []string) error { @@ -94,10 +88,5 @@ func runServiceCatalogGet(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to get service: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(resp, nil) } diff --git a/cmd/slos.go b/cmd/slos.go index 52982b28..b90d7157 100644 --- a/cmd/slos.go +++ b/cmd/slos.go @@ -9,7 +9,6 @@ import ( "fmt" "github.com/DataDog/datadog-api-client-go/v2/api/datadogV1" - "github.com/DataDog/pup/pkg/formatter" "github.com/spf13/cobra" ) @@ -230,13 +229,7 @@ func runSlosList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list SLOs: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runSlosGet(cmd *cobra.Command, args []string) error { @@ -256,13 +249,7 @@ func runSlosGet(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to get SLO: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runSlosDelete(cmd *cobra.Command, args []string) error { @@ -300,11 +287,5 @@ func runSlosDelete(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to delete SLO: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } diff --git a/cmd/synthetics.go b/cmd/synthetics.go index 2a7f1e40..fee3e2d9 100644 --- a/cmd/synthetics.go +++ b/cmd/synthetics.go @@ -9,7 +9,6 @@ import ( "fmt" "github.com/DataDog/datadog-api-client-go/v2/api/datadogV1" - "github.com/DataDog/pup/pkg/formatter" "github.com/spf13/cobra" ) @@ -92,12 +91,7 @@ func runSyntheticsTestsList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list synthetic tests: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runSyntheticsTestsGet(cmd *cobra.Command, args []string) error { @@ -116,12 +110,7 @@ func runSyntheticsTestsGet(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to get synthetic test: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runSyntheticsLocationsList(cmd *cobra.Command, args []string) error { @@ -139,10 +128,5 @@ func runSyntheticsLocationsList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list locations: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } diff --git a/cmd/tags.go b/cmd/tags.go index cacaba7f..ae653d25 100644 --- a/cmd/tags.go +++ b/cmd/tags.go @@ -9,7 +9,6 @@ import ( "fmt" "github.com/DataDog/datadog-api-client-go/v2/api/datadogV1" - "github.com/DataDog/pup/pkg/formatter" "github.com/spf13/cobra" ) @@ -95,12 +94,7 @@ func runTagsList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list host tags: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runTagsGet(cmd *cobra.Command, args []string) error { @@ -119,12 +113,7 @@ func runTagsGet(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to get host tags: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runTagsAdd(cmd *cobra.Command, args []string) error { @@ -149,12 +138,7 @@ func runTagsAdd(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to add host tags: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runTagsUpdate(cmd *cobra.Command, args []string) error { @@ -179,12 +163,7 @@ func runTagsUpdate(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to update host tags: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runTagsDelete(cmd *cobra.Command, args []string) error { diff --git a/cmd/usage.go b/cmd/usage.go index cc5dfd4f..0e66647c 100644 --- a/cmd/usage.go +++ b/cmd/usage.go @@ -10,7 +10,6 @@ import ( "time" "github.com/DataDog/datadog-api-client-go/v2/api/datadogV1" - "github.com/DataDog/pup/pkg/formatter" "github.com/spf13/cobra" ) @@ -90,12 +89,7 @@ func runUsageSummary(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to get usage summary: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runUsageHourly(cmd *cobra.Command, args []string) error { @@ -125,10 +119,5 @@ func runUsageHourly(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to get hourly usage: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } diff --git a/cmd/users.go b/cmd/users.go index 5b9312ae..99d595f7 100644 --- a/cmd/users.go +++ b/cmd/users.go @@ -9,7 +9,6 @@ import ( "fmt" "github.com/DataDog/datadog-api-client-go/v2/api/datadogV2" - "github.com/DataDog/pup/pkg/formatter" "github.com/spf13/cobra" ) @@ -83,12 +82,7 @@ func runUsersList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list users: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runUsersGet(cmd *cobra.Command, args []string) error { @@ -107,12 +101,7 @@ func runUsersGet(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to get user: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } func runUsersRolesList(cmd *cobra.Command, args []string) error { @@ -130,10 +119,5 @@ func runUsersRolesList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list roles: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - printOutput("%s\n", output) - return nil + return formatAndPrint(resp, nil) } diff --git a/cmd/vulnerabilities.go b/cmd/vulnerabilities.go index f5ae694b..1b231f5c 100644 --- a/cmd/vulnerabilities.go +++ b/cmd/vulnerabilities.go @@ -9,7 +9,6 @@ import ( "fmt" "github.com/DataDog/datadog-api-client-go/v2/api/datadogV2" - "github.com/DataDog/pup/pkg/formatter" "github.com/spf13/cobra" ) @@ -152,12 +151,7 @@ func runStaticAnalysisASTList(cmd *cobra.Command, args []string) error { }, } - output, err := formatter.FormatOutput(result, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(result, nil) } func runStaticAnalysisASTGet(cmd *cobra.Command, args []string) error { @@ -172,12 +166,7 @@ func runStaticAnalysisASTGet(cmd *cobra.Command, args []string) error { }, } - output, err := formatter.FormatOutput(result, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(result, nil) } func runStaticAnalysisCustomRulesetsList(cmd *cobra.Command, args []string) error { @@ -195,12 +184,7 @@ func runStaticAnalysisCustomRulesetsList(cmd *cobra.Command, args []string) erro return fmt.Errorf("failed to list custom rulesets: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(resp, nil) } func runStaticAnalysisCustomRulesetsGet(cmd *cobra.Command, args []string) error { @@ -219,12 +203,7 @@ func runStaticAnalysisCustomRulesetsGet(cmd *cobra.Command, args []string) error return fmt.Errorf("failed to get custom ruleset: %w", err) } - output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(resp, nil) } func runStaticAnalysisSCAList(cmd *cobra.Command, args []string) error { @@ -240,12 +219,7 @@ func runStaticAnalysisSCAList(cmd *cobra.Command, args []string) error { }, } - output, err := formatter.FormatOutput(result, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(result, nil) } func runStaticAnalysisSCAGet(cmd *cobra.Command, args []string) error { @@ -260,12 +234,7 @@ func runStaticAnalysisSCAGet(cmd *cobra.Command, args []string) error { }, } - output, err := formatter.FormatOutput(result, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(result, nil) } func runStaticAnalysisCoverageList(cmd *cobra.Command, args []string) error { @@ -282,12 +251,7 @@ func runStaticAnalysisCoverageList(cmd *cobra.Command, args []string) error { }, } - output, err := formatter.FormatOutput(result, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(result, nil) } func runStaticAnalysisCoverageGet(cmd *cobra.Command, args []string) error { @@ -302,10 +266,5 @@ func runStaticAnalysisCoverageGet(cmd *cobra.Command, args []string) error { }, } - output, err := formatter.FormatOutput(result, formatter.OutputFormat(outputFormat)) - if err != nil { - return err - } - fmt.Println(output) - return nil + return formatAndPrint(result, nil) } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 649d89d1..dd6daac2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -137,22 +137,34 @@ Based on [RFC 6749](https://tools.ietf.org/html/rfc6749) and [RFC 7636](https:// **Fallback:** - If refresh fails, prompt user to re-authenticate -## User Agent +## User Agent & Agent Mode Custom user agent identifies pup CLI and detects AI coding assistants: **Format:** ``` -pup/v0.1.0 (go go1.25.0; os darwin; arch arm64) # Without agent -pup/v0.1.0 (go go1.25.0; os darwin; arch arm64) claude-code # With agent +pup/v0.1.0 (go go1.25.0; os darwin; arch arm64) # Without agent +pup/v0.1.0 (go go1.25.0; os darwin; arch arm64; ai-agent claude-code) # With agent ``` -**AI Agent Detection:** -- `CLAUDECODE=1` or `CLAUDE_CODE=1` → appends `claude-code` -- `CURSOR_AGENT=true` or `CURSOR_AGENT=1` → appends `cursor` -- Precedence: CLAUDECODE > CURSOR_AGENT +**AI Agent Detection** (`pkg/useragent`): -**Implementation:** `pkg/useragent` - Separate package for reusability and testing. +Table-driven registry detecting 11 agents via environment variables. First match wins: +- Claude Code (`CLAUDECODE`, `CLAUDE_CODE`), Cursor (`CURSOR_AGENT`), Codex (`CODEX`, `OPENAI_CODEX`), OpenCode (`OPENCODE`), Aider (`AIDER`), Cline (`CLINE`), Windsurf (`WINDSURF_AGENT`), GitHub Copilot (`GITHUB_COPILOT`), Amazon Q (`AMAZON_Q`, `AWS_Q_DEVELOPER`), Gemini Code Assist (`GEMINI_CODE_ASSIST`), Sourcegraph Cody (`SRC_CODY`) +- Manual override: `FORCE_AGENT_MODE=1` or `--agent` flag + +**Agent Mode Behavior** (when detected): +- `--help` returns structured JSON schema instead of Cobra text +- Confirmation prompts auto-approved (prevents stdin hangs) +- API responses wrapped in metadata envelope (count, truncation, warnings) +- Errors returned as structured JSON with suggestions + +**Agent Operability Packages:** +- `pkg/agenthelp/` — Schema generation (Cobra tree walker), steering content, embedded guide +- `pkg/formatter/envelope.go` — Agent envelope and structured error formatting +- `cmd/agent.go` — `pup agent schema`, `pup agent guide` commands + +See [LLM_GUIDE.md](docs/LLM_GUIDE.md) for the complete agent guide. ## API Client Wrapper diff --git a/docs/LLM_GUIDE.md b/docs/LLM_GUIDE.md index 4c07efbf..1ee58f31 100644 --- a/docs/LLM_GUIDE.md +++ b/docs/LLM_GUIDE.md @@ -1,462 +1,355 @@ # LLM Agent Guide for Pup CLI -This guide helps AI agents (LLMs) understand and effectively use the Pup CLI tool. +This guide helps AI coding agents understand and effectively use the Pup CLI tool. It covers the agent operability system, discovery commands, query syntax, and common workflows. -## Quick Reference +For the runtime version of this guide (embedded in the binary), run `pup agent guide`. -### Discovery Commands +## Agent Mode -```bash -# See all available commands -pup --help - -# Get detailed help for any command -pup --help -pup --help +Pup auto-detects AI coding agents and switches to **agent mode**, which changes how the CLI behaves. Agent mode is triggered by any of: -# Examples -pup monitors --help -pup monitors list --help -pup auth login --help -``` - -### Authentication +| Method | Example | +|--------|---------| +| Auto-detect | `CLAUDECODE=1`, `CLAUDE_CODE=1`, `CURSOR_AGENT=1`, `CODEX=1`, `OPENAI_CODEX=1`, `OPENCODE=1`, `AIDER=1`, `CLINE=1`, `WINDSURF_AGENT=1`, `GITHUB_COPILOT=1`, `AMAZON_Q=1`, `AWS_Q_DEVELOPER=1`, `GEMINI_CODE_ASSIST=1`, `SRC_CODY=1` | +| Explicit flag | `pup --agent ` | +| Environment override | `FORCE_AGENT_MODE=1` | -```bash -# OAuth2 (Recommended) -pup auth login # Browser-based login -pup auth status # Check auth status -pup auth refresh # Refresh token -pup auth logout # Logout - -# API Keys (Legacy) -export DD_API_KEY="..." -export DD_APP_KEY="..." -``` +### What changes in agent mode -## Command Patterns +| Behavior | Human Mode | Agent Mode | +|----------|-----------|------------| +| `--help` output | Cobra text help | Structured JSON schema | +| Confirmation prompts | Interactive stdin | Auto-approved (no hangs) | +| Error format | Human text with suggestions | Structured JSON with error codes | +| API response wrapping | Raw API response | Envelope with metadata (count, truncation, warnings) | -### List Resources +### Verifying agent mode ```bash -# General pattern -pup list [--filters] +# This should return JSON schema (not text) when agent is detected +pup --help -# Examples -pup monitors list -pup monitors list --name="CPU" -pup monitors list --tags="env:production" +# Force agent mode for testing +FORCE_AGENT_MODE=1 pup --help -pup dashboards list -pup slos list -pup incidents list +# Subtree schema (only logs commands + logs query syntax) +FORCE_AGENT_MODE=1 pup logs --help ``` -### Get Resource Details +## Discovery Commands (Recommended First Steps) -```bash -# General pattern -pup get - -# Examples -pup monitors get 12345678 -pup dashboards get abc-def-123 -pup slos get abc-123-def -pup incidents get abc-123-def -``` +### 1. Get full command schema -### Delete Resources +In agent mode, `--help` returns the complete JSON schema with all commands, flags, query syntax, workflows, best practices, and anti-patterns in a single call: ```bash -# General pattern (requires confirmation) -pup delete - -# With auto-approve (no confirmation) -pup delete --yes - -# Examples -pup monitors delete 12345678 --yes -pup dashboards delete abc-def-123 --yes -pup slos delete abc-123-def --yes +pup --help +# Returns: { version, auth, global_flags, commands[], query_syntax, time_formats, workflows, best_practices, anti_patterns } ``` -## Parsing Output - -All commands output JSON by default. Use `jq` for parsing: +### 2. Get domain-specific schema ```bash -# Get monitor names -pup monitors list | jq '.[] | .name' - -# Filter by field -pup monitors list | jq '.[] | select(.overall_state == "Alert")' - -# Extract specific fields -pup monitors get 12345678 | jq '{name: .name, state: .overall_state}' - -# Count resources -pup dashboards list | jq '.dashboards | length' +pup logs --help # Only logs commands + logs query syntax +pup monitors --help # Only monitors commands +pup metrics --help # Only metrics commands ``` -## Common Tasks - -### Monitor Management +### 3. Explicit schema commands (work regardless of agent mode) ```bash -# Find all critical monitors -pup monitors list | jq '.[] | select(.overall_state == "Alert")' - -# Get monitor by name pattern -pup monitors list --name="CPU" | jq '.[0] | .id' - -# Check monitor status -pup monitors get 12345678 | jq '.overall_state' - -# List production monitors -pup monitors list --tags="env:production" +pup agent schema # Full JSON schema +pup agent schema --compact # Minimal schema (names + flags only, fewer tokens) +pup agent guide # Full steering guide (markdown) +pup agent guide logs # Domain-specific guide section ``` -### Dashboard Management +## Authentication ```bash -# Find dashboard by name -pup dashboards list | jq '.dashboards[] | select(.title | contains("API"))' - -# Get dashboard ID -pup dashboards list | jq '.dashboards[0] | .id' +# OAuth2 (recommended) — opens browser for secure login +pup auth login -# Backup dashboard -pup dashboards get abc-def-123 > dashboard-backup.json +# Check auth status +pup auth status -# Export all dashboards -pup dashboards list | jq -r '.dashboards[].id' | \ - xargs -I {} pup dashboards get {} > dashboards-{}.json +# API keys (legacy) — set environment variables +export DD_API_KEY="your-key" +export DD_APP_KEY="your-key" +export DD_SITE="datadoghq.com" ``` -### SLO Monitoring +- OAuth2 tokens are stored in the OS keychain and refresh automatically +- Some endpoints require API keys even with OAuth2 (e.g., logs search v1) +- In agent mode, if auth fails, the error JSON includes `suggestions` with remediation steps -```bash -# Find breaching SLOs -pup slos list | jq '.data[] | select(.status.state == "breaching")' - -# Check error budget -pup slos get abc-123 | jq '.data.error_budget_remaining' +## Command Patterns -# List all SLO statuses -pup slos list | jq '.data[] | {name: .name, state: .status.state, budget: .status.error_budget_remaining}' -``` +All commands follow `pup [flags]` or `pup [flags]`. -### Incident Response +### CRUD operations ```bash -# Find active incidents -pup incidents list | jq '.data[] | select(.state == "active")' - -# Find SEV-1 incidents -pup incidents list | jq '.data[] | select(.severity == "SEV-1")' - -# Get incident timeline -pup incidents get abc-123 | jq '.data.timeline' - -# Check customer impact -pup incidents list | jq '.data[] | select(.customer_impacted == true)' +pup list [--filters] # List/search resources +pup get # Get details by ID +pup delete [--yes] # Delete (--yes to skip confirmation) +pup create [--body=file.json] # Create from JSON +pup update [--body=...] # Update resource ``` -## Help Text Structure - -Each command provides structured help with these sections: - -1. **CAPABILITIES**: What the command can do -2. **EXAMPLES**: Real-world usage examples -3. **OUTPUT FIELDS**: Description of output structure -4. **FILTERS**: Available filtering options -5. **AUTHENTICATION**: Auth requirements - -### Example Help Output +### Output formats ```bash -$ pup monitors list --help - -FILTERS: - --name Filter by monitor name (substring match) - --tags Filter by tags (comma-separated) - -EXAMPLES: - pup monitors list - pup monitors list --name="CPU" - pup monitors list --tags="env:production" - -OUTPUT FIELDS: - • id: Monitor ID - • name: Monitor name - • type: Monitor type - • query: Monitor query - • overall_state: Current state +pup monitors list --output=json # JSON (default, recommended for agents) +pup monitors list --output=table # Human-readable table +pup monitors list --output=yaml # YAML ``` -## Error Handling - -```bash -# Commands return non-zero exit codes on error -pup monitors get 99999999 -echo $? # Non-zero +## Query Syntax by Domain -# Capture errors -if ! pup monitors get 99999999 2>&1; then - echo "Monitor not found" -fi +### Logs -# Parse error messages -pup monitors get 99999999 2>&1 | grep "Error" ``` - -## Automation Patterns - -### Confirmation Bypass - -```bash -# Method 1: --yes flag -pup monitors delete 12345678 --yes - -# Method 2: Environment variable -DD_AUTO_APPROVE=true pup monitors delete 12345678 - -# Method 3: Global flag -pup monitors delete 12345678 -y +status:error # Filter by status +service:web-app # Filter by service +@user.id:12345 # Custom attribute (@ prefix) +host:i-* # Wildcard matching +"exact error message" # Exact phrase matching +status:error AND service:web # Boolean AND (implicit or explicit) +status:error OR status:warn # Boolean OR +-status:info # Negation +@http.status_code:[400 TO 599] # Numeric range ``` -### Multi-Site Operations - ```bash -# Operate on different sites -DD_SITE=datadoghq.com pup monitors list -DD_SITE=datadoghq.eu pup monitors list -DD_SITE=us3.datadoghq.com pup monitors list -``` +# Search logs +pup logs search --query="status:error AND service:api" --from=1h --limit=100 -### Batch Operations +# Aggregate logs (counting, statistics) +pup logs aggregate --query="*" --from=1h --compute="count" --group-by="service" -```bash -# Delete multiple monitors -for id in 111 222 333; do - pup monitors delete $id --yes -done - -# Backup all dashboards -pup dashboards list | jq -r '.dashboards[].id' | while read id; do - pup dashboards get "$id" > "dashboard-$id.json" -done +# Storage tiers +pup logs search --query="*" --from=30d --storage="flex" ``` -## LLM-Specific Tips +### Metrics -### 1. Always Check Help First - -Before using any command, check its help text: - -```bash -pup --help -pup --help ``` +:{} by {} -### 2. Use Structured Output - -All commands output JSON. Parse with jq: - -```bash -pup | jq '.' +avg:system.cpu.user{env:prod} by {host} # CPU by host +sum:trace.servlet.request.hits{service:web} # Request count +max:system.mem.used{*} by {host} # Max memory ``` -### 3. Understand Authentication - -Check if authenticated before running commands: - ```bash -pup auth status +pup metrics query --query="avg:system.cpu.user{env:prod} by {host}" --from=1h +pup metrics list --query="system.cpu" ``` -### 4. Filter with jq, Not Grep +### APM / Traces -Use jq for structured filtering: +**CRITICAL: Durations are in NANOSECONDS** +- 1ms = 1,000,000 ns +- 1s = 1,000,000,000 ns -```bash -# Good -pup monitors list | jq '.[] | select(.name | contains("CPU"))' - -# Less good -pup monitors list | grep "CPU" ``` - -### 5. Save Output for Analysis - -Save command output to files for later analysis: +service: # Filter by service +resource_name: # Filter by endpoint +@duration:>5000000000 # Duration > 5s (nanoseconds!) +status:error # Error spans only +env:production # Filter by environment +``` ```bash -pup monitors list > monitors.json -jq '.[] | select(.overall_state == "Alert")' monitors.json +pup traces search --query="service:api AND @duration:>1000000000" --from=1h +pup apm services list ``` -## Resource Types - ### Monitors -- **ID Format**: Numeric (e.g., 12345678) -- **List Command**: `pup monitors list` -- **Get Command**: `pup monitors get ` -- **Filter By**: name, tags - -### Dashboards -- **ID Format**: UUID-like (e.g., abc-def-123) -- **List Command**: `pup dashboards list` -- **Get Command**: `pup dashboards get ` -- **Filter By**: None (use jq) - -### SLOs -- **ID Format**: UUID-like (e.g., abc-123-def) -- **List Command**: `pup slos list` -- **Get Command**: `pup slos get ` -- **Filter By**: None (use jq) - -### Incidents -- **ID Format**: UUID-like (e.g., abc-123-def) -- **List Command**: `pup incidents list` -- **Get Command**: `pup incidents get ` -- **Filter By**: None (use jq) - -## Output Format - -### JSON (Default) ```bash -pup monitors list -# Returns: JSON array or object +pup monitors list --tags="env:production" --name="CPU" # Filter by tags/name +pup monitors search --query="status:Alert" # Full-text search +pup monitors get 12345678 # Get by ID ``` -### Table (Future) +### RUM -```bash -pup monitors list --output=table -# Returns: Formatted table ``` - -### YAML (Future) - -```bash -pup monitors list --output=yaml -# Returns: YAML format +@type:error # Error events +@type:view # Page views +@view.loading_time:>3000 # Slow pages (milliseconds) +@session.type:user # Real users (not synthetic) ``` -## Troubleshooting - -### Authentication Issues +### Incidents ```bash -# Check auth status -pup auth status - -# Re-authenticate -pup auth login - -# Check API keys (legacy) -echo $DD_API_KEY -echo $DD_APP_KEY +pup incidents list --query="status:active" +pup incidents get ``` -### Rate Limiting +## Time Ranges -If you encounter rate limits: -- Reduce request frequency -- Use filters to limit data retrieved -- Cache responses when possible +All `--from` and `--to` flags accept: -### Invalid IDs - -```bash -# Verify resource exists -pup monitors list | jq '.[] | .id' - -# Then get specific resource -pup monitors get -``` - -## Best Practices +| Format | Example | +|--------|---------| +| Relative short | `1h`, `30m`, `7d`, `5s`, `1w` | +| Relative long | `5min`, `2hours`, `3days` | +| With spaces | `"5 minutes"`, `"2 hours"` | +| RFC3339 | `2024-01-01T00:00:00Z` | +| Unix ms | `1704067200000` | +| Keyword | `now` | -1. **Always authenticate first** - ```bash - pup auth login - ``` +## Common Workflows -2. **Use filters to reduce data** - ```bash - pup monitors list --tags="env:production" - ``` +### Error investigation -3. **Save responses for reuse** - ```bash - pup monitors list > monitors.json - ``` - -4. **Check help for each command** - ```bash - pup --help - ``` +```bash +# 1. Get error counts by service +pup logs aggregate --query="status:error" --from=1h --compute="count" --group-by="service" -5. **Use jq for parsing** - ```bash - pup monitors list | jq '.[] | .name' - ``` +# 2. Drill into affected service +pup logs search --query="status:error AND service:" --from=1h --limit=20 -6. **Auto-approve for automation** - ```bash - pup monitors delete --yes - ``` +# 3. Check monitors for that service +pup monitors list --tags="service:" -## Example Workflows +# 4. Check recent events +pup events list --from=4h +``` -### Morning Health Check +### Performance investigation ```bash -# Check authentication -pup auth status - -# Check for alerts -pup monitors list | jq '.[] | select(.overall_state == "Alert")' +# 1. Check service latency +pup metrics query --query="avg:trace.servlet.request.duration{service:} by {resource_name}" --from=1h -# Check active incidents -pup incidents list | jq '.data[] | select(.state == "active")' +# 2. Find slow traces (>5 seconds) +pup traces search --query="service: AND @duration:>5000000000" --from=1h -# Check SLO breaches -pup slos list | jq '.data[] | select(.status.state == "breaching")' +# 3. Check resource utilization +pup metrics query --query="avg:system.cpu.user{service:} by {host}" --from=1h ``` -### Dashboard Backup +### Service health overview ```bash -# List all dashboards -pup dashboards list > dashboard-list.json - -# Backup each dashboard -cat dashboard-list.json | jq -r '.dashboards[].id' | while read id; do - pup dashboards get "$id" > "backups/dashboard-$id.json" - echo "Backed up dashboard $id" -done +pup slos list +pup monitors list --tags="team:" +pup incidents list --query="status:active" ``` -### Monitor Audit - -```bash -# Find untagged monitors -pup monitors list | jq '.[] | select(.tags | length == 0)' - -# Find monitors without notifications -pup monitors list | jq '.[] | select(.message | contains("@") | not)' +## Agent Envelope (Agent Mode Output) + +In agent mode, command output is wrapped in a metadata envelope: + +```json +{ + "status": "success", + "data": [ ... ], + "metadata": { + "count": 42, + "truncated": false, + "command": "monitors list", + "warnings": [] + } +} +``` -# Find monitors in Alert state -pup monitors list | jq '.[] | select(.overall_state == "Alert")' +Error responses in agent mode: + +```json +{ + "status": "error", + "error_code": 401, + "error_message": "Authentication failed", + "operation": "list monitors", + "suggestions": [ + "Run 'pup auth login' to re-authenticate", + "Or set DD_API_KEY and DD_APP_KEY environment variables" + ] +} ``` -## Additional Resources +## Best Practices -- **Main Docs**: README.md -- **OAuth2 Guide**: docs/OAUTH2.md -- **Developer Guide**: CLAUDE.md -- **Implementation**: SUMMARY.md +1. **Always specify `--from`** — most commands default to 1h but be explicit +2. **Start narrow, widen later** — begin with 1h, expand to 24h/7d only if needed +3. **Filter at the API level** — use `--tags`, `--query`, `--name` instead of fetching everything and parsing locally +4. **Use `aggregate` for counts** — don't fetch all logs and count them yourself +5. **APM durations are in nanoseconds** — 1s = 1,000,000,000 +6. **Use `--yes` for automation** — or rely on agent mode auto-approval +7. **Check `pup agent schema`** when unsure about a command's flags +8. **Chain queries** — aggregate first to find patterns, then search for specifics + +## Anti-Patterns + +1. **Don't omit `--from`** on time-series queries — you'll get unexpected ranges or errors +2. **Don't use `--limit=1000` as a first step** — start small and refine +3. **Don't list all monitors without filters** in large orgs (>10k monitors) +4. **Don't assume durations are in seconds** — APM uses nanoseconds +5. **Don't fetch raw logs to count them** — use `pup logs aggregate --compute=count` +6. **Don't retry 401/403 errors** — re-authenticate or check permissions instead +7. **Don't use `--from=30d`** unless you specifically need a month of data + +## Error Reference + +| Status | Meaning | Suggested Action | +|--------|---------|------------------| +| 401 | Authentication failed | `pup auth login` or check DD_API_KEY/DD_APP_KEY | +| 403 | Insufficient permissions | Verify API/App key scopes | +| 404 | Resource not found | Check the resource ID | +| 429 | Rate limited | Wait and retry with backoff | +| 5xx | Server error | Retry after a short delay; check https://status.datadoghq.com/ | + +## Architecture Reference + +### Agent detection + +- Implementation: `pkg/useragent/useragent.go` +- Table-driven detector registry; first match wins +- `IsAgentMode()` checks `FORCE_AGENT_MODE` first, then agent env vars +- `DetectAgentInfo()` returns `AgentInfo{Name, Detected}` + +### Schema generation + +- Implementation: `pkg/agenthelp/agenthelp.go` +- Walks the Cobra command tree via `rootCmd.Commands()` recursion +- Schema stays in sync automatically as commands are added +- Subtree schemas filter to a single domain + relevant query syntax + +### Output envelope + +- Implementation: `pkg/formatter/envelope.go` +- `WrapForAgent(data, metadata)` wraps responses in `AgentEnvelope` +- `FormatAgentError(operation, statusCode, message, apiBody)` formats structured errors +- Only activated when `cfg.AgentMode == true` + +### Steering content + +- Query syntax, time formats, workflows, best practices, anti-patterns: `pkg/agenthelp/steering.go` +- Embedded guide document: `pkg/agenthelp/guide.md` (loaded via `go:embed`) +- Guide sections retrievable by domain: `agenthelp.GetGuideSection("logs")` + +### Help interception + +- `cmd/root.go:ExecuteWithArgs()` intercepts `--help`/`-h` before Cobra processes args +- When `useragent.IsAgentMode()` is true, calls `printAgentSchema()` instead of Cobra help +- `firstNonFlagArg()` extracts the domain name for subtree schemas + +## File Map + +| File | Purpose | +|------|---------| +| `pkg/useragent/useragent.go` | Agent detection (11 agents + FORCE_AGENT_MODE) | +| `pkg/agenthelp/agenthelp.go` | Schema generation (Cobra tree walker) | +| `pkg/agenthelp/steering.go` | Query syntax, workflows, best practices | +| `pkg/agenthelp/guide.go` | Embedded guide document (`go:embed`) | +| `pkg/agenthelp/guide.md` | Runtime steering guide content | +| `pkg/formatter/envelope.go` | Agent envelope and structured errors | +| `pkg/config/config.go` | `AgentMode` field on Config | +| `cmd/root.go` | `--agent` flag, help interception, `formatAndPrint()`, `formatAPIError()` | +| `cmd/agent.go` | `pup agent schema`, `pup agent guide` commands | diff --git a/pkg/agenthelp/agenthelp.go b/pkg/agenthelp/agenthelp.go new file mode 100644 index 00000000..ed3aeb93 --- /dev/null +++ b/pkg/agenthelp/agenthelp.go @@ -0,0 +1,231 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2024-present Datadog, Inc. + +package agenthelp + +import ( + "github.com/DataDog/pup/internal/version" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// Schema is the top-level structure returned by --help in agent mode or 'pup agent schema'. +type Schema struct { + Version string `json:"version"` + Description string `json:"description"` + Auth AuthInfo `json:"auth"` + GlobalFlags []FlagInfo `json:"global_flags"` + Commands []CommandInfo `json:"commands"` + QuerySyntax map[string]string `json:"query_syntax"` + TimeFormats TimeFormats `json:"time_formats"` + Workflows []Workflow `json:"workflows"` + BestPractices []string `json:"best_practices"` + AntiPatterns []string `json:"anti_patterns"` +} + +// AuthInfo describes authentication options. +type AuthInfo struct { + OAuth string `json:"oauth"` + APIKeys string `json:"api_keys"` +} + +// FlagInfo describes a command flag. +type FlagInfo struct { + Name string `json:"name"` + Type string `json:"type"` + Default string `json:"default,omitempty"` + Description string `json:"description"` +} + +// CommandInfo describes a command or subcommand. +type CommandInfo struct { + Name string `json:"name"` + FullPath string `json:"full_path"` + Description string `json:"description"` + Flags []FlagInfo `json:"flags,omitempty"` + Examples []string `json:"examples,omitempty"` + ReadOnly bool `json:"read_only"` + Subcommands []CommandInfo `json:"subcommands,omitempty"` +} + +// TimeFormats describes supported time format options. +type TimeFormats struct { + Relative []string `json:"relative"` + Absolute []string `json:"absolute"` + Examples []string `json:"examples"` +} + +// Workflow describes a multi-step agent workflow. +type Workflow struct { + Name string `json:"name"` + Steps []string `json:"steps"` +} + +// GenerateSchema builds the complete schema from a cobra command tree. +func GenerateSchema(root *cobra.Command) Schema { + return Schema{ + Version: version.Version, + Description: "Pup - Datadog API CLI. Provides OAuth2 + API key authentication for querying metrics, logs, monitors, traces, and 30+ other Datadog API domains.", + Auth: AuthInfo{ + OAuth: "pup auth login", + APIKeys: "Set DD_API_KEY + DD_APP_KEY + DD_SITE environment variables", + }, + GlobalFlags: extractGlobalFlags(root), + Commands: extractCommands(root, ""), + QuerySyntax: GetQuerySyntax(), + TimeFormats: GetTimeFormats(), + Workflows: GetWorkflows(), + BestPractices: GetBestPractices(), + AntiPatterns: GetAntiPatterns(), + } +} + +// GenerateSubtreeSchema builds a schema for a specific subtree of commands. +func GenerateSubtreeSchema(root *cobra.Command, subtreeName string) *Schema { + for _, cmd := range root.Commands() { + if cmd.Name() == subtreeName { + schema := Schema{ + Version: version.Version, + Description: cmd.Short, + Auth: AuthInfo{ + OAuth: "pup auth login", + APIKeys: "Set DD_API_KEY + DD_APP_KEY + DD_SITE environment variables", + }, + GlobalFlags: extractGlobalFlags(root), + Commands: []CommandInfo{buildCommandInfo(cmd, subtreeName)}, + QuerySyntax: filterQuerySyntax(subtreeName), + TimeFormats: GetTimeFormats(), + BestPractices: GetBestPractices(), + AntiPatterns: GetAntiPatterns(), + } + return &schema + } + } + return nil +} + +// CompactSchema is a minimal schema with just command names and flags. +type CompactSchema struct { + Version string `json:"version"` + Commands []CompactCommand `json:"commands"` +} + +// CompactCommand is a minimal command representation. +type CompactCommand struct { + Name string `json:"name"` + Flags []string `json:"flags,omitempty"` + Subcommands []CompactCommand `json:"subcommands,omitempty"` +} + +// GenerateCompactSchema builds a token-efficient schema. +func GenerateCompactSchema(root *cobra.Command) CompactSchema { + return CompactSchema{ + Version: version.Version, + Commands: extractCompactCommands(root), + } +} + +func extractGlobalFlags(cmd *cobra.Command) []FlagInfo { + var flags []FlagInfo + cmd.PersistentFlags().VisitAll(func(f *pflag.Flag) { + flags = append(flags, FlagInfo{ + Name: "--" + f.Name, + Type: f.Value.Type(), + Default: f.DefValue, + Description: f.Usage, + }) + }) + return flags +} + +func extractCommands(parent *cobra.Command, prefix string) []CommandInfo { + var commands []CommandInfo + for _, cmd := range parent.Commands() { + if cmd.Hidden || cmd.Name() == "help" || cmd.Name() == "completion" { + continue + } + fullPath := cmd.Name() + if prefix != "" { + fullPath = prefix + " " + cmd.Name() + } + commands = append(commands, buildCommandInfo(cmd, fullPath)) + } + return commands +} + +func buildCommandInfo(cmd *cobra.Command, fullPath string) CommandInfo { + info := CommandInfo{ + Name: cmd.Name(), + FullPath: fullPath, + Description: cmd.Short, + ReadOnly: isReadOnlyCommand(cmd.Name()), + Flags: extractLocalFlags(cmd), + Examples: extractExamples(cmd), + } + + for _, sub := range cmd.Commands() { + if sub.Hidden || sub.Name() == "help" || sub.Name() == "completion" { + continue + } + subPath := fullPath + " " + sub.Name() + info.Subcommands = append(info.Subcommands, buildCommandInfo(sub, subPath)) + } + + return info +} + +func extractLocalFlags(cmd *cobra.Command) []FlagInfo { + var flags []FlagInfo + cmd.LocalFlags().VisitAll(func(f *pflag.Flag) { + flags = append(flags, FlagInfo{ + Name: "--" + f.Name, + Type: f.Value.Type(), + Default: f.DefValue, + Description: f.Usage, + }) + }) + return flags +} + +func extractExamples(cmd *cobra.Command) []string { + if cmd.Example == "" { + return nil + } + return []string{cmd.Example} +} + +func extractCompactCommands(parent *cobra.Command) []CompactCommand { + var commands []CompactCommand + for _, cmd := range parent.Commands() { + if cmd.Hidden || cmd.Name() == "help" || cmd.Name() == "completion" { + continue + } + cc := CompactCommand{Name: cmd.Name()} + cmd.LocalFlags().VisitAll(func(f *pflag.Flag) { + cc.Flags = append(cc.Flags, "--"+f.Name) + }) + cc.Subcommands = extractCompactCommands(cmd) + commands = append(commands, cc) + } + return commands +} + +// isReadOnlyCommand returns true for commands that only read data. +func isReadOnlyCommand(name string) bool { + writeCommands := map[string]bool{ + "delete": true, "create": true, "update": true, "set": true, + "import": true, "login": true, "logout": true, + } + return !writeCommands[name] +} + +// filterQuerySyntax returns query syntax relevant to a specific domain. +func filterQuerySyntax(domain string) map[string]string { + all := GetQuerySyntax() + if syntax, ok := all[domain]; ok { + return map[string]string{domain: syntax} + } + return all +} diff --git a/pkg/agenthelp/agenthelp_test.go b/pkg/agenthelp/agenthelp_test.go new file mode 100644 index 00000000..bccd3b36 --- /dev/null +++ b/pkg/agenthelp/agenthelp_test.go @@ -0,0 +1,204 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2024-present Datadog, Inc. + +package agenthelp + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func newTestRoot() *cobra.Command { + root := &cobra.Command{Use: "pup", Short: "test CLI"} + root.PersistentFlags().String("output", "json", "Output format") + root.PersistentFlags().Bool("agent", false, "Agent mode") + + monitors := &cobra.Command{Use: "monitors", Short: "Manage monitors"} + monitorsList := &cobra.Command{Use: "list", Short: "List monitors"} + monitorsList.Flags().String("tags", "", "Filter by tags") + monitorsList.Flags().Int("limit", 200, "Maximum results") + monitorsGet := &cobra.Command{Use: "get", Short: "Get monitor details"} + monitorsDelete := &cobra.Command{Use: "delete", Short: "Delete a monitor"} + monitors.AddCommand(monitorsList, monitorsGet, monitorsDelete) + + logs := &cobra.Command{Use: "logs", Short: "Search and analyze logs"} + logsSearch := &cobra.Command{Use: "search", Short: "Search logs"} + logsSearch.Flags().String("query", "", "Search query") + logsSearch.Flags().String("from", "1h", "Start time") + logs.AddCommand(logsSearch) + + root.AddCommand(monitors, logs) + return root +} + +func TestGenerateSchema(t *testing.T) { + root := newTestRoot() + schema := GenerateSchema(root) + + if schema.Version == "" { + t.Error("Schema.Version should not be empty") + } + if schema.Description == "" { + t.Error("Schema.Description should not be empty") + } + if schema.Auth.OAuth == "" { + t.Error("Schema.Auth.OAuth should not be empty") + } + if len(schema.GlobalFlags) == 0 { + t.Error("Schema.GlobalFlags should not be empty") + } + if len(schema.Commands) == 0 { + t.Error("Schema.Commands should not be empty") + } + if len(schema.QuerySyntax) == 0 { + t.Error("Schema.QuerySyntax should not be empty") + } + if len(schema.TimeFormats.Relative) == 0 { + t.Error("Schema.TimeFormats.Relative should not be empty") + } + if len(schema.Workflows) == 0 { + t.Error("Schema.Workflows should not be empty") + } + if len(schema.BestPractices) == 0 { + t.Error("Schema.BestPractices should not be empty") + } + if len(schema.AntiPatterns) == 0 { + t.Error("Schema.AntiPatterns should not be empty") + } +} + +func TestGenerateSchema_CommandsIncludeSubcommands(t *testing.T) { + root := newTestRoot() + schema := GenerateSchema(root) + + var found bool + for _, cmd := range schema.Commands { + if cmd.Name == "monitors" { + found = true + if len(cmd.Subcommands) != 3 { + t.Errorf("monitors should have 3 subcommands, got %d", len(cmd.Subcommands)) + } + for _, sub := range cmd.Subcommands { + if sub.Name == "list" { + if len(sub.Flags) == 0 { + t.Error("monitors list should have flags") + } + } + } + } + } + if !found { + t.Error("monitors command not found in schema") + } +} + +func TestGenerateSchema_GlobalFlags(t *testing.T) { + root := newTestRoot() + schema := GenerateSchema(root) + + flagNames := make(map[string]bool) + for _, f := range schema.GlobalFlags { + flagNames[f.Name] = true + } + + if !flagNames["--output"] { + t.Error("Global flags should include --output") + } + if !flagNames["--agent"] { + t.Error("Global flags should include --agent") + } +} + +func TestGenerateSubtreeSchema(t *testing.T) { + root := newTestRoot() + + schema := GenerateSubtreeSchema(root, "monitors") + if schema == nil { + t.Fatal("GenerateSubtreeSchema should not return nil for 'monitors'") + } + if len(schema.Commands) != 1 { + t.Errorf("Subtree schema should have 1 command, got %d", len(schema.Commands)) + } + if schema.Commands[0].Name != "monitors" { + t.Errorf("Subtree command should be 'monitors', got %q", schema.Commands[0].Name) + } +} + +func TestGenerateSubtreeSchema_NotFound(t *testing.T) { + root := newTestRoot() + + schema := GenerateSubtreeSchema(root, "nonexistent") + if schema != nil { + t.Error("GenerateSubtreeSchema should return nil for nonexistent command") + } +} + +func TestGenerateCompactSchema(t *testing.T) { + root := newTestRoot() + compact := GenerateCompactSchema(root) + + if compact.Version == "" { + t.Error("CompactSchema.Version should not be empty") + } + if len(compact.Commands) == 0 { + t.Error("CompactSchema.Commands should not be empty") + } + + for _, cmd := range compact.Commands { + if cmd.Name == "monitors" { + if len(cmd.Subcommands) != 3 { + t.Errorf("monitors should have 3 subcommands, got %d", len(cmd.Subcommands)) + } + for _, sub := range cmd.Subcommands { + if sub.Name == "list" && len(sub.Flags) == 0 { + t.Error("monitors list should have flags in compact schema") + } + } + } + } +} + +func TestIsReadOnlyCommand(t *testing.T) { + tests := []struct { + name string + want bool + }{ + {"list", true}, + {"get", true}, + {"search", true}, + {"query", true}, + {"delete", false}, + {"create", false}, + {"update", false}, + {"set", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isReadOnlyCommand(tt.name) + if got != tt.want { + t.Errorf("isReadOnlyCommand(%q) = %v, want %v", tt.name, got, tt.want) + } + }) + } +} + +func TestFilterQuerySyntax(t *testing.T) { + // Known domain + result := filterQuerySyntax("logs") + if _, ok := result["logs"]; !ok { + t.Error("filterQuerySyntax('logs') should contain 'logs' key") + } + if len(result) != 1 { + t.Errorf("filterQuerySyntax('logs') should have 1 entry, got %d", len(result)) + } + + // Unknown domain returns all + result = filterQuerySyntax("unknown") + if len(result) < 2 { + t.Error("filterQuerySyntax('unknown') should return all entries") + } +} diff --git a/pkg/agenthelp/guide.go b/pkg/agenthelp/guide.go new file mode 100644 index 00000000..72b47235 --- /dev/null +++ b/pkg/agenthelp/guide.go @@ -0,0 +1,68 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2024-present Datadog, Inc. + +package agenthelp + +import ( + _ "embed" + "strings" +) + +//go:embed guide.md +var guideContent string + +// GetGuide returns the full steering guide. +func GetGuide() string { + return guideContent +} + +// GetGuideSection returns a specific domain section from the guide. +// Returns the full guide if the domain is not found. +func GetGuideSection(domain string) string { + // Try multiple casing strategies to find the section heading + candidates := []string{ + "## " + strings.ToUpper(domain[:1]) + domain[1:], // "## Logs" + "## " + strings.ToUpper(domain), // "## APM" + "## " + domain, // "## logs" (exact) + } + + var idx int = -1 + var heading string + for _, candidate := range candidates { + idx = strings.Index(guideContent, candidate) + if idx != -1 { + heading = candidate + break + } + } + + // Fall back to case-insensitive line scan + if idx == -1 { + lowerDomain := strings.ToLower(domain) + for i, line := range strings.Split(guideContent, "\n") { + if strings.HasPrefix(line, "## ") && strings.Contains(strings.ToLower(line), lowerDomain) { + // Reconstruct idx from line number + idx = 0 + for _, l := range strings.Split(guideContent, "\n")[:i] { + idx += len(l) + 1 + } + heading = line + break + } + } + } + + if idx == -1 { + return guideContent + } + + // Find the next ## heading after this one + rest := guideContent[idx+len(heading):] + nextSection := strings.Index(rest, "\n## ") + if nextSection == -1 { + return guideContent[idx:] + } + return guideContent[idx : idx+len(heading)+nextSection] +} diff --git a/pkg/agenthelp/guide.md b/pkg/agenthelp/guide.md new file mode 100644 index 00000000..af31eaa9 --- /dev/null +++ b/pkg/agenthelp/guide.md @@ -0,0 +1,318 @@ +# Pup Agent Guide + +Pup is a CLI for the Datadog API. This guide helps AI coding agents use pup effectively. + +## Quick Start + +```bash +# Authenticate (one-time setup) +pup auth login + +# Or use API keys +export DD_API_KEY="your-key" DD_APP_KEY="your-key" DD_SITE="datadoghq.com" + +# Get the full command schema (recommended first step for agents) +pup --help + +# Get schema for a specific domain +pup logs --help +``` + +## Authentication + +- **OAuth2 (recommended):** `pup auth login` — opens browser for secure login +- **API keys:** Set `DD_API_KEY`, `DD_APP_KEY`, and `DD_SITE` environment variables +- OAuth2 tokens are stored in the OS keychain and refresh automatically +- Some endpoints (logs search) require API keys even with OAuth2 + +## Logs + +### Query Syntax +``` +status:error # Filter by status +service:web-app # Filter by service +@user.id:12345 # Filter by custom attribute +host:i-* # Wildcard matching +"exact error message" # Exact phrase matching +status:error AND service:web # Boolean AND +status:error OR status:warn # Boolean OR +NOT status:info # Negation +-status:info # Shorthand negation +``` + +### Commands +```bash +# Search logs (v1 API, follows pagination) +pup logs search --query="status:error" --from=1h --limit=100 + +# Query logs (v2 API) +pup logs query --query="service:api AND status:error" --from=4h + +# Aggregate logs (counts, distributions) +pup logs aggregate --query="*" --from=1h --compute="count" --group-by="service" +pup logs aggregate --query="service:api" --from=1h --compute="avg(@duration)" --group-by="@http.status_code" + +# List logs (v2 API, simple) +pup logs list --from=1h --limit=20 + +# Storage tiers: indexes (default), online-archives, flex +pup logs search --query="*" --from=30d --storage="flex" +``` + +### Tips +- Always specify `--from` for a time range +- Use `aggregate` for counting/statistics, not `search` + local processing +- Default limit is 50; increase with `--limit` up to 1000 +- Supported compute functions: count, avg, sum, min, max, cardinality, percentile + +## Metrics + +### Query Syntax +``` +:{} by {} + +# Examples: +avg:system.cpu.user{*} # All hosts, average CPU +avg:system.cpu.user{env:prod} by {host} # By host, production only +sum:trace.servlet.request.hits{service:web} # Request count +max:system.mem.used{*} by {host} # Max memory by host +``` + +### Commands +```bash +# Query metrics (timeseries) +pup metrics query --query="avg:system.cpu.user{env:prod} by {host}" --from=1h + +# List metric names +pup metrics list --query="system.cpu" + +# Get metric metadata +pup metrics metadata get "system.cpu.user" + +# Submit metrics +pup metrics submit --metric="custom.metric" --value=42 --tags="env:test" +``` + +### Tips +- Aggregations: avg, sum, min, max, count +- Always include `{...}` filter even if empty: `{*}` means all +- Time range defaults to 1h if not specified + +## Monitors + +### Commands +```bash +# List monitors with filtering +pup monitors list --tags="env:production" --limit=500 +pup monitors list --name="CPU" --tags="team:backend" + +# Search monitors (full-text) +pup monitors search --query="database" + +# Get monitor details +pup monitors get 12345678 + +# Delete monitor (prompts for confirmation; use --yes to skip) +pup monitors delete 12345678 --yes +``` + +### Tips +- Use `--tags` for efficient filtering at the API level +- Default limit is 200; max is 1000 +- Search supports full-text across monitor names and queries +- Monitor states: OK, Alert, Warn, No Data + +## APM / Traces + +### Query Syntax +``` +service: # Filter by service +resource_name: # Filter by resource/endpoint +@duration:>5000000000 # Duration > 5 seconds (NANOSECONDS!) +status:error # Error traces only +operation_name:rack.request # Filter by operation +env:production # Filter by environment +``` + +**CRITICAL: APM durations are in NANOSECONDS** +- 1 millisecond = 1,000,000 ns +- 1 second = 1,000,000,000 ns +- 5 seconds = 5,000,000,000 ns + +### Commands +```bash +# List APM services +pup apm services list + +# Search traces +pup traces search --query="service:web-api AND @duration:>1000000000" --from=1h + +# List spans +pup traces list --query="service:web-api" --from=1h --limit=50 +``` + +## RUM (Real User Monitoring) + +### Query Syntax +``` +@type:error # Error events +@session.type:user # User sessions (not synthetic) +@view.url_path:/checkout # Specific page +@action.type:click # Click actions +service: # Filter by application +``` + +### Commands +```bash +# List RUM applications +pup rum apps list + +# Search RUM events +pup rum events search --query="@type:error" --from=1h + +# Aggregate RUM data +pup rum aggregate --query="@type:view" --from=4h --compute="avg(@view.loading_time)" +``` + +## Incidents + +```bash +# List active incidents +pup incidents list --query="status:active" + +# Get incident details +pup incidents get + +# List incident timeline +pup incidents timeline +``` + +## SLOs + +```bash +# List all SLOs +pup slos list + +# Get SLO details +pup slos get + +# Get SLO history +pup slos history --from=7d +``` + +## Security + +```bash +# List security rules +pup security rules list + +# Search security signals +pup security signals list --query="status:critical" --from=1d + +# List security filters +pup security filters list +``` + +## Dashboards + +```bash +# List dashboards +pup dashboards list + +# Get dashboard details (includes all widgets and queries) +pup dashboards get +``` + +## Events + +```bash +# List events +pup events list --from=1h + +# Search events by source +pup events search --query="sources:pagerduty" --from=1d +``` + +## Common Patterns + +### Error Investigation +```bash +# 1. Check for errors in logs +pup logs aggregate --query="status:error" --from=1h --compute="count" --group-by="service" + +# 2. Drill into the affected service +pup logs search --query="status:error AND service:" --from=1h --limit=20 + +# 3. Check monitors for that service +pup monitors list --tags="service:" + +# 4. Check recent deployments/events +pup events list --from=4h +``` + +### Performance Investigation +```bash +# 1. Check service latency +pup metrics query --query="avg:trace.servlet.request.duration{service:} by {resource_name}" --from=1h + +# 2. Find slow traces (>5 seconds) +pup traces search --query="service: AND @duration:>5000000000" --from=1h + +# 3. Check resource utilization +pup metrics query --query="avg:system.cpu.user{service:} by {host}" --from=1h +``` + +## Time Ranges + +All time-related flags (`--from`, `--to`) accept: + +| Format | Example | Description | +|--------|---------|-------------| +| Relative short | `1h`, `30m`, `7d`, `5s` | Ago from now | +| Relative long | `5min`, `2hours`, `3days` | Ago from now | +| With spaces | `"5 minutes"`, `"2 hours"` | Ago from now | +| RFC3339 | `2024-01-01T00:00:00Z` | Absolute time | +| Unix ms | `1704067200000` | Milliseconds since epoch | +| Keyword | `now` | Current time | + +## Output Formats + +```bash +# JSON (default, recommended for agents) +pup monitors list --output=json + +# Table (human-readable) +pup monitors list --output=table + +# YAML +pup monitors list --output=yaml +``` + +## Error Handling + +| Status | Meaning | Action | +|--------|---------|--------| +| 401 | Authentication failed | Run `pup auth login` or check API keys | +| 403 | Insufficient permissions | Verify API/App key permissions | +| 404 | Resource not found | Check the ID or resource name | +| 429 | Rate limited | Wait and retry with backoff | +| 5xx | Server error | Retry after a short delay | + +## Agent Mode + +Agent mode is auto-detected when running inside AI coding assistants (Claude Code, Cursor, Codex, etc.) or can be enabled explicitly: + +```bash +# Explicit flag +pup --agent monitors list + +# Environment variable +FORCE_AGENT_MODE=1 pup monitors list + +# Auto-detected from: CLAUDECODE, CLAUDE_CODE, CURSOR_AGENT, CODEX, AIDER, etc. +``` + +In agent mode: +- Confirmation prompts are auto-approved (no stdin hangs) +- Output is JSON by default +- Structured error responses with suggestions diff --git a/pkg/agenthelp/guide_test.go b/pkg/agenthelp/guide_test.go new file mode 100644 index 00000000..2f2dde46 --- /dev/null +++ b/pkg/agenthelp/guide_test.go @@ -0,0 +1,60 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2024-present Datadog, Inc. + +package agenthelp + +import ( + "strings" + "testing" +) + +func TestGetGuide(t *testing.T) { + guide := GetGuide() + if guide == "" { + t.Fatal("GetGuide() should not be empty") + } + if !strings.Contains(guide, "# Pup Agent Guide") { + t.Error("Guide should contain the title") + } + if !strings.Contains(guide, "## Logs") { + t.Error("Guide should contain Logs section") + } + if !strings.Contains(guide, "## Metrics") { + t.Error("Guide should contain Metrics section") + } + if !strings.Contains(guide, "## Monitors") { + t.Error("Guide should contain Monitors section") + } +} + +func TestGetGuideSection(t *testing.T) { + tests := []struct { + domain string + shouldMatch string + }{ + {"logs", "## Logs"}, + {"metrics", "## Metrics"}, + {"monitors", "## Monitors"}, + {"apm", "## APM"}, + {"Logs", "## Logs"}, + } + + for _, tt := range tests { + t.Run(tt.domain, func(t *testing.T) { + section := GetGuideSection(tt.domain) + if !strings.Contains(section, tt.shouldMatch) { + t.Errorf("GetGuideSection(%q) should contain %q", tt.domain, tt.shouldMatch) + } + }) + } +} + +func TestGetGuideSection_NotFound(t *testing.T) { + section := GetGuideSection("nonexistent_domain_xyz") + guide := GetGuide() + if section != guide { + t.Error("GetGuideSection for unknown domain should return the full guide") + } +} diff --git a/pkg/agenthelp/steering.go b/pkg/agenthelp/steering.go new file mode 100644 index 00000000..ea320b8b --- /dev/null +++ b/pkg/agenthelp/steering.go @@ -0,0 +1,112 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2024-present Datadog, Inc. + +package agenthelp + +// GetQuerySyntax returns query syntax documentation for each domain. +func GetQuerySyntax() map[string]string { + return map[string]string{ + "logs": `status:error, service:web-app, @attr:val, host:i-*, "exact phrase", AND/OR/NOT operators, -status:info (negation), wildcards with *`, + "metrics": `:{} by {}. Example: avg:system.cpu.user{env:prod} by {host}. Aggregations: avg, sum, min, max, count`, + "monitors": `Use --name for substring search, --tags for tag filtering (comma-separated). Search via --query for full-text search`, + "apm": `service: resource_name: @duration:>5000000000 (nanoseconds!) status:error operation_name:. Duration is always in nanoseconds`, + "rum": `@type:error @session.type:user @view.url_path:/checkout @action.type:click service:`, + "security": `@workflow.rule.type:log_detection source:cloudtrail @network.client.ip:10.0.0.0/8 status:critical`, + "events": `sources:nagios,pagerduty status:error priority:normal tags:env:prod`, + "traces": `service: resource_name: @duration:>5s (shorthand) env:production`, + } +} + +// GetTimeFormats returns documentation for supported time formats. +func GetTimeFormats() TimeFormats { + return TimeFormats{ + Relative: []string{"5s", "30m", "1h", "4h", "1d", "7d", "1w", "30d", "5min", "2hours", "3days"}, + Absolute: []string{"Unix timestamp in milliseconds", "RFC3339 (2024-01-01T00:00:00Z)"}, + Examples: []string{ + `--from=1h (1 hour ago)`, + `--from=30m --to=now`, + `--from=7d --to=1d (7 days ago to 1 day ago)`, + `--from=2024-01-01T00:00:00Z --to=2024-01-02T00:00:00Z`, + `--from="5 minutes"`, + }, + } +} + +// GetWorkflows returns common multi-step workflows for agents. +func GetWorkflows() []Workflow { + return []Workflow{ + { + Name: "Investigate errors", + Steps: []string{ + `pup logs search --query="status:error" --from=1h --limit=20`, + `pup logs aggregate --query="status:error" --from=1h --compute="count" --group-by="service"`, + `pup monitors list --tags="env:production" --limit=50`, + }, + }, + { + Name: "Performance investigation", + Steps: []string{ + `pup metrics query --query="avg:trace.servlet.request.duration{env:prod} by {service}" --from=1h`, + `pup logs search --query="@duration:>5000000000" --from=1h --limit=20`, + `pup apm services list`, + }, + }, + { + Name: "Monitor status check", + Steps: []string{ + `pup monitors list --tags="env:production" --limit=500`, + `pup monitors search --query="status:Alert"`, + `pup monitors get `, + }, + }, + { + Name: "Security audit", + Steps: []string{ + `pup audit-logs search --query="*" --from=1d --limit=100`, + `pup security rules list`, + `pup security signals list --query="status:critical" --from=1d`, + }, + }, + { + Name: "Service health overview", + Steps: []string{ + `pup slos list`, + `pup monitors list --tags="team:"`, + `pup incidents list --query="status:active"`, + }, + }, + } +} + +// GetBestPractices returns agent-specific best practices. +func GetBestPractices() []string { + return []string{ + "Always specify --from to set a time range; most commands default to 1h but be explicit", + "Start with narrow time ranges (1h) then widen if needed; large ranges are slow and expensive", + "Filter by service first when investigating issues: --query='service:'", + "Use --limit to control result size; default varies by command (50-200)", + "For monitors, use --tags to filter rather than listing all and parsing locally", + "APM durations are in NANOSECONDS: 1 second = 1000000000, 5ms = 5000000", + "Use 'pup logs aggregate' for counts and distributions instead of fetching all logs and counting locally", + "Prefer JSON output (default) for structured parsing; use --output=table only for human display", + "Chain narrow queries: first aggregate to find patterns, then search for specific examples", + "Use 'pup monitors search' for full-text search, 'pup monitors list' for tag/name filtering", + } +} + +// GetAntiPatterns returns common mistakes agents should avoid. +func GetAntiPatterns() []string { + return []string{ + "Don't omit --from on time-series queries; you'll get unexpected time ranges or errors", + "Don't use --limit=1000 as a first step; start with small limits and refine queries", + "Don't list all monitors/logs without filters in large organizations (>10k monitors)", + "Don't assume APM durations are in seconds or milliseconds; they are in NANOSECONDS", + "Don't fetch raw logs to count them; use 'pup logs aggregate --compute=count' instead", + "Don't use --from=30d unless you specifically need a month of data; it's slow", + "Don't retry failed requests without checking the error; 401 means re-authenticate, 403 means missing permissions", + "Don't use 'pup metrics query' without specifying an aggregation (avg, sum, max, min, count)", + "Don't pipe large JSON responses through multiple jq transforms; use query filters at the API level", + } +} diff --git a/pkg/agenthelp/steering_test.go b/pkg/agenthelp/steering_test.go new file mode 100644 index 00000000..ea869680 --- /dev/null +++ b/pkg/agenthelp/steering_test.go @@ -0,0 +1,74 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2024-present Datadog, Inc. + +package agenthelp + +import "testing" + +func TestGetQuerySyntax(t *testing.T) { + syntax := GetQuerySyntax() + if len(syntax) == 0 { + t.Fatal("GetQuerySyntax() should not be empty") + } + + expectedDomains := []string{"logs", "metrics", "monitors", "apm", "rum", "security", "events", "traces"} + for _, domain := range expectedDomains { + if _, ok := syntax[domain]; !ok { + t.Errorf("GetQuerySyntax() missing domain %q", domain) + } + } +} + +func TestGetTimeFormats(t *testing.T) { + tf := GetTimeFormats() + if len(tf.Relative) == 0 { + t.Error("TimeFormats.Relative should not be empty") + } + if len(tf.Absolute) == 0 { + t.Error("TimeFormats.Absolute should not be empty") + } + if len(tf.Examples) == 0 { + t.Error("TimeFormats.Examples should not be empty") + } +} + +func TestGetWorkflows(t *testing.T) { + workflows := GetWorkflows() + if len(workflows) == 0 { + t.Fatal("GetWorkflows() should not be empty") + } + for _, w := range workflows { + if w.Name == "" { + t.Error("Workflow name should not be empty") + } + if len(w.Steps) == 0 { + t.Errorf("Workflow %q should have steps", w.Name) + } + } +} + +func TestGetBestPractices(t *testing.T) { + bp := GetBestPractices() + if len(bp) == 0 { + t.Fatal("GetBestPractices() should not be empty") + } + for _, p := range bp { + if p == "" { + t.Error("Best practice should not be empty string") + } + } +} + +func TestGetAntiPatterns(t *testing.T) { + ap := GetAntiPatterns() + if len(ap) == 0 { + t.Fatal("GetAntiPatterns() should not be empty") + } + for _, p := range ap { + if p == "" { + t.Error("Anti-pattern should not be empty string") + } + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index ba6c212a..404b2ccd 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -16,6 +16,7 @@ type Config struct { AppKey string Site string AutoApprove bool + AgentMode bool } // Load loads configuration from environment variables diff --git a/pkg/formatter/envelope.go b/pkg/formatter/envelope.go new file mode 100644 index 00000000..8ddc657e --- /dev/null +++ b/pkg/formatter/envelope.go @@ -0,0 +1,82 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2024-present Datadog, Inc. + +package formatter + +import "encoding/json" + +// AgentEnvelope wraps API responses with metadata for agent consumption. +type AgentEnvelope struct { + Status string `json:"status"` + Data interface{} `json:"data"` + Metadata *Metadata `json:"metadata,omitempty"` +} + +// Metadata contains structured information about the response. +type Metadata struct { + Count *int `json:"count,omitempty"` + Truncated bool `json:"truncated,omitempty"` + NextAction string `json:"next_action,omitempty"` + Command string `json:"command"` + Warnings []string `json:"warnings,omitempty"` +} + +// AgentError is a structured error response for agent mode. +type AgentError struct { + Status string `json:"status"` + ErrorCode int `json:"error_code,omitempty"` + Message string `json:"error_message"` + Operation string `json:"operation"` + Suggestions []string `json:"suggestions,omitempty"` + APIResponse string `json:"api_response,omitempty"` +} + +// WrapForAgent wraps data in an AgentEnvelope and formats as JSON. +func WrapForAgent(data interface{}, meta *Metadata) (string, error) { + env := AgentEnvelope{ + Status: "success", + Data: data, + Metadata: meta, + } + bytes, err := json.MarshalIndent(env, "", " ") + if err != nil { + return "", err + } + return string(bytes), nil +} + +// FormatAgentError formats a structured error for agent mode. +func FormatAgentError(operation string, statusCode int, errMsg string, apiResponse string) (string, error) { + ae := AgentError{ + Status: "error", + ErrorCode: statusCode, + Message: errMsg, + Operation: operation, + Suggestions: suggestionsForStatus(statusCode), + APIResponse: apiResponse, + } + bytes, err := json.MarshalIndent(ae, "", " ") + if err != nil { + return "", err + } + return string(bytes), nil +} + +func suggestionsForStatus(code int) []string { + switch { + case code == 401: + return []string{"Run 'pup auth login'", "Set DD_API_KEY and DD_APP_KEY"} + case code == 403: + return []string{"Verify your API/App keys have required permissions"} + case code == 404: + return []string{"Verify the resource ID", "Check if the resource was deleted"} + case code == 429: + return []string{"Wait and retry with backoff"} + case code >= 500: + return []string{"Retry after a short delay", "Check https://status.datadoghq.com/"} + default: + return nil + } +} diff --git a/pkg/formatter/envelope_test.go b/pkg/formatter/envelope_test.go new file mode 100644 index 00000000..f17362ba --- /dev/null +++ b/pkg/formatter/envelope_test.go @@ -0,0 +1,150 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2024-present Datadog, Inc. + +package formatter + +import ( + "encoding/json" + "testing" +) + +func TestWrapForAgent(t *testing.T) { + data := map[string]string{"key": "value"} + count := 1 + meta := &Metadata{ + Count: &count, + Command: "test command", + } + + result, err := WrapForAgent(data, meta) + if err != nil { + t.Fatalf("WrapForAgent() error = %v", err) + } + + var envelope AgentEnvelope + if err := json.Unmarshal([]byte(result), &envelope); err != nil { + t.Fatalf("WrapForAgent() produced invalid JSON: %v", err) + } + + if envelope.Status != "success" { + t.Errorf("envelope.Status = %q, want %q", envelope.Status, "success") + } + if envelope.Metadata == nil { + t.Fatal("envelope.Metadata should not be nil") + } + if *envelope.Metadata.Count != 1 { + t.Errorf("envelope.Metadata.Count = %d, want 1", *envelope.Metadata.Count) + } + if envelope.Metadata.Command != "test command" { + t.Errorf("envelope.Metadata.Command = %q, want %q", envelope.Metadata.Command, "test command") + } +} + +func TestWrapForAgent_NilMetadata(t *testing.T) { + data := []int{1, 2, 3} + + result, err := WrapForAgent(data, nil) + if err != nil { + t.Fatalf("WrapForAgent() error = %v", err) + } + + var envelope AgentEnvelope + if err := json.Unmarshal([]byte(result), &envelope); err != nil { + t.Fatalf("WrapForAgent() produced invalid JSON: %v", err) + } + + if envelope.Status != "success" { + t.Errorf("envelope.Status = %q, want %q", envelope.Status, "success") + } + if envelope.Metadata != nil { + t.Error("envelope.Metadata should be nil when no metadata provided") + } +} + +func TestWrapForAgent_Truncated(t *testing.T) { + data := "some data" + count := 100 + meta := &Metadata{ + Count: &count, + Truncated: true, + NextAction: "Use --limit=500", + Command: "monitors list", + } + + result, err := WrapForAgent(data, meta) + if err != nil { + t.Fatalf("WrapForAgent() error = %v", err) + } + + var envelope AgentEnvelope + if err := json.Unmarshal([]byte(result), &envelope); err != nil { + t.Fatalf("WrapForAgent() produced invalid JSON: %v", err) + } + + if !envelope.Metadata.Truncated { + t.Error("envelope.Metadata.Truncated should be true") + } + if envelope.Metadata.NextAction != "Use --limit=500" { + t.Errorf("envelope.Metadata.NextAction = %q, want %q", envelope.Metadata.NextAction, "Use --limit=500") + } +} + +func TestFormatAgentError(t *testing.T) { + result, err := FormatAgentError("list monitors", 401, "authentication failed", "unauthorized") + if err != nil { + t.Fatalf("FormatAgentError() error = %v", err) + } + + var agentErr AgentError + if err := json.Unmarshal([]byte(result), &agentErr); err != nil { + t.Fatalf("FormatAgentError() produced invalid JSON: %v", err) + } + + if agentErr.Status != "error" { + t.Errorf("agentErr.Status = %q, want %q", agentErr.Status, "error") + } + if agentErr.ErrorCode != 401 { + t.Errorf("agentErr.ErrorCode = %d, want 401", agentErr.ErrorCode) + } + if agentErr.Operation != "list monitors" { + t.Errorf("agentErr.Operation = %q, want %q", agentErr.Operation, "list monitors") + } + if len(agentErr.Suggestions) == 0 { + t.Error("401 error should have suggestions") + } +} + +func TestFormatAgentError_AllStatusCodes(t *testing.T) { + tests := []struct { + code int + wantSuggestions bool + }{ + {401, true}, + {403, true}, + {404, true}, + {429, true}, + {500, true}, + {200, false}, + } + + for _, tt := range tests { + t.Run("", func(t *testing.T) { + result, err := FormatAgentError("test", tt.code, "error", "") + if err != nil { + t.Fatalf("FormatAgentError() error = %v", err) + } + + var agentErr AgentError + if err := json.Unmarshal([]byte(result), &agentErr); err != nil { + t.Fatalf("FormatAgentError() produced invalid JSON: %v", err) + } + + hasSuggestions := len(agentErr.Suggestions) > 0 + if hasSuggestions != tt.wantSuggestions { + t.Errorf("status %d: hasSuggestions = %v, want %v", tt.code, hasSuggestions, tt.wantSuggestions) + } + }) + } +} diff --git a/pkg/useragent/useragent.go b/pkg/useragent/useragent.go index bae1fb52..34d9f5cd 100644 --- a/pkg/useragent/useragent.go +++ b/pkg/useragent/useragent.go @@ -14,6 +14,34 @@ import ( "github.com/DataDog/pup/internal/version" ) +// AgentInfo contains information about a detected AI coding agent. +type AgentInfo struct { + Name string + Detected bool +} + +// agentDetector defines how to detect a specific AI coding agent. +type agentDetector struct { + Name string + EnvVars []string +} + +// agentDetectors is a table-driven registry of known AI coding agents. +// Order matters: first match wins when multiple agents are detected. +var agentDetectors = []agentDetector{ + {Name: "claude-code", EnvVars: []string{"CLAUDECODE", "CLAUDE_CODE"}}, + {Name: "cursor", EnvVars: []string{"CURSOR_AGENT"}}, + {Name: "codex", EnvVars: []string{"CODEX", "OPENAI_CODEX"}}, + {Name: "opencode", EnvVars: []string{"OPENCODE"}}, + {Name: "aider", EnvVars: []string{"AIDER"}}, + {Name: "cline", EnvVars: []string{"CLINE"}}, + {Name: "windsurf", EnvVars: []string{"WINDSURF_AGENT"}}, + {Name: "github-copilot", EnvVars: []string{"GITHUB_COPILOT"}}, + {Name: "amazon-q", EnvVars: []string{"AMAZON_Q", "AWS_Q_DEVELOPER"}}, + {Name: "gemini-code", EnvVars: []string{"GEMINI_CODE_ASSIST"}}, + {Name: "sourcegraph-cody", EnvVars: []string{"SRC_CODY"}}, +} + // Get returns the user agent string for pup CLI with optional AI agent detection. // // Format without agent: @@ -23,12 +51,6 @@ import ( // Format with agent: // // pup/v0.1.0 (go go1.25.0; os darwin; arch arm64; ai-agent claude-code) -// -// AI agents are detected via environment variables: -// - CLAUDECODE=1 or CLAUDE_CODE=1 → adds "ai-agent claude-code" -// - CURSOR_AGENT=true or CURSOR_AGENT=1 → adds "ai-agent cursor" -// -// If multiple agents are detected, CLAUDECODE takes precedence. func Get() string { base := fmt.Sprintf( "pup/%s (go %s; os %s; arch %s", @@ -44,19 +66,38 @@ func Get() string { return base + ")" } -// detectAgent detects AI coding assistant from environment variables. -// Returns empty string if no agent is detected. -func detectAgent() string { - // Check Claude Code (CLAUDECODE or CLAUDE_CODE) - if os.Getenv("CLAUDECODE") == "1" || os.Getenv("CLAUDE_CODE") == "1" { - return "claude-code" +// IsAgentMode returns true if any AI agent is detected or FORCE_AGENT_MODE=1 is set. +func IsAgentMode() bool { + if isEnvTruthy("FORCE_AGENT_MODE") { + return true } + return detectAgent() != "" +} - // Check Cursor (CURSOR_AGENT=true or CURSOR_AGENT=1) - cursorAgent := strings.ToLower(os.Getenv("CURSOR_AGENT")) - if cursorAgent == "true" || cursorAgent == "1" { - return "cursor" +// DetectAgentInfo returns information about the detected AI coding agent. +func DetectAgentInfo() AgentInfo { + agent := detectAgent() + if agent == "" { + return AgentInfo{} } + return AgentInfo{Name: agent, Detected: true} +} +// detectAgent detects AI coding assistant from environment variables. +// Returns empty string if no agent is detected. +func detectAgent() string { + for _, d := range agentDetectors { + for _, envVar := range d.EnvVars { + if isEnvTruthy(envVar) { + return d.Name + } + } + } return "" } + +// isEnvTruthy checks if an environment variable is set to a truthy value. +func isEnvTruthy(key string) bool { + val := strings.ToLower(os.Getenv(key)) + return val == "1" || val == "true" +} diff --git a/pkg/useragent/useragent_test.go b/pkg/useragent/useragent_test.go index 04042cc3..ef268726 100644 --- a/pkg/useragent/useragent_test.go +++ b/pkg/useragent/useragent_test.go @@ -14,96 +14,74 @@ import ( "github.com/DataDog/pup/internal/version" ) +// allAgentEnvVars returns all env vars used by agent detectors plus FORCE_AGENT_MODE. +func allAgentEnvVars() []string { + vars := []string{"FORCE_AGENT_MODE"} + for _, d := range agentDetectors { + vars = append(vars, d.EnvVars...) + } + return vars +} + +// clearAllAgentEnvVars unsets every agent-related env var. +func clearAllAgentEnvVars() { + for _, v := range allAgentEnvVars() { + os.Unsetenv(v) + } +} + func TestGet_NoAgent(t *testing.T) { - // NOTE: Not parallel - modifies env vars - // Clear all agent environment variables - os.Unsetenv("CLAUDECODE") - os.Unsetenv("CLAUDE_CODE") - os.Unsetenv("CURSOR_AGENT") + clearAllAgentEnvVars() result := Get() - // Check format matches: pup/VERSION (go GOVERSION; os OS; arch ARCH) expected := "pup/" + version.Version + " (go " + runtime.Version() + "; os " + runtime.GOOS + "; arch " + runtime.GOARCH + ")" if result != expected { t.Errorf("Get() = %q, want %q", result, expected) } - // Verify no agent in output if strings.Contains(result, "ai-agent") { t.Errorf("Get() should not contain ai-agent, got %q", result) } } -func TestGet_WithClaudeCode(t *testing.T) { - // NOTE: Not parallel - modifies env vars +func TestGet_AllAgents(t *testing.T) { tests := []struct { - name string - envVar string - envVal string - wantSuf string + name string + envVar string + envVal string + wantName string }{ {"CLAUDECODE=1", "CLAUDECODE", "1", "claude-code"}, {"CLAUDE_CODE=1", "CLAUDE_CODE", "1", "claude-code"}, + {"CURSOR_AGENT=true", "CURSOR_AGENT", "true", "cursor"}, + {"CURSOR_AGENT=1", "CURSOR_AGENT", "1", "cursor"}, + {"CODEX=1", "CODEX", "1", "codex"}, + {"OPENAI_CODEX=1", "OPENAI_CODEX", "1", "codex"}, + {"OPENCODE=1", "OPENCODE", "1", "opencode"}, + {"AIDER=1", "AIDER", "1", "aider"}, + {"CLINE=1", "CLINE", "1", "cline"}, + {"WINDSURF_AGENT=1", "WINDSURF_AGENT", "1", "windsurf"}, + {"GITHUB_COPILOT=1", "GITHUB_COPILOT", "1", "github-copilot"}, + {"AMAZON_Q=1", "AMAZON_Q", "1", "amazon-q"}, + {"AWS_Q_DEVELOPER=true", "AWS_Q_DEVELOPER", "true", "amazon-q"}, + {"GEMINI_CODE_ASSIST=1", "GEMINI_CODE_ASSIST", "1", "gemini-code"}, + {"SRC_CODY=1", "SRC_CODY", "1", "sourcegraph-cody"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Clear all agent env vars first - os.Unsetenv("CLAUDECODE") - os.Unsetenv("CLAUDE_CODE") - os.Unsetenv("CURSOR_AGENT") - - // Set test env var + clearAllAgentEnvVars() os.Setenv(tt.envVar, tt.envVal) defer os.Unsetenv(tt.envVar) result := Get() - // Verify agent is present in structured format - expectedAgent := "; ai-agent " + tt.wantSuf + ")" - if !strings.HasSuffix(result, expectedAgent) { - t.Errorf("Get() = %q, want suffix %q", result, expectedAgent) - } - - // Verify base format is still correct - expectedBase := "pup/" + version.Version + " (go " + runtime.Version() + "; os " + runtime.GOOS + "; arch " + runtime.GOARCH - if !strings.HasPrefix(result, expectedBase) { - t.Errorf("Get() = %q, want prefix %q", result, expectedBase) - } - }) - } -} - -func TestGet_WithCursor(t *testing.T) { - tests := []struct { - name string - envVal string - }{ - {"CURSOR_AGENT=true", "true"}, - {"CURSOR_AGENT=TRUE", "TRUE"}, - {"CURSOR_AGENT=1", "1"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Clear all agent env vars first - os.Unsetenv("CLAUDECODE") - os.Unsetenv("CLAUDE_CODE") - os.Unsetenv("CURSOR_AGENT") - - // Set test env var - os.Setenv("CURSOR_AGENT", tt.envVal) - defer os.Unsetenv("CURSOR_AGENT") - - result := Get() - - // Verify agent is present in structured format - if !strings.HasSuffix(result, "; ai-agent cursor)") { - t.Errorf("Get() = %q, want suffix '; ai-agent cursor)'", result) + expectedSuffix := "; ai-agent " + tt.wantName + ")" + if !strings.HasSuffix(result, expectedSuffix) { + t.Errorf("Get() = %q, want suffix %q", result, expectedSuffix) } - // Verify base format is still correct expectedBase := "pup/" + version.Version + " (go " + runtime.Version() + "; os " + runtime.GOOS + "; arch " + runtime.GOARCH if !strings.HasPrefix(result, expectedBase) { t.Errorf("Get() = %q, want prefix %q", result, expectedBase) @@ -113,11 +91,7 @@ func TestGet_WithCursor(t *testing.T) { } func TestGet_WithMultipleAgents(t *testing.T) { - // Test precedence: CLAUDECODE should win when both are set - os.Unsetenv("CLAUDECODE") - os.Unsetenv("CLAUDE_CODE") - os.Unsetenv("CURSOR_AGENT") - + clearAllAgentEnvVars() os.Setenv("CLAUDECODE", "1") os.Setenv("CURSOR_AGENT", "true") defer func() { @@ -127,7 +101,6 @@ func TestGet_WithMultipleAgents(t *testing.T) { result := Get() - // Should have ai-agent claude-code, not cursor if !strings.HasSuffix(result, "; ai-agent claude-code)") { t.Errorf("Get() = %q, want suffix '; ai-agent claude-code)' (CLAUDECODE should take precedence)", result) } @@ -139,106 +112,32 @@ func TestGet_WithMultipleAgents(t *testing.T) { func TestDetectAgent(t *testing.T) { tests := []struct { name string - setup func() - teardown func() + envVar string + envVal string want string }{ - { - name: "no agent", - setup: func() { - os.Unsetenv("CLAUDECODE") - os.Unsetenv("CLAUDE_CODE") - os.Unsetenv("CURSOR_AGENT") - }, - teardown: func() {}, - want: "", - }, - { - name: "CLAUDECODE=1", - setup: func() { - os.Unsetenv("CLAUDECODE") - os.Unsetenv("CLAUDE_CODE") - os.Unsetenv("CURSOR_AGENT") - os.Setenv("CLAUDECODE", "1") - }, - teardown: func() { - os.Unsetenv("CLAUDECODE") - }, - want: "claude-code", - }, - { - name: "CLAUDE_CODE=1", - setup: func() { - os.Unsetenv("CLAUDECODE") - os.Unsetenv("CLAUDE_CODE") - os.Unsetenv("CURSOR_AGENT") - os.Setenv("CLAUDE_CODE", "1") - }, - teardown: func() { - os.Unsetenv("CLAUDE_CODE") - }, - want: "claude-code", - }, - { - name: "CURSOR_AGENT=true", - setup: func() { - os.Unsetenv("CLAUDECODE") - os.Unsetenv("CLAUDE_CODE") - os.Unsetenv("CURSOR_AGENT") - os.Setenv("CURSOR_AGENT", "true") - }, - teardown: func() { - os.Unsetenv("CURSOR_AGENT") - }, - want: "cursor", - }, - { - name: "CURSOR_AGENT=1", - setup: func() { - os.Unsetenv("CLAUDECODE") - os.Unsetenv("CLAUDE_CODE") - os.Unsetenv("CURSOR_AGENT") - os.Setenv("CURSOR_AGENT", "1") - }, - teardown: func() { - os.Unsetenv("CURSOR_AGENT") - }, - want: "cursor", - }, - { - name: "CURSOR_AGENT=false (invalid, should not detect)", - setup: func() { - os.Unsetenv("CLAUDECODE") - os.Unsetenv("CLAUDE_CODE") - os.Unsetenv("CURSOR_AGENT") - os.Setenv("CURSOR_AGENT", "false") - }, - teardown: func() { - os.Unsetenv("CURSOR_AGENT") - }, - want: "", - }, - { - name: "multiple agents (CLAUDECODE precedence)", - setup: func() { - os.Unsetenv("CLAUDECODE") - os.Unsetenv("CLAUDE_CODE") - os.Unsetenv("CURSOR_AGENT") - os.Setenv("CLAUDECODE", "1") - os.Setenv("CURSOR_AGENT", "true") - }, - teardown: func() { - os.Unsetenv("CLAUDECODE") - os.Unsetenv("CURSOR_AGENT") - }, - want: "claude-code", - }, + {"no agent", "", "", ""}, + {"CLAUDECODE=1", "CLAUDECODE", "1", "claude-code"}, + {"CLAUDE_CODE=1", "CLAUDE_CODE", "1", "claude-code"}, + {"CURSOR_AGENT=true", "CURSOR_AGENT", "true", "cursor"}, + {"CURSOR_AGENT=1", "CURSOR_AGENT", "1", "cursor"}, + {"CURSOR_AGENT=false (not truthy)", "CURSOR_AGENT", "false", ""}, + {"CODEX=1", "CODEX", "1", "codex"}, + {"AIDER=true", "AIDER", "true", "aider"}, + {"WINDSURF_AGENT=1", "WINDSURF_AGENT", "1", "windsurf"}, + {"GITHUB_COPILOT=1", "GITHUB_COPILOT", "1", "github-copilot"}, + {"AMAZON_Q=1", "AMAZON_Q", "1", "amazon-q"}, + {"GEMINI_CODE_ASSIST=1", "GEMINI_CODE_ASSIST", "1", "gemini-code"}, + {"SRC_CODY=1", "SRC_CODY", "1", "sourcegraph-cody"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - tt.setup() - defer tt.teardown() + clearAllAgentEnvVars() + if tt.envVar != "" { + os.Setenv(tt.envVar, tt.envVal) + defer os.Unsetenv(tt.envVar) + } got := detectAgent() if got != tt.want { @@ -248,51 +147,126 @@ func TestDetectAgent(t *testing.T) { } } +func TestIsAgentMode(t *testing.T) { + tests := []struct { + name string + envVar string + envVal string + want bool + }{ + {"no env", "", "", false}, + {"FORCE_AGENT_MODE=1", "FORCE_AGENT_MODE", "1", true}, + {"FORCE_AGENT_MODE=true", "FORCE_AGENT_MODE", "true", true}, + {"FORCE_AGENT_MODE=false", "FORCE_AGENT_MODE", "false", false}, + {"CLAUDECODE=1", "CLAUDECODE", "1", true}, + {"CURSOR_AGENT=1", "CURSOR_AGENT", "1", true}, + {"AIDER=1", "AIDER", "1", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clearAllAgentEnvVars() + if tt.envVar != "" { + os.Setenv(tt.envVar, tt.envVal) + defer os.Unsetenv(tt.envVar) + } + + got := IsAgentMode() + if got != tt.want { + t.Errorf("IsAgentMode() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestDetectAgentInfo(t *testing.T) { + tests := []struct { + name string + envVar string + envVal string + want AgentInfo + }{ + {"no agent", "", "", AgentInfo{}}, + {"claude-code", "CLAUDECODE", "1", AgentInfo{Name: "claude-code", Detected: true}}, + {"cursor", "CURSOR_AGENT", "1", AgentInfo{Name: "cursor", Detected: true}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clearAllAgentEnvVars() + if tt.envVar != "" { + os.Setenv(tt.envVar, tt.envVal) + defer os.Unsetenv(tt.envVar) + } + + got := DetectAgentInfo() + if got != tt.want { + t.Errorf("DetectAgentInfo() = %+v, want %+v", got, tt.want) + } + }) + } +} + func TestGet_Format(t *testing.T) { - // Clear all agent env vars - os.Unsetenv("CLAUDECODE") - os.Unsetenv("CLAUDE_CODE") - os.Unsetenv("CURSOR_AGENT") + clearAllAgentEnvVars() result := Get() - // Verify format: pup/VERSION (go GOVERSION; os OS; arch ARCH) if !strings.HasPrefix(result, "pup/") { t.Errorf("Get() should start with 'pup/', got %q", result) } - if !strings.Contains(result, "(go ") { t.Errorf("Get() should contain '(go ', got %q", result) } - if !strings.Contains(result, "; os ") { t.Errorf("Get() should contain '; os ', got %q", result) } - if !strings.Contains(result, "; arch ") { t.Errorf("Get() should contain '; arch ', got %q", result) } - - // Verify no extra spaces or malformed output if strings.Contains(result, " ") { t.Errorf("Get() should not contain double spaces, got %q", result) } - // Test with agent os.Setenv("CLAUDECODE", "1") defer os.Unsetenv("CLAUDECODE") resultWithAgent := Get() - - // Verify agent is in structured format expectedSuffix := "; ai-agent claude-code)" if !strings.HasSuffix(resultWithAgent, expectedSuffix) { t.Errorf("Get() with agent should end with %q, got %q", expectedSuffix, resultWithAgent) } - // Verify base part is present expectedBase := "pup/" + version.Version + " (go " + runtime.Version() + "; os " + runtime.GOOS + "; arch " + runtime.GOARCH if !strings.HasPrefix(resultWithAgent, expectedBase) { t.Errorf("Get() with agent should start with %q, got %q", expectedBase, resultWithAgent) } } + +func TestIsEnvTruthy(t *testing.T) { + tests := []struct { + val string + want bool + }{ + {"1", true}, + {"true", true}, + {"TRUE", true}, + {"True", true}, + {"0", false}, + {"false", false}, + {"", false}, + {"yes", false}, + } + + for _, tt := range tests { + t.Run(tt.val, func(t *testing.T) { + os.Setenv("TEST_TRUTHY", tt.val) + defer os.Unsetenv("TEST_TRUTHY") + + got := isEnvTruthy("TEST_TRUTHY") + if got != tt.want { + t.Errorf("isEnvTruthy(%q) = %v, want %v", tt.val, got, tt.want) + } + }) + } +}