Skip to content

Commit 39d6bfc

Browse files
authored
feat(cli): phase-1 gaps — TOON output, mcp create, monit-query/agent, plural --channel (#11)
- Add `--output-format toon` (token-efficient; `--json` byte-identical, kept as alias) via flashduty-sdk Marshal/toon-go. - Add `mcp create` for provisioning a Flashduty MCP channel. - Add `monit-query` / `monit-agent` subcommands. - `change list` accepts plural `--channel`. - Re-pin flashduty-sdk to the merged main (v0.9.1-0.20260528073358-9821a7ff07c9). Pairs with fc-safari #74 (bash-guard auth + flashduty skill) and flashduty-runner #50 (bundled CLI + PATH precedence).
1 parent c43f31e commit 39d6bfc

31 files changed

Lines changed: 1403 additions & 68 deletions

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ module github.com/flashcatcloud/flashduty-cli
33
go 1.25.1
44

55
require (
6-
github.com/flashcatcloud/flashduty-sdk v0.9.0
6+
github.com/flashcatcloud/flashduty-sdk v0.9.1-0.20260528073358-9821a7ff07c9
77
github.com/mattn/go-runewidth v0.0.23
88
github.com/spf13/cobra v1.10.2
99
github.com/spf13/pflag v1.0.9

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
22
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
33
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
4-
github.com/flashcatcloud/flashduty-sdk v0.9.0 h1:gEBt9ZJ8HbDc22U1V4cWPitxlPxfztqKIe2x6TyRqJw=
5-
github.com/flashcatcloud/flashduty-sdk v0.9.0/go.mod h1:dG4eJfdZaj4jNBMwEexbfK/3PmcIMhNeJ88L/DcZzUY=
4+
github.com/flashcatcloud/flashduty-sdk v0.9.1-0.20260528073358-9821a7ff07c9 h1:xNoqIR4zOHcX8TbLpn/ENaK/G6ZwpPyOeVTuqbE1uoc=
5+
github.com/flashcatcloud/flashduty-sdk v0.9.1-0.20260528073358-9821a7ff07c9/go.mod h1:dG4eJfdZaj4jNBMwEexbfK/3PmcIMhNeJ88L/DcZzUY=
66
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
77
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
88
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=

internal/cli/alert.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ func newAlertGetCmd() *cobra.Command {
123123
return err
124124
}
125125

126-
if ctx.JSON {
126+
if ctx.Structured() {
127127
return ctx.Printer.Print(result.Alert, nil)
128128
}
129129

internal/cli/args.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,10 @@ func requireExactlyOneFlag(cmd *cobra.Command, flagNames ...string) error {
3939
}
4040

4141
// confirmAction prompts the user for confirmation in interactive terminals.
42-
// Returns true if the user confirms, or if running in non-interactive / JSON / --force mode.
42+
// Returns true if the user confirms, or if running in non-interactive /
43+
// structured-output (JSON/TOON) / --force mode.
4344
func confirmAction(cmd *cobra.Command, message string) bool {
44-
if flagJSON {
45+
if currentOutputFormat().Structured() {
4546
return true
4647
}
4748
force, _ := cmd.Flags().GetBool("force")

internal/cli/change.go

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ func newChangeCmd() *cobra.Command {
2121
}
2222

2323
func newChangeListCmd() *cobra.Command {
24-
var channelID int64
24+
var channel string
2525
var since, until string
2626
var limit, page int
2727

@@ -39,13 +39,22 @@ func newChangeListCmd() *cobra.Command {
3939
return fmt.Errorf("invalid --until: %w", err)
4040
}
4141

42-
result, err := ctx.Client.ListChanges(cmdContext(ctx.Cmd), &flashduty.ListChangesInput{
43-
ChannelID: channelID,
42+
input := &flashduty.ListChangesInput{
4443
StartTime: startTime,
4544
EndTime: endTime,
4645
Limit: limit,
4746
Page: page,
48-
})
47+
}
48+
49+
if channel != "" {
50+
channelIDs, err := parseIntSlice(channel)
51+
if err != nil {
52+
return fmt.Errorf("invalid --channel: %w", err)
53+
}
54+
input.ChannelIDs = channelIDs
55+
}
56+
57+
result, err := ctx.Client.ListChanges(cmdContext(ctx.Cmd), input)
4958
if err != nil {
5059
return err
5160
}
@@ -63,7 +72,7 @@ func newChangeListCmd() *cobra.Command {
6372
},
6473
}
6574

66-
cmd.Flags().Int64Var(&channelID, "channel", 0, "Filter by channel ID")
75+
cmd.Flags().StringVar(&channel, "channel", "", "Comma-separated channel IDs")
6776
cmd.Flags().StringVar(&since, "since", "24h", "Start time")
6877
cmd.Flags().StringVar(&until, "until", "now", "End time")
6978
cmd.Flags().IntVar(&limit, "limit", 20, "Max results")

internal/cli/change_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package cli
2+
3+
import (
4+
"testing"
5+
)
6+
7+
// TestChangeListChannelFlag verifies that --channel is a string flag (comma-separated IDs),
8+
// not a singular int64 flag. Mirrors the alert list --channel pattern.
9+
func TestChangeListChannelFlag(t *testing.T) {
10+
cmd := newChangeListCmd()
11+
flags := cmd.Flags()
12+
13+
f := flags.Lookup("channel")
14+
if f == nil {
15+
t.Fatal("flag --channel not registered")
16+
}
17+
18+
// Must be a string flag (Value.Type() == "string"), not int64.
19+
if got := f.Value.Type(); got != "string" {
20+
t.Errorf("--channel flag type = %q, want %q", got, "string")
21+
}
22+
23+
// Default must be empty string (not "0").
24+
if got := f.DefValue; got != "" {
25+
t.Errorf("--channel default = %q, want %q", got, "")
26+
}
27+
}
28+
29+
// TestChangeListChannelParsing verifies that a comma-separated --channel value
30+
// is correctly parsed to []int64 via parseIntSlice — the same helper used by
31+
// alert list. Full comma-split semantics are covered by TestParseIntSlice in
32+
// helpers_test.go; this test only confirms the wiring is correct.
33+
func TestChangeListChannelParsing(t *testing.T) {
34+
// parseIntSlice is the shared helper; spot-check the three-value case.
35+
got, err := parseIntSlice("100,200,300")
36+
if err != nil {
37+
t.Fatalf("parseIntSlice(\"100,200,300\"): unexpected error: %v", err)
38+
}
39+
want := []int64{100, 200, 300}
40+
if len(got) != len(want) {
41+
t.Fatalf("length mismatch: got %d, want %d", len(got), len(want))
42+
}
43+
for i := range want {
44+
if got[i] != want[i] {
45+
t.Errorf("index %d: got %d, want %d", i, got[i], want[i])
46+
}
47+
}
48+
}

internal/cli/command.go

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package cli
22

33
import (
4-
"encoding/json"
54
"fmt"
65
"io"
76

@@ -18,9 +17,14 @@ type RunContext struct {
1817
Args []string
1918
Writer io.Writer
2019
Printer output.Printer
21-
JSON bool
20+
Format output.Format
2221
}
2322

23+
// Structured reports whether output should be a machine-readable dump (JSON or
24+
// TOON) rather than the human table/detail view. Command handlers branch on
25+
// this to suppress detail views, footers, and interactive prompts.
26+
func (ctx *RunContext) Structured() bool { return ctx.Format.Structured() }
27+
2428
// runCommand creates a client and RunContext, then calls fn.
2529
// It centralises setup that every API-backed command repeats.
2630
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
3438
Args: args,
3539
Writer: cmd.OutOrStdout(),
3640
Printer: newPrinter(cmd.OutOrStdout()),
37-
JSON: flagJSON,
41+
Format: currentOutputFormat(),
3842
}
3943
return fn(ctx)
4044
}
@@ -44,7 +48,7 @@ func (ctx *RunContext) PrintList(items any, cols []output.Column, count, page, t
4448
if err := ctx.Printer.Print(items, cols); err != nil {
4549
return err
4650
}
47-
if !ctx.JSON {
51+
if !ctx.Structured() {
4852
_, _ = fmt.Fprintf(ctx.Writer, "Showing %d results (page %d, total %d).\n", count, page, total)
4953
}
5054
return nil
@@ -55,7 +59,7 @@ func (ctx *RunContext) PrintTotal(items any, cols []output.Column, total int) er
5559
if err := ctx.Printer.Print(items, cols); err != nil {
5660
return err
5761
}
58-
if !ctx.JSON {
62+
if !ctx.Structured() {
5963
_, _ = fmt.Fprintf(ctx.Writer, "Total: %d\n", total)
6064
}
6165
return nil
@@ -66,17 +70,18 @@ func (ctx *RunContext) WriteResult(message string) {
6670
writeResult(ctx.Writer, message)
6771
}
6872

69-
// WriteResultJSON outputs structured data as JSON in --json mode,
70-
// or a human-readable message in table mode.
73+
// WriteResultJSON outputs structured data in JSON or TOON mode, or a
74+
// human-readable message in table mode. JSON stays indented (byte-compatible
75+
// with the legacy --json path); TOON routes through the SDK marshaller.
7176
func (ctx *RunContext) WriteResultJSON(data any, humanMessage string) error {
72-
if ctx.JSON {
73-
out, err := json.MarshalIndent(data, "", " ")
74-
if err != nil {
75-
return fmt.Errorf("failed to marshal JSON: %w", err)
76-
}
77-
_, _ = fmt.Fprintln(ctx.Writer, string(out))
77+
if !ctx.Structured() {
78+
_, _ = fmt.Fprintln(ctx.Writer, humanMessage)
7879
return nil
7980
}
80-
_, _ = fmt.Fprintln(ctx.Writer, humanMessage)
81+
out, err := marshalStructured(data)
82+
if err != nil {
83+
return fmt.Errorf("failed to marshal output: %w", err)
84+
}
85+
_, _ = fmt.Fprintln(ctx.Writer, string(out))
8186
return nil
8287
}

internal/cli/command_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,28 @@ func (m *mockClient) DeleteTeam(context.Context, *flashduty.TeamDeleteInput) err
282282
return fmt.Errorf("mockClient: DeleteTeam not implemented")
283283
}
284284

285+
func (m *mockClient) CreateMCPServer(context.Context, *flashduty.CreateMCPServerInput) (*flashduty.CreateMCPServerOutput, error) {
286+
return nil, fmt.Errorf("mockClient: CreateMCPServer not implemented")
287+
}
288+
289+
// CLI Phase 2: monit-query
290+
func (m *mockClient) MonitQueryDiagnose(context.Context, *flashduty.MonitQueryDiagnoseInput) (*flashduty.MonitQueryDiagnoseOutput, error) {
291+
return nil, fmt.Errorf("mockClient: MonitQueryDiagnose not implemented")
292+
}
293+
294+
func (m *mockClient) MonitQueryRows(context.Context, *flashduty.MonitQueryRowsInput) (*flashduty.MonitQueryRowsOutput, error) {
295+
return nil, fmt.Errorf("mockClient: MonitQueryRows not implemented")
296+
}
297+
298+
// CLI Phase 2: monit-agent
299+
func (m *mockClient) MonitAgentCatalog(context.Context, *flashduty.MonitAgentCatalogInput) (*flashduty.MonitAgentCatalogOutput, error) {
300+
return nil, fmt.Errorf("mockClient: MonitAgentCatalog not implemented")
301+
}
302+
303+
func (m *mockClient) MonitAgentInvoke(context.Context, *flashduty.MonitAgentInvokeInput) (*flashduty.MonitAgentInvokeOutput, error) {
304+
return nil, fmt.Errorf("mockClient: MonitAgentInvoke not implemented")
305+
}
306+
285307
// saveAndResetGlobals saves the current state of all global vars that commands
286308
// mutate, resets them to safe defaults, and returns a restore function for
287309
// t.Cleanup.
@@ -352,6 +374,16 @@ func resetFlagSet(flags *pflag.FlagSet) {
352374
case "bool", "int", "int64", "string":
353375
_ = flag.Value.Set(flag.DefValue)
354376
flag.Changed = false
377+
case "stringSlice", "stringArray":
378+
// Slice-valued flags accumulate across Parse() calls; clear them
379+
// explicitly so a later test isn't observing the previous test's
380+
// repeated --flag entries. pflag's SliceValue / Append interfaces
381+
// don't expose a "reset to default" — Set("") would append an
382+
// empty entry, so we use Replace([]) to truly empty the slice.
383+
if sv, ok := flag.Value.(pflag.SliceValue); ok {
384+
_ = sv.Replace([]string{})
385+
flag.Changed = false
386+
}
355387
}
356388
})
357389
}

internal/cli/helpers.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package cli
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"strings"
7+
8+
flashduty "github.com/flashcatcloud/flashduty-sdk"
9+
)
10+
11+
// parseKVSlice converts a slice of "KEY=VALUE" entries into a map.
12+
// Returns nil (not an error) for an empty input so callers can pass nil
13+
// maps through to the SDK without triggering omitempty issues.
14+
func parseKVSlice(entries []string) (map[string]string, error) {
15+
if len(entries) == 0 {
16+
return nil, nil
17+
}
18+
out := make(map[string]string, len(entries))
19+
for _, e := range entries {
20+
i := strings.IndexByte(e, '=')
21+
if i < 0 {
22+
return nil, fmt.Errorf("missing '=': %q", e)
23+
}
24+
out[e[:i]] = e[i+1:]
25+
}
26+
return out, nil
27+
}
28+
29+
// parseToolSpecs converts a slice of "name=<tool>[,params=<json>]" specs into
30+
// MonitAgentInvokeTool entries. The `name` key is required; `params` is
31+
// optional and defaults to `{}` so the server-side decoder accepts it. Splits
32+
// each spec on ',' first then on the first '=', mirroring parseKVSlice — that
33+
// means params JSON containing commas isn't supported; specs with complex
34+
// params must keep their objects single-keyed.
35+
func parseToolSpecs(specs []string) ([]flashduty.MonitAgentInvokeTool, error) {
36+
out := make([]flashduty.MonitAgentInvokeTool, 0, len(specs))
37+
for _, s := range specs {
38+
var name string
39+
params := json.RawMessage("{}")
40+
for _, kv := range strings.Split(s, ",") {
41+
i := strings.IndexByte(kv, '=')
42+
if i < 0 {
43+
return nil, fmt.Errorf("missing '=' in %q", kv)
44+
}
45+
k, v := kv[:i], kv[i+1:]
46+
switch k {
47+
case "name":
48+
name = v
49+
case "params":
50+
params = json.RawMessage(v)
51+
default:
52+
return nil, fmt.Errorf("unknown key %q in tool-spec", k)
53+
}
54+
}
55+
if name == "" {
56+
return nil, fmt.Errorf("missing name= in spec %q", s)
57+
}
58+
out = append(out, flashduty.MonitAgentInvokeTool{Tool: name, Params: params})
59+
}
60+
return out, nil
61+
}

internal/cli/helpers_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cli
22

33
import (
4+
"reflect"
45
"strings"
56
"testing"
67
)
@@ -125,3 +126,40 @@ func TestOrDash(t *testing.T) {
125126
func TestMemberPersonInfosDisplay(t *testing.T) {
126127
t.Skip("requires injection seam for fake client (Phase 3)")
127128
}
129+
130+
func TestParseKVSlice(t *testing.T) {
131+
cases := []struct {
132+
name string
133+
input []string
134+
want map[string]string
135+
wantErr bool
136+
}{
137+
{"nil input", nil, nil, false},
138+
{"empty input", []string{}, nil, false},
139+
{"single pair", []string{"K=V"}, map[string]string{"K": "V"}, false},
140+
{"multiple pairs", []string{"A=1", "B=2"}, map[string]string{"A": "1", "B": "2"}, false},
141+
// Value contains additional '=' signs — only the first splits key from value.
142+
{"value contains equals", []string{"K=a=b=c"}, map[string]string{"K": "a=b=c"}, false},
143+
{"empty value", []string{"K="}, map[string]string{"K": ""}, false},
144+
// Empty-key is the current behaviour when the entry starts with '='; documented here.
145+
{"empty key", []string{"=V"}, map[string]string{"": "V"}, false},
146+
{"missing equals", []string{"NOEQ"}, nil, true},
147+
}
148+
for _, tc := range cases {
149+
t.Run(tc.name, func(t *testing.T) {
150+
got, err := parseKVSlice(tc.input)
151+
if tc.wantErr {
152+
if err == nil {
153+
t.Fatal("expected error, got nil")
154+
}
155+
return
156+
}
157+
if err != nil {
158+
t.Fatalf("unexpected error: %v", err)
159+
}
160+
if !reflect.DeepEqual(got, tc.want) {
161+
t.Errorf("got %v, want %v", got, tc.want)
162+
}
163+
})
164+
}
165+
}

0 commit comments

Comments
 (0)