Skip to content
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/alert.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ func newAlertGetCmd() *cobra.Command {
return err
}

if ctx.JSON {
if ctx.Structured() {
return ctx.Printer.Print(result.Alert, nil)
}

Expand Down
5 changes: 3 additions & 2 deletions internal/cli/args.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
19 changes: 14 additions & 5 deletions internal/cli/change.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
}
Expand All @@ -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")
Expand Down
48 changes: 48 additions & 0 deletions internal/cli/change_test.go
Original file line number Diff line number Diff line change
@@ -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])
}
}
}
33 changes: 19 additions & 14 deletions internal/cli/command.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package cli

import (
"encoding/json"
"fmt"
"io"

Expand All @@ -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 {
Expand All @@ -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)
}
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
}
32 changes: 32 additions & 0 deletions internal/cli/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
}
})
}
Expand Down
61 changes: 61 additions & 0 deletions internal/cli/helpers.go
Original file line number Diff line number Diff line change
@@ -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=<tool>[,params=<json>]" 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
}
38 changes: 38 additions & 0 deletions internal/cli/helpers_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cli

import (
"reflect"
"strings"
"testing"
)
Expand Down Expand Up @@ -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)
}
})
}
}
6 changes: 3 additions & 3 deletions internal/cli/incident.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ func newIncidentGetCmd() *cobra.Command {
return err
}

if ctx.JSON {
if ctx.Structured() {
return ctx.Printer.Print(result.Incidents, nil)
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -1194,7 +1194,7 @@ func newIncidentDetailCmd() *cobra.Command {
return err
}

if ctx.JSON {
if ctx.Structured() {
return ctx.Printer.Print(result.Incident, nil)
}

Expand Down
Loading
Loading