Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.20260527160039-3203385df5ad
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.20260527160039-3203385df5ad h1:TreTSjEIGnp2byx4kGj3BN7RT+6ev8w2PCxVhTWbpVY=
github.com/flashcatcloud/flashduty-sdk v0.9.1-0.20260527160039-3203385df5ad/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
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])
}
}
}
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)
}
})
}
}
87 changes: 87 additions & 0 deletions internal/cli/mcp.go
Original file line number Diff line number Diff line change
@@ -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
}
35 changes: 35 additions & 0 deletions internal/cli/mcp_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
Loading
Loading