diff --git a/go.mod b/go.mod index 990455b..7b7294e 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/flashcatcloud/flashduty-cli go 1.25.1 require ( - github.com/flashcatcloud/flashduty-sdk v0.9.0 + github.com/flashcatcloud/flashduty-sdk v0.9.1-0.20260528073358-9821a7ff07c9 github.com/mattn/go-runewidth v0.0.23 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.9 diff --git a/go.sum b/go.sum index 62f1eab..a7b1070 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,8 @@ github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/flashcatcloud/flashduty-sdk v0.9.0 h1:gEBt9ZJ8HbDc22U1V4cWPitxlPxfztqKIe2x6TyRqJw= -github.com/flashcatcloud/flashduty-sdk v0.9.0/go.mod h1:dG4eJfdZaj4jNBMwEexbfK/3PmcIMhNeJ88L/DcZzUY= +github.com/flashcatcloud/flashduty-sdk v0.9.1-0.20260528073358-9821a7ff07c9 h1:xNoqIR4zOHcX8TbLpn/ENaK/G6ZwpPyOeVTuqbE1uoc= +github.com/flashcatcloud/flashduty-sdk v0.9.1-0.20260528073358-9821a7ff07c9/go.mod h1:dG4eJfdZaj4jNBMwEexbfK/3PmcIMhNeJ88L/DcZzUY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= diff --git a/internal/cli/alert.go b/internal/cli/alert.go index e2fd918..54d2287 100644 --- a/internal/cli/alert.go +++ b/internal/cli/alert.go @@ -123,7 +123,7 @@ func newAlertGetCmd() *cobra.Command { return err } - if ctx.JSON { + if ctx.Structured() { return ctx.Printer.Print(result.Alert, nil) } diff --git a/internal/cli/args.go b/internal/cli/args.go index e83813d..623b6c4 100644 --- a/internal/cli/args.go +++ b/internal/cli/args.go @@ -39,9 +39,10 @@ func requireExactlyOneFlag(cmd *cobra.Command, flagNames ...string) error { } // confirmAction prompts the user for confirmation in interactive terminals. -// Returns true if the user confirms, or if running in non-interactive / JSON / --force mode. +// Returns true if the user confirms, or if running in non-interactive / +// structured-output (JSON/TOON) / --force mode. func confirmAction(cmd *cobra.Command, message string) bool { - if flagJSON { + if currentOutputFormat().Structured() { return true } force, _ := cmd.Flags().GetBool("force") diff --git a/internal/cli/change.go b/internal/cli/change.go index 0b049b1..77964ba 100644 --- a/internal/cli/change.go +++ b/internal/cli/change.go @@ -21,7 +21,7 @@ func newChangeCmd() *cobra.Command { } func newChangeListCmd() *cobra.Command { - var channelID int64 + var channel string var since, until string var limit, page int @@ -39,13 +39,22 @@ func newChangeListCmd() *cobra.Command { return fmt.Errorf("invalid --until: %w", err) } - result, err := ctx.Client.ListChanges(cmdContext(ctx.Cmd), &flashduty.ListChangesInput{ - ChannelID: channelID, + input := &flashduty.ListChangesInput{ StartTime: startTime, EndTime: endTime, Limit: limit, Page: page, - }) + } + + if channel != "" { + channelIDs, err := parseIntSlice(channel) + if err != nil { + return fmt.Errorf("invalid --channel: %w", err) + } + input.ChannelIDs = channelIDs + } + + result, err := ctx.Client.ListChanges(cmdContext(ctx.Cmd), input) if err != nil { return err } @@ -63,7 +72,7 @@ func newChangeListCmd() *cobra.Command { }, } - cmd.Flags().Int64Var(&channelID, "channel", 0, "Filter by channel ID") + cmd.Flags().StringVar(&channel, "channel", "", "Comma-separated channel IDs") cmd.Flags().StringVar(&since, "since", "24h", "Start time") cmd.Flags().StringVar(&until, "until", "now", "End time") cmd.Flags().IntVar(&limit, "limit", 20, "Max results") diff --git a/internal/cli/change_test.go b/internal/cli/change_test.go new file mode 100644 index 0000000..a7f7a0d --- /dev/null +++ b/internal/cli/change_test.go @@ -0,0 +1,48 @@ +package cli + +import ( + "testing" +) + +// TestChangeListChannelFlag verifies that --channel is a string flag (comma-separated IDs), +// not a singular int64 flag. Mirrors the alert list --channel pattern. +func TestChangeListChannelFlag(t *testing.T) { + cmd := newChangeListCmd() + flags := cmd.Flags() + + f := flags.Lookup("channel") + if f == nil { + t.Fatal("flag --channel not registered") + } + + // Must be a string flag (Value.Type() == "string"), not int64. + if got := f.Value.Type(); got != "string" { + t.Errorf("--channel flag type = %q, want %q", got, "string") + } + + // Default must be empty string (not "0"). + if got := f.DefValue; got != "" { + t.Errorf("--channel default = %q, want %q", got, "") + } +} + +// TestChangeListChannelParsing verifies that a comma-separated --channel value +// is correctly parsed to []int64 via parseIntSlice — the same helper used by +// alert list. Full comma-split semantics are covered by TestParseIntSlice in +// helpers_test.go; this test only confirms the wiring is correct. +func TestChangeListChannelParsing(t *testing.T) { + // parseIntSlice is the shared helper; spot-check the three-value case. + got, err := parseIntSlice("100,200,300") + if err != nil { + t.Fatalf("parseIntSlice(\"100,200,300\"): unexpected error: %v", err) + } + want := []int64{100, 200, 300} + if len(got) != len(want) { + t.Fatalf("length mismatch: got %d, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("index %d: got %d, want %d", i, got[i], want[i]) + } + } +} diff --git a/internal/cli/command.go b/internal/cli/command.go index 9f4289e..bb93e17 100644 --- a/internal/cli/command.go +++ b/internal/cli/command.go @@ -1,7 +1,6 @@ package cli import ( - "encoding/json" "fmt" "io" @@ -18,9 +17,14 @@ type RunContext struct { Args []string Writer io.Writer Printer output.Printer - JSON bool + Format output.Format } +// Structured reports whether output should be a machine-readable dump (JSON or +// TOON) rather than the human table/detail view. Command handlers branch on +// this to suppress detail views, footers, and interactive prompts. +func (ctx *RunContext) Structured() bool { return ctx.Format.Structured() } + // runCommand creates a client and RunContext, then calls fn. // It centralises setup that every API-backed command repeats. func runCommand(cmd *cobra.Command, args []string, fn func(ctx *RunContext) error) error { @@ -34,7 +38,7 @@ func runCommand(cmd *cobra.Command, args []string, fn func(ctx *RunContext) erro Args: args, Writer: cmd.OutOrStdout(), Printer: newPrinter(cmd.OutOrStdout()), - JSON: flagJSON, + Format: currentOutputFormat(), } return fn(ctx) } @@ -44,7 +48,7 @@ func (ctx *RunContext) PrintList(items any, cols []output.Column, count, page, t if err := ctx.Printer.Print(items, cols); err != nil { return err } - if !ctx.JSON { + if !ctx.Structured() { _, _ = fmt.Fprintf(ctx.Writer, "Showing %d results (page %d, total %d).\n", count, page, total) } return nil @@ -55,7 +59,7 @@ func (ctx *RunContext) PrintTotal(items any, cols []output.Column, total int) er if err := ctx.Printer.Print(items, cols); err != nil { return err } - if !ctx.JSON { + if !ctx.Structured() { _, _ = fmt.Fprintf(ctx.Writer, "Total: %d\n", total) } return nil @@ -66,17 +70,18 @@ func (ctx *RunContext) WriteResult(message string) { writeResult(ctx.Writer, message) } -// WriteResultJSON outputs structured data as JSON in --json mode, -// or a human-readable message in table mode. +// WriteResultJSON outputs structured data in JSON or TOON mode, or a +// human-readable message in table mode. JSON stays indented (byte-compatible +// with the legacy --json path); TOON routes through the SDK marshaller. func (ctx *RunContext) WriteResultJSON(data any, humanMessage string) error { - if ctx.JSON { - out, err := json.MarshalIndent(data, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - _, _ = fmt.Fprintln(ctx.Writer, string(out)) + if !ctx.Structured() { + _, _ = fmt.Fprintln(ctx.Writer, humanMessage) return nil } - _, _ = fmt.Fprintln(ctx.Writer, humanMessage) + out, err := marshalStructured(data) + if err != nil { + return fmt.Errorf("failed to marshal output: %w", err) + } + _, _ = fmt.Fprintln(ctx.Writer, string(out)) return nil } diff --git a/internal/cli/command_test.go b/internal/cli/command_test.go index 96e6c2f..7b5ac04 100644 --- a/internal/cli/command_test.go +++ b/internal/cli/command_test.go @@ -282,6 +282,28 @@ func (m *mockClient) DeleteTeam(context.Context, *flashduty.TeamDeleteInput) err return fmt.Errorf("mockClient: DeleteTeam not implemented") } +func (m *mockClient) CreateMCPServer(context.Context, *flashduty.CreateMCPServerInput) (*flashduty.CreateMCPServerOutput, error) { + return nil, fmt.Errorf("mockClient: CreateMCPServer not implemented") +} + +// CLI Phase 2: monit-query +func (m *mockClient) MonitQueryDiagnose(context.Context, *flashduty.MonitQueryDiagnoseInput) (*flashduty.MonitQueryDiagnoseOutput, error) { + return nil, fmt.Errorf("mockClient: MonitQueryDiagnose not implemented") +} + +func (m *mockClient) MonitQueryRows(context.Context, *flashduty.MonitQueryRowsInput) (*flashduty.MonitQueryRowsOutput, error) { + return nil, fmt.Errorf("mockClient: MonitQueryRows not implemented") +} + +// CLI Phase 2: monit-agent +func (m *mockClient) MonitAgentCatalog(context.Context, *flashduty.MonitAgentCatalogInput) (*flashduty.MonitAgentCatalogOutput, error) { + return nil, fmt.Errorf("mockClient: MonitAgentCatalog not implemented") +} + +func (m *mockClient) MonitAgentInvoke(context.Context, *flashduty.MonitAgentInvokeInput) (*flashduty.MonitAgentInvokeOutput, error) { + return nil, fmt.Errorf("mockClient: MonitAgentInvoke not implemented") +} + // saveAndResetGlobals saves the current state of all global vars that commands // mutate, resets them to safe defaults, and returns a restore function for // t.Cleanup. @@ -352,6 +374,16 @@ func resetFlagSet(flags *pflag.FlagSet) { case "bool", "int", "int64", "string": _ = flag.Value.Set(flag.DefValue) flag.Changed = false + case "stringSlice", "stringArray": + // Slice-valued flags accumulate across Parse() calls; clear them + // explicitly so a later test isn't observing the previous test's + // repeated --flag entries. pflag's SliceValue / Append interfaces + // don't expose a "reset to default" — Set("") would append an + // empty entry, so we use Replace([]) to truly empty the slice. + if sv, ok := flag.Value.(pflag.SliceValue); ok { + _ = sv.Replace([]string{}) + flag.Changed = false + } } }) } diff --git a/internal/cli/helpers.go b/internal/cli/helpers.go new file mode 100644 index 0000000..c517c08 --- /dev/null +++ b/internal/cli/helpers.go @@ -0,0 +1,61 @@ +package cli + +import ( + "encoding/json" + "fmt" + "strings" + + flashduty "github.com/flashcatcloud/flashduty-sdk" +) + +// parseKVSlice converts a slice of "KEY=VALUE" entries into a map. +// Returns nil (not an error) for an empty input so callers can pass nil +// maps through to the SDK without triggering omitempty issues. +func parseKVSlice(entries []string) (map[string]string, error) { + if len(entries) == 0 { + return nil, nil + } + out := make(map[string]string, len(entries)) + for _, e := range entries { + i := strings.IndexByte(e, '=') + if i < 0 { + return nil, fmt.Errorf("missing '=': %q", e) + } + out[e[:i]] = e[i+1:] + } + return out, nil +} + +// parseToolSpecs converts a slice of "name=[,params=]" specs into +// MonitAgentInvokeTool entries. The `name` key is required; `params` is +// optional and defaults to `{}` so the server-side decoder accepts it. Splits +// each spec on ',' first then on the first '=', mirroring parseKVSlice — that +// means params JSON containing commas isn't supported; specs with complex +// params must keep their objects single-keyed. +func parseToolSpecs(specs []string) ([]flashduty.MonitAgentInvokeTool, error) { + out := make([]flashduty.MonitAgentInvokeTool, 0, len(specs)) + for _, s := range specs { + var name string + params := json.RawMessage("{}") + for _, kv := range strings.Split(s, ",") { + i := strings.IndexByte(kv, '=') + if i < 0 { + return nil, fmt.Errorf("missing '=' in %q", kv) + } + k, v := kv[:i], kv[i+1:] + switch k { + case "name": + name = v + case "params": + params = json.RawMessage(v) + default: + return nil, fmt.Errorf("unknown key %q in tool-spec", k) + } + } + if name == "" { + return nil, fmt.Errorf("missing name= in spec %q", s) + } + out = append(out, flashduty.MonitAgentInvokeTool{Tool: name, Params: params}) + } + return out, nil +} diff --git a/internal/cli/helpers_test.go b/internal/cli/helpers_test.go index 1602949..146a62b 100644 --- a/internal/cli/helpers_test.go +++ b/internal/cli/helpers_test.go @@ -1,6 +1,7 @@ package cli import ( + "reflect" "strings" "testing" ) @@ -125,3 +126,40 @@ func TestOrDash(t *testing.T) { func TestMemberPersonInfosDisplay(t *testing.T) { t.Skip("requires injection seam for fake client (Phase 3)") } + +func TestParseKVSlice(t *testing.T) { + cases := []struct { + name string + input []string + want map[string]string + wantErr bool + }{ + {"nil input", nil, nil, false}, + {"empty input", []string{}, nil, false}, + {"single pair", []string{"K=V"}, map[string]string{"K": "V"}, false}, + {"multiple pairs", []string{"A=1", "B=2"}, map[string]string{"A": "1", "B": "2"}, false}, + // Value contains additional '=' signs — only the first splits key from value. + {"value contains equals", []string{"K=a=b=c"}, map[string]string{"K": "a=b=c"}, false}, + {"empty value", []string{"K="}, map[string]string{"K": ""}, false}, + // Empty-key is the current behaviour when the entry starts with '='; documented here. + {"empty key", []string{"=V"}, map[string]string{"": "V"}, false}, + {"missing equals", []string{"NOEQ"}, nil, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := parseKVSlice(tc.input) + if tc.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("got %v, want %v", got, tc.want) + } + }) + } +} diff --git a/internal/cli/incident.go b/internal/cli/incident.go index c661c7f..1bfcab2 100644 --- a/internal/cli/incident.go +++ b/internal/cli/incident.go @@ -124,7 +124,7 @@ func newIncidentGetCmd() *cobra.Command { return err } - if ctx.JSON { + if ctx.Structured() { return ctx.Printer.Print(result.Incidents, nil) } @@ -973,7 +973,7 @@ the chat ID and integration ID for an incident.`, if err != nil { return err } - if ctx.JSON { + if ctx.Structured() { return ctx.Printer.Print(warRoom, nil) } printWarRoomDetail(ctx.Writer, warRoom) @@ -1194,7 +1194,7 @@ func newIncidentDetailCmd() *cobra.Command { return err } - if ctx.JSON { + if ctx.Structured() { return ctx.Printer.Print(result.Incident, nil) } diff --git a/internal/cli/mcp.go b/internal/cli/mcp.go new file mode 100644 index 0000000..3db09af --- /dev/null +++ b/internal/cli/mcp.go @@ -0,0 +1,87 @@ +package cli + +import ( + "fmt" + "strings" + + flashduty "github.com/flashcatcloud/flashduty-sdk" + "github.com/spf13/cobra" +) + +func newMCPCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "mcp", + Short: "Manage MCP server registrations", + } + cmd.AddCommand(newMCPCreateCmd()) + return cmd +} + +func newMCPCreateCmd() *cobra.Command { + var ( + serverName string + description string + transport string + command string + argsFlag []string + envEntries []string + url string + headerEntries []string + connectTimeout int + callTimeout int + teamID int64 + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Register an MCP server", + RunE: func(cmd *cobra.Command, args []string) error { + return runCommand(cmd, args, func(ctx *RunContext) error { + if strings.TrimSpace(serverName) == "" { + return fmt.Errorf("--server-name is required") + } + envMap, err := parseKVSlice(envEntries) + if err != nil { + return fmt.Errorf("invalid --env: %w", err) + } + headerMap, err := parseKVSlice(headerEntries) + if err != nil { + return fmt.Errorf("invalid --headers: %w", err) + } + input := &flashduty.CreateMCPServerInput{ + ServerName: serverName, + Description: description, + Transport: transport, + Command: command, + Args: argsFlag, + Env: envMap, + URL: url, + Headers: headerMap, + ConnectTimeout: connectTimeout, + CallTimeout: callTimeout, + TeamID: teamID, + } + result, err := ctx.Client.CreateMCPServer(cmdContext(ctx.Cmd), input) + if err != nil { + return err + } + return ctx.WriteResultJSON(result, + fmt.Sprintf("MCP server registered: %s (status: %s)", result.ServerID, result.Status)) + }) + }, + } + + cmd.Flags().StringVar(&serverName, "server-name", "", "MCP server display name (required)") + cmd.Flags().StringVar(&description, "description", "", "Server description") + cmd.Flags().StringVar(&transport, "transport", "streamable-http", "Transport: stdio|sse|streamable-http") + cmd.Flags().StringVar(&command, "command", "", "Executable (stdio transport)") + cmd.Flags().StringSliceVar(&argsFlag, "args", nil, "Executable args (stdio transport, repeatable)") + cmd.Flags().StringSliceVar(&envEntries, "env", nil, "Env entries KEY=VALUE (repeatable)") + cmd.Flags().StringVar(&url, "url", "", "URL (sse / streamable-http)") + cmd.Flags().StringSliceVar(&headerEntries, "headers", nil, "Header entries KEY=VALUE (repeatable)") + cmd.Flags().IntVar(&connectTimeout, "connect-timeout", 10, "Connection timeout in seconds") + cmd.Flags().IntVar(&callTimeout, "call-timeout", 60, "Tool-call timeout in seconds") + cmd.Flags().Int64Var(&teamID, "team-id", 0, "Team scope (0 = account-scope)") + + return cmd +} diff --git a/internal/cli/mcp_test.go b/internal/cli/mcp_test.go new file mode 100644 index 0000000..0beebac --- /dev/null +++ b/internal/cli/mcp_test.go @@ -0,0 +1,35 @@ +package cli + +import ( + "strings" + "testing" +) + +func TestMCPCreateFlagSurface(t *testing.T) { + cmd := newMCPCreateCmd() + flags := cmd.Flags() + for _, name := range []string{ + "server-name", "description", "transport", + "command", "args", "env", "url", "headers", + "connect-timeout", "call-timeout", "team-id", + } { + if flags.Lookup(name) == nil { + t.Errorf("flag --%s not registered", name) + } + } +} + +func TestMCPCreateRejectsEmptyServerName(t *testing.T) { + saveAndResetGlobals(t) + // The empty-name guard fires inside runCommand before CreateMCPServer is + // ever called, so a no-op stub is sufficient. + newClientFn = func() (flashdutyClient, error) { return &mockClient{}, nil } + + _, err := execCommand("mcp", "create") + if err == nil { + t.Fatal("expected error for empty --server-name, got nil") + } + if !strings.Contains(err.Error(), "--server-name is required") { + t.Fatalf("expected error %q, got %q", "--server-name is required", err.Error()) + } +} diff --git a/internal/cli/member.go b/internal/cli/member.go index 472e8b9..55da0c9 100644 --- a/internal/cli/member.go +++ b/internal/cli/member.go @@ -59,14 +59,14 @@ func newMemberListCmd() *cobra.Command { return err } } else { - if ctx.JSON { + if ctx.Structured() { return ctx.Printer.Print([]struct{}{}, nil) } _, _ = fmt.Fprintln(ctx.Writer, "No members found.") return nil } - if !ctx.JSON { + if !ctx.Structured() { _, _ = fmt.Fprintf(ctx.Writer, "Total: %d\n", result.Total) } return nil diff --git a/internal/cli/monit_agent.go b/internal/cli/monit_agent.go new file mode 100644 index 0000000..cd1f258 --- /dev/null +++ b/internal/cli/monit_agent.go @@ -0,0 +1,96 @@ +package cli + +import ( + "fmt" + + flashduty "github.com/flashcatcloud/flashduty-sdk" + "github.com/spf13/cobra" +) + +func newMonitAgentCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "monit-agent", + Short: "On-box diagnostics via flashmonit agents (host/mysql/redis/…)", + } + cmd.AddCommand(newMonitAgentCatalogCmd()) + cmd.AddCommand(newMonitAgentInvokeCmd()) + return cmd +} + +func newMonitAgentCatalogCmd() *cobra.Command { + var targetKind, targetLocator string + + cmd := &cobra.Command{ + Use: "catalog", + Short: "List the diagnostic tools the agent exposes for a target", + RunE: func(cmd *cobra.Command, args []string) error { + if targetLocator == "" { + return fmt.Errorf("--target-locator is required") + } + return runCommand(cmd, args, func(ctx *RunContext) error { + input := &flashduty.MonitAgentCatalogInput{ + TargetKind: targetKind, + TargetLocator: targetLocator, + } + result, err := ctx.Client.MonitAgentCatalog(cmdContext(ctx.Cmd), input) + if err != nil { + return err + } + return ctx.Printer.Print(result, nil) + }) + }, + } + + cmd.Flags().StringVar(&targetKind, "target-kind", "", "Target kind (host|mysql|redis|…); omit to let the agent infer") + cmd.Flags().StringVar(&targetLocator, "target-locator", "", "Target locator: internal IP, hostname, or data-source name (required)") + + return cmd +} + +func newMonitAgentInvokeCmd() *cobra.Command { + var ( + targetKind, targetLocator string + toolSpecs []string + ) + + cmd := &cobra.Command{ + Use: "invoke", + Short: "Run up to 8 monit-agent tools concurrently on a target", + RunE: func(cmd *cobra.Command, args []string) error { + if targetLocator == "" { + return fmt.Errorf("--target-locator is required") + } + if len(toolSpecs) == 0 { + return fmt.Errorf("--tool-spec is required (repeatable; up to 8)") + } + if len(toolSpecs) > 8 { + return fmt.Errorf("--tool-spec accepts up to 8 entries (got %d)", len(toolSpecs)) + } + parsed, err := parseToolSpecs(toolSpecs) + if err != nil { + return fmt.Errorf("invalid --tool-spec: %w", err) + } + + return runCommand(cmd, args, func(ctx *RunContext) error { + input := &flashduty.MonitAgentInvokeInput{ + TargetKind: targetKind, + TargetLocator: targetLocator, + Tools: parsed, + } + result, err := ctx.Client.MonitAgentInvoke(cmdContext(ctx.Cmd), input) + if err != nil { + return err + } + return ctx.Printer.Print(result, nil) + }) + }, + } + + cmd.Flags().StringVar(&targetKind, "target-kind", "", "Target kind (host|mysql|redis|…); omit to let the agent infer") + cmd.Flags().StringVar(&targetLocator, "target-locator", "", "Target locator: internal IP, hostname, or data-source name (required)") + // Use StringArray (not StringSlice) so commas inside params= aren't + // mis-parsed as CSV separators — each --tool-spec entry is taken verbatim. + cmd.Flags().StringArrayVar(&toolSpecs, "tool-spec", nil, "Tool spec 'name=[,params=]' (repeatable, max 8)") + + return cmd +} diff --git a/internal/cli/monit_agent_test.go b/internal/cli/monit_agent_test.go new file mode 100644 index 0000000..87dc2f7 --- /dev/null +++ b/internal/cli/monit_agent_test.go @@ -0,0 +1,300 @@ +package cli + +import ( + "context" + "encoding/json" + "strings" + "testing" + + flashduty "github.com/flashcatcloud/flashduty-sdk" +) + +// --- flag surface --------------------------------------------------------- + +func TestMonitAgentCatalogFlags(t *testing.T) { + cmd := newMonitAgentCatalogCmd() + for _, name := range []string{"target-kind", "target-locator"} { + if cmd.Flags().Lookup(name) == nil { + t.Errorf("flag --%s missing", name) + } + } +} + +func TestMonitAgentInvokeFlags(t *testing.T) { + cmd := newMonitAgentInvokeCmd() + for _, name := range []string{"target-kind", "target-locator", "tool-spec"} { + if cmd.Flags().Lookup(name) == nil { + t.Errorf("flag --%s missing", name) + } + } +} + +// --- shared mock plumbing ------------------------------------------------- + +type mockMonitAgent struct { + mockClient + + catalogInput *flashduty.MonitAgentCatalogInput + catalogOut *flashduty.MonitAgentCatalogOutput + catalogErr error + + invokeInput *flashduty.MonitAgentInvokeInput + invokeOut *flashduty.MonitAgentInvokeOutput + invokeErr error +} + +func (m *mockMonitAgent) MonitAgentCatalog(_ context.Context, input *flashduty.MonitAgentCatalogInput) (*flashduty.MonitAgentCatalogOutput, error) { + copied := *input + m.catalogInput = &copied + if m.catalogErr != nil { + return nil, m.catalogErr + } + if m.catalogOut != nil { + return m.catalogOut, nil + } + return &flashduty.MonitAgentCatalogOutput{}, nil +} + +func (m *mockMonitAgent) MonitAgentInvoke(_ context.Context, input *flashduty.MonitAgentInvokeInput) (*flashduty.MonitAgentInvokeOutput, error) { + copied := *input + copied.Tools = append([]flashduty.MonitAgentInvokeTool(nil), input.Tools...) + m.invokeInput = &copied + if m.invokeErr != nil { + return nil, m.invokeErr + } + if m.invokeOut != nil { + return m.invokeOut, nil + } + return &flashduty.MonitAgentInvokeOutput{}, nil +} + +// --- monit-agent catalog -------------------------------------------------- + +func TestMonitAgentCatalogHappyPath(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockMonitAgent{ + catalogOut: &flashduty.MonitAgentCatalogOutput{ + Tools: []flashduty.MonitAgentTool{ + {Name: "ps_top", Description: "Top processes by CPU"}, + }, + }, + } + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + _, err := execCommand( + "monit-agent", "catalog", + "--target-kind", "host", + "--target-locator", "10.0.1.5", + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mock.catalogInput == nil { + t.Fatal("expected MonitAgentCatalog to be called") + } + if mock.catalogInput.TargetKind != "host" || mock.catalogInput.TargetLocator != "10.0.1.5" { + t.Errorf("unexpected catalog input: %+v", mock.catalogInput) + } +} + +func TestMonitAgentCatalogOmitsKind(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockMonitAgent{} + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + _, err := execCommand( + "monit-agent", "catalog", + "--target-locator", "web-01", + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mock.catalogInput == nil { + t.Fatal("expected MonitAgentCatalog to be called") + } + if mock.catalogInput.TargetKind != "" { + t.Errorf("expected empty target-kind, got %q", mock.catalogInput.TargetKind) + } + if mock.catalogInput.TargetLocator != "web-01" { + t.Errorf("expected locator web-01, got %q", mock.catalogInput.TargetLocator) + } +} + +func TestMonitAgentCatalogRequiresLocator(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockMonitAgent{} + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + _, err := execCommand("monit-agent", "catalog", "--target-kind", "host") + if err == nil { + t.Fatal("expected required-flag error, got nil") + } + if !strings.Contains(err.Error(), "--target-locator") { + t.Errorf("expected error to mention --target-locator, got %q", err.Error()) + } + if mock.catalogInput != nil { + t.Errorf("MonitAgentCatalog should not have been called: %#v", mock.catalogInput) + } +} + +// --- monit-agent invoke --------------------------------------------------- + +func TestMonitAgentInvokeHappyPath(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockMonitAgent{} + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + _, err := execCommand( + "monit-agent", "invoke", + "--target-kind", "host", + "--target-locator", "10.0.1.5", + "--tool-spec", `name=ps_top,params={"limit":5}`, + "--tool-spec", "name=uptime", + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mock.invokeInput == nil { + t.Fatal("expected MonitAgentInvoke to be called") + } + got := mock.invokeInput + if got.TargetKind != "host" || got.TargetLocator != "10.0.1.5" { + t.Errorf("unexpected invoke target: %+v", got) + } + if len(got.Tools) != 2 { + t.Fatalf("expected 2 tools, got %d", len(got.Tools)) + } + if got.Tools[0].Tool != "ps_top" { + t.Errorf("expected first tool ps_top, got %q", got.Tools[0].Tool) + } + if string(got.Tools[0].Params) != `{"limit":5}` { + t.Errorf("expected ps_top params %q, got %q", `{"limit":5}`, string(got.Tools[0].Params)) + } + if got.Tools[1].Tool != "uptime" { + t.Errorf("expected second tool uptime, got %q", got.Tools[1].Tool) + } + // default params for a name-only spec must be valid JSON `{}`, so the + // server-side decoder accepts it. + if !json.Valid(got.Tools[1].Params) { + t.Errorf("uptime params not valid JSON: %q", string(got.Tools[1].Params)) + } +} + +func TestMonitAgentInvokeOmitsKind(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockMonitAgent{} + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + _, err := execCommand( + "monit-agent", "invoke", + "--target-locator", "10.0.1.5", + "--tool-spec", "name=uptime", + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mock.invokeInput == nil { + t.Fatal("expected MonitAgentInvoke to be called") + } + if mock.invokeInput.TargetKind != "" { + t.Errorf("expected empty target-kind, got %q", mock.invokeInput.TargetKind) + } +} + +func TestMonitAgentInvokeRequiresLocator(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockMonitAgent{} + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + _, err := execCommand( + "monit-agent", "invoke", + "--tool-spec", "name=ps_top", + ) + if err == nil { + t.Fatal("expected required-flag error, got nil") + } + if !strings.Contains(err.Error(), "--target-locator") { + t.Errorf("expected error to mention --target-locator, got %q", err.Error()) + } + if mock.invokeInput != nil { + t.Errorf("MonitAgentInvoke should not have been called: %#v", mock.invokeInput) + } +} + +func TestMonitAgentInvokeRequiresToolSpec(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockMonitAgent{} + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + _, err := execCommand( + "monit-agent", "invoke", + "--target-locator", "10.0.1.5", + ) + if err == nil { + t.Fatal("expected required-flag error, got nil") + } + if !strings.Contains(err.Error(), "--tool-spec") { + t.Errorf("expected error to mention --tool-spec, got %q", err.Error()) + } + if mock.invokeInput != nil { + t.Errorf("MonitAgentInvoke should not have been called: %#v", mock.invokeInput) + } +} + +func TestMonitAgentInvokeRejectsMoreThan8Specs(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockMonitAgent{} + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + args := []string{ + "monit-agent", "invoke", + "--target-locator", "10.0.1.5", + } + for i := 0; i < 9; i++ { + args = append(args, "--tool-spec", "name=t"+string(rune('0'+i))) + } + + _, err := execCommand(args...) + if err == nil { + t.Fatal("expected too-many-tools error, got nil") + } + if !strings.Contains(err.Error(), "up to 8") { + t.Errorf("expected error to mention 'up to 8', got %q", err.Error()) + } + if mock.invokeInput != nil { + t.Errorf("MonitAgentInvoke should not have been called: %#v", mock.invokeInput) + } +} + +func TestMonitAgentInvokeMalformedSpec(t *testing.T) { + cases := []struct { + name string + spec string + }{ + {"missing name=", "params={}"}, + {"missing equals", "no-equals-sign"}, + {"unknown key", "namez=foo,params={}"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockMonitAgent{} + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + _, err := execCommand( + "monit-agent", "invoke", + "--target-locator", "10.0.1.5", + "--tool-spec", tc.spec, + ) + if err == nil { + t.Fatal("expected parse error, got nil") + } + if !strings.Contains(err.Error(), "--tool-spec") { + t.Errorf("expected error to mention --tool-spec, got %q", err.Error()) + } + if mock.invokeInput != nil { + t.Errorf("MonitAgentInvoke should not have been called: %#v", mock.invokeInput) + } + }) + } +} diff --git a/internal/cli/monit_query.go b/internal/cli/monit_query.go new file mode 100644 index 0000000..e8da45a --- /dev/null +++ b/internal/cli/monit_query.go @@ -0,0 +1,133 @@ +package cli + +import ( + "fmt" + + flashduty "github.com/flashcatcloud/flashduty-sdk" + "github.com/spf13/cobra" + + "github.com/flashcatcloud/flashduty-cli/internal/timeutil" +) + +func newMonitQueryCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "monit-query", + Short: "Probe monit-backed datasources (prometheus|victorialogs|loki|mysql)", + } + cmd.AddCommand(newMonitQueryDiagnoseCmd()) + cmd.AddCommand(newMonitQueryRowsCmd()) + return cmd +} + +func newMonitQueryDiagnoseCmd() *cobra.Command { + var ( + dsType, dsName, timeStart, timeEnd, inputQuery, operation string + maxLogs, maxPatterns, timeoutSeconds int + ) + + cmd := &cobra.Command{ + Use: "diagnose", + Short: "Pre-clustered RCA findings (log_patterns or metric_trends)", + RunE: func(cmd *cobra.Command, args []string) error { + if dsType == "" || dsName == "" || inputQuery == "" { + return fmt.Errorf("--ds-type, --ds-name, --input-query are required") + } + startTime, err := timeutil.Parse(timeStart) + if err != nil { + return fmt.Errorf("invalid --time-start: %w", err) + } + endTime, err := timeutil.Parse(timeEnd) + if err != nil { + return fmt.Errorf("invalid --time-end: %w", err) + } + + return runCommand(cmd, args, func(ctx *RunContext) error { + input := &flashduty.MonitQueryDiagnoseInput{ + DsType: dsType, + DsName: dsName, + TimeStart: startTime, + TimeEnd: endTime, + Operation: operation, + Input: flashduty.MonitQueryDiagnoseQuery{Query: inputQuery}, + } + if maxLogs > 0 { + input.MaxLogsScanned = maxLogs + } + if maxPatterns > 0 { + input.MaxPatterns = maxPatterns + } + if timeoutSeconds > 0 { + input.TimeoutSeconds = timeoutSeconds + } + + result, err := ctx.Client.MonitQueryDiagnose(cmdContext(ctx.Cmd), input) + if err != nil { + return err + } + return ctx.Printer.Print(result, nil) + }) + }, + } + + cmd.Flags().StringVar(&dsType, "ds-type", "", "Datasource type: prometheus|victorialogs|loki|mysql (required)") + cmd.Flags().StringVar(&dsName, "ds-name", "", "Datasource name as configured (required)") + cmd.Flags().StringVar(&timeStart, "time-start", "15m", "Window start (relative '15m'/'1h', unix seconds, or 'now')") + cmd.Flags().StringVar(&timeEnd, "time-end", "now", "Window end (relative, unix seconds, or 'now'; span capped at 6h)") + cmd.Flags().StringVar(&inputQuery, "input-query", "", "Filter-only log query OR matrix PromQL (required)") + cmd.Flags().StringVar(&operation, "operation", "", "log_patterns or metric_trends (default inferred from ds-type)") + cmd.Flags().IntVar(&maxLogs, "max-logs", 0, "Max log lines scanned (default 10000, cap 50000)") + cmd.Flags().IntVar(&maxPatterns, "max-patterns", 0, "Max patterns returned (default 20, cap 50)") + cmd.Flags().IntVar(&timeoutSeconds, "timeout-seconds", 0, "Per-call timeout in seconds (default 25, cap 30)") + + return cmd +} + +func newMonitQueryRowsCmd() *cobra.Command { + var ( + dsType, dsName, expr string + argsKV []string + ) + + cmd := &cobra.Command{ + Use: "rows", + Short: "Raw datasource passthrough (returns values/rows as the datasource itself would)", + RunE: func(cmd *cobra.Command, args []string) error { + if dsType == "" || dsName == "" || expr == "" { + return fmt.Errorf("--ds-type, --ds-name, --expr are required") + } + argsMap, err := parseKVSlice(argsKV) + if err != nil { + return fmt.Errorf("invalid --args: %w", err) + } + + return runCommand(cmd, args, func(ctx *RunContext) error { + input := &flashduty.MonitQueryRowsInput{ + DsType: dsType, + DsName: dsName, + Expr: expr, + Args: argsMap, + } + result, err := ctx.Client.MonitQueryRows(cmdContext(ctx.Cmd), input) + if err != nil { + return err + } + // MonitQueryRowsOutput intentionally captures the entire response + // body as a RawMessage (data shape is datasource-specific). The + // struct itself marshals to `{}`, so write the raw bytes through. + if len(result.Data) == 0 { + _, err = fmt.Fprintln(ctx.Writer, "{}") + } else { + _, err = fmt.Fprintln(ctx.Writer, string(result.Data)) + } + return err + }) + }, + } + + cmd.Flags().StringVar(&dsType, "ds-type", "", "Datasource type (required)") + cmd.Flags().StringVar(&dsName, "ds-name", "", "Datasource name (required)") + cmd.Flags().StringVar(&expr, "expr", "", "Query expression (required)") + cmd.Flags().StringSliceVar(&argsKV, "args", nil, "Arg entries KEY=VALUE (repeatable; values must be strings per monit-query contract)") + + return cmd +} diff --git a/internal/cli/monit_query_test.go b/internal/cli/monit_query_test.go new file mode 100644 index 0000000..c6fc388 --- /dev/null +++ b/internal/cli/monit_query_test.go @@ -0,0 +1,288 @@ +package cli + +import ( + "context" + "strings" + "testing" + + flashduty "github.com/flashcatcloud/flashduty-sdk" +) + +func TestMonitQueryDiagnoseFlags(t *testing.T) { + cmd := newMonitQueryDiagnoseCmd() + for _, name := range []string{ + "ds-type", "ds-name", "time-start", "time-end", + "input-query", "operation", + "max-logs", "max-patterns", "timeout-seconds", + } { + if cmd.Flags().Lookup(name) == nil { + t.Errorf("flag --%s missing", name) + } + } +} + +func TestMonitQueryRowsFlags(t *testing.T) { + cmd := newMonitQueryRowsCmd() + for _, name := range []string{"ds-type", "ds-name", "expr", "args"} { + if cmd.Flags().Lookup(name) == nil { + t.Errorf("flag --%s missing", name) + } + } +} + +// --- shared mock plumbing ------------------------------------------------- + +type mockMonitQuery struct { + mockClient + + diagnoseInput *flashduty.MonitQueryDiagnoseInput + diagnoseOut *flashduty.MonitQueryDiagnoseOutput + diagnoseErr error + + rowsInput *flashduty.MonitQueryRowsInput + rowsOut *flashduty.MonitQueryRowsOutput + rowsErr error +} + +func (m *mockMonitQuery) MonitQueryDiagnose(_ context.Context, input *flashduty.MonitQueryDiagnoseInput) (*flashduty.MonitQueryDiagnoseOutput, error) { + copied := *input + m.diagnoseInput = &copied + if m.diagnoseErr != nil { + return nil, m.diagnoseErr + } + if m.diagnoseOut != nil { + return m.diagnoseOut, nil + } + return &flashduty.MonitQueryDiagnoseOutput{Operation: "log_patterns"}, nil +} + +func (m *mockMonitQuery) MonitQueryRows(_ context.Context, input *flashduty.MonitQueryRowsInput) (*flashduty.MonitQueryRowsOutput, error) { + copied := *input + m.rowsInput = &copied + if m.rowsErr != nil { + return nil, m.rowsErr + } + if m.rowsOut != nil { + return m.rowsOut, nil + } + return &flashduty.MonitQueryRowsOutput{}, nil +} + +// --- monit-query diagnose ------------------------------------------------- + +func TestMonitQueryDiagnoseHappyPath(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockMonitQuery{} + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + _, err := execCommand( + "monit-query", "diagnose", + "--ds-type", "victorialogs", + "--ds-name", "vl-prod", + "--input-query", `{app="api"}`, + "--operation", "log_patterns", + "--max-logs", "5000", + "--max-patterns", "10", + "--timeout-seconds", "20", + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mock.diagnoseInput == nil { + t.Fatal("expected MonitQueryDiagnose to be called") + } + got := mock.diagnoseInput + if got.DsType != "victorialogs" || got.DsName != "vl-prod" { + t.Errorf("unexpected ds fields: %+v", got) + } + if got.Input.Query != `{app="api"}` { + t.Errorf("expected input query %q, got %q", `{app="api"}`, got.Input.Query) + } + if got.Operation != "log_patterns" { + t.Errorf("expected operation log_patterns, got %q", got.Operation) + } + if got.MaxLogsScanned != 5000 || got.MaxPatterns != 10 || got.TimeoutSeconds != 20 { + t.Errorf("unexpected caps: logs=%d patterns=%d timeout=%d", + got.MaxLogsScanned, got.MaxPatterns, got.TimeoutSeconds) + } + if got.TimeStart == 0 || got.TimeEnd == 0 { + t.Errorf("expected non-zero default time range, got start=%d end=%d", + got.TimeStart, got.TimeEnd) + } +} + +func TestMonitQueryDiagnoseRequiredFlags(t *testing.T) { + cases := []struct { + name string + args []string + }{ + { + name: "missing ds-type", + args: []string{ + "monit-query", "diagnose", + "--ds-name", "vl-prod", + "--input-query", `{app="api"}`, + }, + }, + { + name: "missing ds-name", + args: []string{ + "monit-query", "diagnose", + "--ds-type", "victorialogs", + "--input-query", `{app="api"}`, + }, + }, + { + name: "missing input-query", + args: []string{ + "monit-query", "diagnose", + "--ds-type", "victorialogs", + "--ds-name", "vl-prod", + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockMonitQuery{} + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + _, err := execCommand(tc.args...) + if err == nil { + t.Fatal("expected required-flag error, got nil") + } + if !strings.Contains(err.Error(), "required") { + t.Errorf("expected error to mention 'required', got %q", err.Error()) + } + if mock.diagnoseInput != nil { + t.Errorf("MonitQueryDiagnose should not have been called: %#v", mock.diagnoseInput) + } + }) + } +} + +func TestMonitQueryDiagnoseInvalidTimeStart(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockMonitQuery{} + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + _, err := execCommand( + "monit-query", "diagnose", + "--ds-type", "victorialogs", + "--ds-name", "vl-prod", + "--input-query", `{app="api"}`, + "--time-start", "not-a-time", + ) + if err == nil { + t.Fatal("expected error for invalid --time-start, got nil") + } + if !strings.Contains(err.Error(), "--time-start") { + t.Errorf("expected error to mention --time-start, got %q", err.Error()) + } + if mock.diagnoseInput != nil { + t.Errorf("MonitQueryDiagnose should not have been called: %#v", mock.diagnoseInput) + } +} + +// --- monit-query rows ----------------------------------------------------- + +func TestMonitQueryRowsHappyPath(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockMonitQuery{} + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + _, err := execCommand( + "monit-query", "rows", + "--ds-type", "prometheus", + "--ds-name", "prom-prod", + "--expr", "up", + "--args", "step=15s", + "--args", "tenant=acme", + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mock.rowsInput == nil { + t.Fatal("expected MonitQueryRows to be called") + } + got := mock.rowsInput + if got.DsType != "prometheus" || got.DsName != "prom-prod" || got.Expr != "up" { + t.Errorf("unexpected rows input: %+v", got) + } + if got.Args["step"] != "15s" || got.Args["tenant"] != "acme" { + t.Errorf("expected args step=15s tenant=acme, got %#v", got.Args) + } +} + +func TestMonitQueryRowsRequiredFlags(t *testing.T) { + cases := []struct { + name string + args []string + }{ + { + name: "missing ds-type", + args: []string{ + "monit-query", "rows", + "--ds-name", "prom-prod", + "--expr", "up", + }, + }, + { + name: "missing ds-name", + args: []string{ + "monit-query", "rows", + "--ds-type", "prometheus", + "--expr", "up", + }, + }, + { + name: "missing expr", + args: []string{ + "monit-query", "rows", + "--ds-type", "prometheus", + "--ds-name", "prom-prod", + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockMonitQuery{} + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + _, err := execCommand(tc.args...) + if err == nil { + t.Fatal("expected required-flag error, got nil") + } + if !strings.Contains(err.Error(), "required") { + t.Errorf("expected error to mention 'required', got %q", err.Error()) + } + if mock.rowsInput != nil { + t.Errorf("MonitQueryRows should not have been called: %#v", mock.rowsInput) + } + }) + } +} + +func TestMonitQueryRowsInvalidArgs(t *testing.T) { + saveAndResetGlobals(t) + mock := &mockMonitQuery{} + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + _, err := execCommand( + "monit-query", "rows", + "--ds-type", "prometheus", + "--ds-name", "prom-prod", + "--expr", "up", + "--args", "no-equals-sign", + ) + if err == nil { + t.Fatal("expected error for malformed --args, got nil") + } + if !strings.Contains(err.Error(), "--args") { + t.Errorf("expected error to mention --args, got %q", err.Error()) + } + if mock.rowsInput != nil { + t.Errorf("MonitQueryRows should not have been called: %#v", mock.rowsInput) + } +} diff --git a/internal/cli/oncall.go b/internal/cli/oncall.go index 6721c0e..a21e0bd 100644 --- a/internal/cli/oncall.go +++ b/internal/cli/oncall.go @@ -227,7 +227,7 @@ func newOncallScheduleGetCmd() *cobra.Command { return err } - if ctx.JSON { + if ctx.Structured() { return ctx.Printer.Print(result.Schedule, nil) } diff --git a/internal/cli/output_format_test.go b/internal/cli/output_format_test.go new file mode 100644 index 0000000..c4d52f3 --- /dev/null +++ b/internal/cli/output_format_test.go @@ -0,0 +1,50 @@ +package cli + +import ( + "testing" + + "github.com/flashcatcloud/flashduty-cli/internal/output" +) + +// resolveOutputFormat maps --output-format / --json to a format, with +// --output-format winning, --json as the fallback alias, and an unknown +// value erroring so a typo fails fast instead of silently picking table. +func TestResolveOutputFormat(t *testing.T) { + cases := []struct { + name string + format string + json bool + want output.Format + wantErr bool + }{ + {"default is table", "", false, output.FormatTable, false}, + {"json bool alias", "", true, output.FormatJSON, false}, + {"explicit table", "table", false, output.FormatTable, false}, + {"explicit json", "json", false, output.FormatJSON, false}, + {"explicit toon", "toon", false, output.FormatTOON, false}, + {"toon wins over json bool", "toon", true, output.FormatTOON, false}, + {"case-insensitive", "TOON", false, output.FormatTOON, false}, + {"invalid errors", "yaml", false, output.FormatTable, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + origFormat, origJSON := flagOutputFormat, flagJSON + defer func() { flagOutputFormat, flagJSON = origFormat, origJSON }() + flagOutputFormat, flagJSON = tc.format, tc.json + + got, err := resolveOutputFormat() + if tc.wantErr { + if err == nil { + t.Fatalf("expected error for %q, got nil", tc.format) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Errorf("resolveOutputFormat(%q, json=%v) = %v, want %v", tc.format, tc.json, got, tc.want) + } + }) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index a191d9a..33e7280 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "os" + "strings" flashduty "github.com/flashcatcloud/flashduty-sdk" "github.com/spf13/cobra" @@ -98,16 +99,28 @@ type flashdutyClient interface { GetTeamInfo(ctx context.Context, input *flashduty.TeamGetInput) (*flashduty.TeamItem, error) UpsertTeam(ctx context.Context, input *flashduty.TeamUpsertInput) (*flashduty.TeamUpsertOutput, error) DeleteTeam(ctx context.Context, input *flashduty.TeamDeleteInput) error + + // === CLI Phase 1: MCP === + CreateMCPServer(ctx context.Context, input *flashduty.CreateMCPServerInput) (*flashduty.CreateMCPServerOutput, error) + + // === CLI Phase 2: monit-query === + MonitQueryDiagnose(ctx context.Context, input *flashduty.MonitQueryDiagnoseInput) (*flashduty.MonitQueryDiagnoseOutput, error) + MonitQueryRows(ctx context.Context, input *flashduty.MonitQueryRowsInput) (*flashduty.MonitQueryRowsOutput, error) + + // === CLI Phase 2: monit-agent === + MonitAgentCatalog(ctx context.Context, input *flashduty.MonitAgentCatalogInput) (*flashduty.MonitAgentCatalogOutput, error) + MonitAgentInvoke(ctx context.Context, input *flashduty.MonitAgentInvokeInput) (*flashduty.MonitAgentInvokeOutput, error) } // newClientFn creates a flashdutyClient. Override in tests to inject a mock. var newClientFn = defaultNewClient var ( - flagJSON bool - flagNoTrunc bool - flagAppKey string - flagBaseURL string + flagJSON bool + flagNoTrunc bool + flagAppKey string + flagBaseURL string + flagOutputFormat string ) var updateNotice *update.CheckResult @@ -118,12 +131,15 @@ var rootCmd = &cobra.Command{ Long: "Flashduty CLI - incident management from your terminal.\n\nGet started by running 'flashduty login' to authenticate.", SilenceUsage: true, SilenceErrors: true, - PersistentPreRun: func(cmd *cobra.Command, _ []string) { + PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { + if _, err := resolveOutputFormat(); err != nil { + return err + } if cmd.CommandPath() == "flashduty update" { - return + return nil } if !term.IsTerminal(int(os.Stderr.Fd())) { - return + return nil } updateNotice = update.StateHasUpdate(versionStr) if update.ShouldCheck(versionStr) { @@ -131,6 +147,7 @@ var rootCmd = &cobra.Command{ _, _ = update.CheckForUpdate(versionStr) }() } + return nil }, PersistentPostRun: func(_ *cobra.Command, _ []string) { if updateNotice == nil { @@ -143,7 +160,8 @@ var rootCmd = &cobra.Command{ } func init() { - rootCmd.PersistentFlags().BoolVar(&flagJSON, "json", false, "Output as JSON") + rootCmd.PersistentFlags().BoolVar(&flagJSON, "json", false, "Output as JSON (alias for --output-format json)") + rootCmd.PersistentFlags().StringVar(&flagOutputFormat, "output-format", "", "Output format: table (default), json, or toon (compact, fewer tokens)") rootCmd.PersistentFlags().BoolVar(&flagNoTrunc, "no-trunc", false, "Do not truncate table output") rootCmd.PersistentFlags().StringVar(&flagAppKey, "app-key", "", "Override app key") rootCmd.PersistentFlags().StringVar(&flagBaseURL, "base-url", "", "Override base URL") @@ -176,6 +194,13 @@ func init() { rootCmd.AddCommand(newWhoamiCmd()) rootCmd.AddCommand(newUpdateCmd()) + + // CLI Phase 1 + rootCmd.AddCommand(newMCPCmd()) + + // CLI Phase 2 + rootCmd.AddCommand(newMonitQueryCmd()) + rootCmd.AddCommand(newMonitAgentCmd()) } // Execute runs the root command. @@ -231,12 +256,52 @@ func loadResolvedConfig() (*config.Config, error) { return cfg, nil } +// resolveOutputFormat maps the global flags to an output.Format. --output-format +// wins when set; otherwise --json selects JSON; otherwise the human table view. +// An unrecognized --output-format value is an error so a typo fails fast rather +// than silently falling back. +func resolveOutputFormat() (output.Format, error) { + switch strings.ToLower(strings.TrimSpace(flagOutputFormat)) { + case "table": + return output.FormatTable, nil + case "json": + return output.FormatJSON, nil + case "toon": + return output.FormatTOON, nil + case "": + if flagJSON { + return output.FormatJSON, nil + } + return output.FormatTable, nil + default: + return output.FormatTable, fmt.Errorf("invalid --output-format %q (want table, json, or toon)", flagOutputFormat) + } +} + +// currentOutputFormat returns the resolved format, defaulting to table on the +// error path (the error is surfaced once in PersistentPreRunE, so call sites +// that already passed validation can ignore it). +func currentOutputFormat() output.Format { + f, _ := resolveOutputFormat() + return f +} + +// marshalStructured serializes v for machine-readable output: indented JSON for +// FormatJSON (byte-compatible with the legacy --json path) and TOON via the SDK +// for FormatTOON. +func marshalStructured(v any) ([]byte, error) { + if currentOutputFormat() == output.FormatTOON { + return flashduty.Marshal(v, flashduty.OutputFormatTOON) + } + return json.MarshalIndent(v, "", " ") +} + // newPrinter creates a Printer based on global flags. func newPrinter(w io.Writer) output.Printer { if w == nil { w = os.Stdout } - return output.NewPrinter(flagJSON, flagNoTrunc, w) + return output.NewPrinter(currentOutputFormat(), flagNoTrunc, w) } // cmdContext returns the command's context. @@ -244,13 +309,14 @@ func cmdContext(cmd *cobra.Command) context.Context { return cmd.Context() } -// writeResult prints a message as plain text or JSON depending on the --json flag. +// writeResult prints a success message as plain text, or as a structured +// {"message": ...} object in JSON/TOON mode. func writeResult(w io.Writer, message string) { if w == nil { w = os.Stdout } - if flagJSON { - out, _ := json.MarshalIndent(map[string]string{"message": message}, "", " ") + if currentOutputFormat().Structured() { + out, _ := marshalStructured(map[string]string{"message": message}) _, _ = fmt.Fprintln(w, string(out)) } else { _, _ = fmt.Fprintln(w, message) diff --git a/internal/cli/status_page_migrate.go b/internal/cli/status_page_migrate.go index d921108..5e5b4e4 100644 --- a/internal/cli/status_page_migrate.go +++ b/internal/cli/status_page_migrate.go @@ -136,7 +136,7 @@ func newStatusPageMigrateCancelCmd() *cobra.Command { return err } - if ctx.JSON { + if ctx.Structured() { statusCmd := "flashduty statuspage migrate status --job-id " + jobID return ctx.Printer.Print(map[string]any{ "job_id": jobID, @@ -176,7 +176,7 @@ func validateMigrationSource(source string) error { } func printMigrationStart(ctx *RunContext, migrationType, source, sourcePageID string, targetPageID int64, result *flashduty.StartStatusPageMigrationOutput) error { - if ctx.JSON { + if ctx.Structured() { payload := map[string]any{ "type": migrationType, "source": source, @@ -219,7 +219,7 @@ func printMigrationStart(ctx *RunContext, migrationType, source, sourcePageID st } func printMigrationStatus(ctx *RunContext, job *flashduty.StatusPageMigrationJob) error { - if ctx.JSON { + if ctx.Structured() { return ctx.Printer.Print(job, nil) } diff --git a/internal/cli/team.go b/internal/cli/team.go index d15bb21..d07fa0a 100644 --- a/internal/cli/team.go +++ b/internal/cli/team.go @@ -114,7 +114,7 @@ Examples: return err } - if ctx.JSON { + if ctx.Structured() { return ctx.Printer.Print(team, nil) } diff --git a/internal/cli/template.go b/internal/cli/template.go index 9c40ad7..0de0b60 100644 --- a/internal/cli/template.go +++ b/internal/cli/template.go @@ -38,7 +38,7 @@ func newTemplateGetPresetCmd() *cobra.Command { return err } - if ctx.JSON { + if ctx.Structured() { return ctx.Printer.Print(result, nil) } _, _ = fmt.Fprintln(ctx.Writer, result.TemplateCode) @@ -75,7 +75,7 @@ func newTemplateValidateCmd() *cobra.Command { return err } - if ctx.JSON { + if ctx.Structured() { return ctx.Printer.Print(result, nil) } diff --git a/internal/cli/whoami.go b/internal/cli/whoami.go index ae1f7bb..c652035 100644 --- a/internal/cli/whoami.go +++ b/internal/cli/whoami.go @@ -1,7 +1,6 @@ package cli import ( - "encoding/json" "fmt" "github.com/spf13/cobra" @@ -23,8 +22,11 @@ func newWhoamiCmd() *cobra.Command { } w := cmd.OutOrStdout() - if flagJSON { - out, _ := json.MarshalIndent(id, "", " ") + if currentOutputFormat().Structured() { + out, err := marshalStructured(id) + if err != nil { + return err + } _, _ = fmt.Fprintln(w, string(out)) return nil } diff --git a/internal/output/format.go b/internal/output/format.go new file mode 100644 index 0000000..75b4732 --- /dev/null +++ b/internal/output/format.go @@ -0,0 +1,20 @@ +package output + +// Format selects how command output is serialized. +type Format int + +const ( + // FormatTable renders human-readable aligned columns (the default). + FormatTable Format = iota + // FormatJSON renders pretty-printed JSON. + FormatJSON + // FormatTOON renders Token-Oriented Object Notation — a compact + // serialization that drops the per-row repeated keys JSON emits for + // uniform arrays, materially reducing token count for list output. + FormatTOON +) + +// Structured reports whether the format is a machine-readable dump (JSON or +// TOON) rather than the human table view. Table footers, detail views, and +// interactive prompts are suppressed when the output is structured. +func (f Format) Structured() bool { return f == FormatJSON || f == FormatTOON } diff --git a/internal/output/printer.go b/internal/output/printer.go index 4394da9..991ea44 100644 --- a/internal/output/printer.go +++ b/internal/output/printer.go @@ -17,13 +17,18 @@ type Printer interface { Print(data any, columns []Column) error } -// NewPrinter returns a table or JSON printer based on mode flags. -func NewPrinter(jsonMode bool, noTrunc bool, w io.Writer) Printer { +// NewPrinter returns the printer for the requested output format. noTrunc only +// affects the table printer; structured formats (JSON/TOON) never truncate. +func NewPrinter(format Format, noTrunc bool, w io.Writer) Printer { if w == nil { w = os.Stdout } - if jsonMode { + switch format { + case FormatJSON: return &JSONPrinter{w: w} + case FormatTOON: + return &TOONPrinter{w: w} + default: + return &TablePrinter{w: w, noTrunc: noTrunc} } - return &TablePrinter{w: w, noTrunc: noTrunc} } diff --git a/internal/output/printer_test.go b/internal/output/printer_test.go index 7b67dfb..74f8202 100644 --- a/internal/output/printer_test.go +++ b/internal/output/printer_test.go @@ -21,7 +21,7 @@ func testColumns() []Column { // Test 43: NewPrinter with jsonMode=true returns a JSONPrinter and produces valid JSON output. func TestNewPrinter_JSONMode(t *testing.T) { var buf bytes.Buffer - p := NewPrinter(true, false, &buf) + p := NewPrinter(FormatJSON, false, &buf) // Verify the concrete type is *JSONPrinter. if _, ok := p.(*JSONPrinter); !ok { @@ -51,7 +51,7 @@ func TestNewPrinter_JSONMode(t *testing.T) { // Test 44: NewPrinter with jsonMode=false returns a TablePrinter and produces tab-separated header output. func TestNewPrinter_TableMode(t *testing.T) { var buf bytes.Buffer - p := NewPrinter(false, false, &buf) + p := NewPrinter(FormatTable, false, &buf) // Verify the concrete type is *TablePrinter. if _, ok := p.(*TablePrinter); !ok { @@ -91,13 +91,13 @@ func TestNewPrinter_NilWriterDefaults(t *testing.T) { } }() - p := NewPrinter(false, false, nil) + p := NewPrinter(FormatTable, false, nil) if p == nil { t.Fatal("expected non-nil Printer, got nil") } // Also verify the JSON path with nil writer. - pJSON := NewPrinter(true, false, nil) + pJSON := NewPrinter(FormatJSON, false, nil) if pJSON == nil { t.Fatal("expected non-nil JSON Printer, got nil") } @@ -107,7 +107,7 @@ func TestNewPrinter_NilWriterDefaults(t *testing.T) { // (noTrunc is irrelevant for JSON mode). func TestNewPrinter_NoTruncIrrelevantForJSON(t *testing.T) { var buf bytes.Buffer - p := NewPrinter(true, true, &buf) + p := NewPrinter(FormatJSON, true, &buf) // Verify it is still a JSONPrinter regardless of noTrunc. if _, ok := p.(*JSONPrinter); !ok { diff --git a/internal/output/toon.go b/internal/output/toon.go new file mode 100644 index 0000000..dd129f7 --- /dev/null +++ b/internal/output/toon.go @@ -0,0 +1,25 @@ +package output + +import ( + "fmt" + "io" + + sdk "github.com/flashcatcloud/flashduty-sdk" +) + +// TOONPrinter prints data as TOON (Token-Oriented Object Notation). It routes +// through sdk.Marshal so the encoding stays identical to the Flashduty MCP +// server's `--output-format toon` path — one source of truth for how Flashduty +// serializes TOON. +type TOONPrinter struct { + w io.Writer +} + +func (p *TOONPrinter) Print(data any, _ []Column) error { + out, err := sdk.Marshal(data, sdk.OutputFormatTOON) + if err != nil { + return fmt.Errorf("failed to marshal TOON: %w", err) + } + _, err = fmt.Fprintln(p.w, string(out)) + return err +} diff --git a/internal/output/toon_test.go b/internal/output/toon_test.go new file mode 100644 index 0000000..ac04c31 --- /dev/null +++ b/internal/output/toon_test.go @@ -0,0 +1,34 @@ +package output + +import ( + "bytes" + "strings" + "testing" +) + +// NewPrinter(FormatTOON) returns a *TOONPrinter, and its output is compact — +// for a uniform array it must NOT repeat the field keys on every row the way +// JSON does. That key-deduplication is the whole point of TOON. +func TestNewPrinter_TOONMode(t *testing.T) { + var buf bytes.Buffer + p := NewPrinter(FormatTOON, false, &buf) + + if _, ok := p.(*TOONPrinter); !ok { + t.Fatalf("expected *TOONPrinter, got %T", p) + } + + data := []testItem{{Name: "alert-1"}, {Name: "alert-2"}} + if err := p.Print(data, testColumns()); err != nil { + t.Fatalf("Print returned error: %v", err) + } + + out := buf.String() + if !strings.Contains(out, "alert-1") || !strings.Contains(out, "alert-2") { + t.Errorf("TOON output missing data values:\n%s", out) + } + // TOON encodes a uniform array as a header + rows, so the field name + // appears far fewer times than the row count. JSON would repeat it per row. + if n := strings.Count(out, "name"); n >= len(data) { + t.Errorf("TOON output repeats key %d times for %d rows; expected key dedup:\n%s", n, len(data), out) + } +} diff --git a/internal/update/check.go b/internal/update/check.go index f815461..ba207e2 100644 --- a/internal/update/check.go +++ b/internal/update/check.go @@ -15,13 +15,13 @@ import ( ) const ( - repoOwner = "flashcatcloud" - repoName = "flashduty-cli" - checkInterval = 24 * time.Hour - httpTimeout = 5 * time.Second - stateFileName = "state.yaml" - installShURL = "https://raw.githubusercontent.com/" + repoOwner + "/" + repoName + "/main/install.sh" - installPs1URL = "https://raw.githubusercontent.com/" + repoOwner + "/" + repoName + "/main/install.ps1" + repoOwner = "flashcatcloud" + repoName = "flashduty-cli" + checkInterval = 24 * time.Hour + httpTimeout = 5 * time.Second + stateFileName = "state.yaml" + installShURL = "https://raw.githubusercontent.com/" + repoOwner + "/" + repoName + "/main/install.sh" + installPs1URL = "https://raw.githubusercontent.com/" + repoOwner + "/" + repoName + "/main/install.ps1" maxResponseBytes = 1 << 20 // 1MB )