diff --git a/cmd/agents.go b/cmd/agents.go index e1cb6ae..51584ab 100644 --- a/cmd/agents.go +++ b/cmd/agents.go @@ -42,11 +42,11 @@ var agentsListCmd = &cobra.Command{ return err } if cfg.OutputFormat != "table" { - printer.Print(unmarshalList(data)) + printer.Print(unmarshalNamedList(data, "agents")) return nil } tbl := output.NewTable("ID", "KEY", "NAME", "PROVIDER", "MODEL", "STATUS", "TYPE") - for _, a := range unmarshalList(data) { + for _, a := range unmarshalNamedList(data, "agents") { tbl.AddRow(str(a, "id"), str(a, "agent_key"), str(a, "display_name"), str(a, "provider"), str(a, "model"), str(a, "status"), str(a, "agent_type")) } diff --git a/cmd/list_response_helpers.go b/cmd/list_response_helpers.go new file mode 100644 index 0000000..a8491bb --- /dev/null +++ b/cmd/list_response_helpers.go @@ -0,0 +1,41 @@ +package cmd + +import "encoding/json" + +// unmarshalNamedList handles endpoints that wrap arrays in an object envelope. +func unmarshalNamedList(data json.RawMessage, key string) []map[string]any { + if list := unmarshalList(data); list != nil { + return list + } + var envelope map[string]any + if err := json.Unmarshal(data, &envelope); err == nil { + return mapsFromAnyList(envelope[key]) + } + return nil +} + +func mapsFromAnyList(value any) []map[string]any { + switch list := value.(type) { + case []map[string]any: + return list + case []any: + out := make([]map[string]any, 0, len(list)) + for _, item := range list { + if m, ok := item.(map[string]any); ok { + out = append(out, m) + } + } + return out + default: + return nil + } +} + +func strFirst(m map[string]any, keys ...string) string { + for _, key := range keys { + if v := str(m, key); v != "" { + return v + } + } + return "" +} diff --git a/cmd/memory.go b/cmd/memory.go index 3b6eb99..0ce8b90 100644 --- a/cmd/memory.go +++ b/cmd/memory.go @@ -24,9 +24,11 @@ var memoryListCmd = &cobra.Command{ if err != nil { return err } - path := "/v1/memory/" + args[0] + path := "/v1/agents/" + url.PathEscape(args[0]) + "/memory/documents" if v, _ := cmd.Flags().GetString("user"); v != "" { - path += "?user_id=" + v + q := url.Values{} + q.Set("user_id", v) + path += "?" + q.Encode() } data, err := c.Get(path) if err != nil { @@ -54,7 +56,7 @@ var memoryGetCmd = &cobra.Command{ if err != nil { return err } - data, err := c.Get("/v1/memory/" + url.PathEscape(args[0]) + "/" + url.PathEscape(args[1])) + data, err := c.Get(memoryDocumentPath(args[0], args[1])) if err != nil { return err } @@ -77,7 +79,7 @@ var memoryStoreCmd = &cobra.Command{ if err != nil { return err } - _, err = c.Put("/v1/memory/"+url.PathEscape(args[0])+"/"+url.PathEscape(args[1]), + _, err = c.Put(memoryDocumentPath(args[0], args[1]), map[string]any{"content": content}) if err != nil { return err @@ -99,7 +101,7 @@ var memoryDeleteCmd = &cobra.Command{ if err != nil { return err } - _, err = c.Delete("/v1/memory/" + url.PathEscape(args[0]) + "/" + url.PathEscape(args[1])) + _, err = c.Delete(memoryDocumentPath(args[0], args[1])) if err != nil { return err } @@ -120,15 +122,19 @@ var memorySearchCmd = &cobra.Command{ query, _ := cmd.Flags().GetString("query") user, _ := cmd.Flags().GetString("user") body := buildBody("query", query, "user_id", user) - data, err := c.Post("/v1/memory/"+args[0]+"/search", body) + data, err := c.Post("/v1/agents/"+url.PathEscape(args[0])+"/memory/search", body) if err != nil { return err } - printer.Print(unmarshalList(data)) + printer.Print(unmarshalMap(data)) return nil }, } +func memoryDocumentPath(agentID, path string) string { + return "/v1/agents/" + url.PathEscape(agentID) + "/memory/documents/" + url.PathEscape(path) +} + func init() { memoryListCmd.Flags().String("user", "", "Filter by user ID") memoryStoreCmd.Flags().String("content", "", "Content (or @filepath)") diff --git a/cmd/p4_ux_polish_test.go b/cmd/p4_ux_polish_test.go index dbca00b..9e4e500 100644 --- a/cmd/p4_ux_polish_test.go +++ b/cmd/p4_ux_polish_test.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "os" "path/filepath" "strings" @@ -155,6 +156,10 @@ func TestConfigDefaultsUsesWSMethod(t *testing.T) { func TestToolsInvokeArgsReadsFile(t *testing.T) { defer resetTestFlag(toolsInvokeCmd, "args", "") defer resetTestFlag(toolsInvokeCmd, "param", "") + defer resetTestFlag(toolsInvokeCmd, "agent", "") + defer resetTestFlag(toolsInvokeCmd, "action", "") + defer resetTestFlag(toolsInvokeCmd, "session", "") + defer resetTestFlag(toolsInvokeCmd, "dry-run", "false") var body map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/tools/invoke" { @@ -172,15 +177,195 @@ func TestToolsInvokeArgsReadsFile(t *testing.T) { if err := os.WriteFile(path, []byte(`{"city":"Saigon"}`), 0o600); err != nil { t.Fatalf("write args: %v", err) } - if err := runCmd(t, "tools", "invoke", "weather", "--args=@"+path, "--param=unit=c"); err != nil { + if err := runCmd(t, "tools", "invoke", "weather", "--args=@"+path, "--param=unit=c", + "--agent=goclaw", "--action=forecast", "--session=sess-1", "--dry-run"); err != nil { t.Fatalf("tools invoke: %v", err) } - params := body["parameters"].(map[string]any) + if body["tool"] != "weather" { + t.Fatalf("tool = %#v", body["tool"]) + } + if body["agentId"] != "goclaw" || body["action"] != "forecast" || + body["sessionKey"] != "sess-1" || body["dryRun"] != true { + t.Fatalf("context fields = %#v", body) + } + params := body["args"].(map[string]any) if params["city"] != "Saigon" || params["unit"] != "c" { t.Fatalf("params = %#v", params) } } +func TestToolsCustomUnsupportedDoesNotCallServer(t *testing.T) { + requests := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + err := runCmd(t, "tools", "custom", "list") + if err == nil { + t.Fatal("expected unsupported custom tools error") + } + var detail *output.ErrorDetail + if !errors.As(err, &detail) || detail.Code != "INVALID_REQUEST" { + t.Fatalf("error = %#v, want INVALID_REQUEST detail", err) + } + if requests != 0 { + t.Fatalf("requests = %d, want 0", requests) + } +} + +func TestUsageTimeseriesMapsFlagsToServerContract(t *testing.T) { + defer resetTestFlag(usageTimeseriesCmd, "start", "") + defer resetTestFlag(usageTimeseriesCmd, "end", "") + defer resetTestFlag(usageTimeseriesCmd, "granularity", "day") + defer resetTestFlag(usageTimeseriesCmd, "agent", "") + var query url.Values + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/usage/timeseries" { + w.WriteHeader(http.StatusNotFound) + return + } + query = r.URL.Query() + okJSON(t, w, map[string]any{"series": []map[string]any{}}) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + if err := runCmd(t, "usage", "timeseries", "--start=2026-05-20", "--end=2026-05-21", "--granularity=day", "--agent=agent-1"); err != nil { + t.Fatalf("usage timeseries: %v", err) + } + if query.Get("from") != "2026-05-20T00:00:00Z" || query.Get("to") != "2026-05-21T00:00:00Z" || + query.Get("group_by") != "day" || query.Get("agent_id") != "agent-1" { + t.Fatalf("query = %#v", query) + } + if query.Has("start") || query.Has("end") || query.Has("granularity") || query.Has("agent") { + t.Fatalf("query contains legacy keys: %#v", query) + } +} + +func TestUsageBreakdownMapsFlagsToServerContract(t *testing.T) { + defer resetTestFlag(usageBreakdownCmd, "start", "") + defer resetTestFlag(usageBreakdownCmd, "end", "") + defer resetTestFlag(usageBreakdownCmd, "by", "agent") + var query url.Values + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/usage/breakdown" { + w.WriteHeader(http.StatusNotFound) + return + } + query = r.URL.Query() + okJSON(t, w, map[string]any{"groups": []map[string]any{}}) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + if err := runCmd(t, "usage", "breakdown", "--start=2026-05-20", "--end=2026-05-21", "--by=provider"); err != nil { + t.Fatalf("usage breakdown: %v", err) + } + if query.Get("from") != "2026-05-20T00:00:00Z" || query.Get("to") != "2026-05-21T00:00:00Z" || + query.Get("group_by") != "provider" { + t.Fatalf("query = %#v", query) + } + if query.Has("start") || query.Has("end") || query.Has("by") { + t.Fatalf("query contains legacy keys: %#v", query) + } +} + +func TestMemoryCommandsUseAgentScopedHTTPRoutes(t *testing.T) { + defer resetTestFlag(memoryListCmd, "user", "") + defer resetTestFlag(memorySearchCmd, "query", "") + defer resetTestFlag(memorySearchCmd, "user", "") + var paths []string + var searchBody map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.String()) + switch r.URL.Path { + case "/v1/agents/goclaw/memory/documents": + okJSON(t, w, []map[string]any{{"path": "notes.md"}}) + case "/v1/agents/goclaw/memory/search": + _ = json.NewDecoder(r.Body).Decode(&searchBody) + okJSON(t, w, map[string]any{"results": []map[string]any{}, "count": 0}) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + if err := runCmd(t, "memory", "list", "goclaw", "--user=system"); err != nil { + t.Fatalf("memory list: %v", err) + } + if err := runCmd(t, "memory", "search", "goclaw", "--query=test"); err != nil { + t.Fatalf("memory search: %v", err) + } + if len(paths) != 2 || paths[0] != "/v1/agents/goclaw/memory/documents?user_id=system" || + paths[1] != "/v1/agents/goclaw/memory/search" { + t.Fatalf("paths = %#v", paths) + } + if searchBody["query"] != "test" { + t.Fatalf("search body = %#v", searchBody) + } +} + +func TestListCommandsAcceptObjectWrappedLists(t *testing.T) { + var paths []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + switch r.URL.Path { + case "/v1/agents": + okJSON(t, w, map[string]any{"agents": []map[string]any{{"id": "agent-1"}}}) + case "/v1/sessions": + okJSON(t, w, map[string]any{"sessions": []map[string]any{{"session_key": "sess-1"}}}) + case "/v1/tools/builtin": + okJSON(t, w, map[string]any{"tools": []map[string]any{{"name": "sessions_list"}}}) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + t.Setenv("GOCLAW_OUTPUT", "json") + + agentsOut, err := captureStdout(t, func() error { + return runCmd(t, "agents", "list") + }) + if err != nil { + t.Fatalf("agents list: %v", err) + } + sessionsOut, err := captureStdout(t, func() error { + return runCmd(t, "sessions", "list") + }) + if err != nil { + t.Fatalf("sessions list: %v", err) + } + toolsOut, err := captureStdout(t, func() error { + return runCmd(t, "tools", "builtin", "list") + }) + if err != nil { + t.Fatalf("tools builtin list: %v", err) + } + if strings.Contains(agentsOut, "null") || !strings.Contains(agentsOut, "agent-1") { + t.Fatalf("agents stdout = %q", agentsOut) + } + if strings.Contains(sessionsOut, "null") || !strings.Contains(sessionsOut, "sess-1") { + t.Fatalf("sessions stdout = %q", sessionsOut) + } + if strings.Contains(toolsOut, "null") || !strings.Contains(toolsOut, "sessions_list") { + t.Fatalf("tools stdout = %q", toolsOut) + } + if len(paths) != 3 || paths[0] != "/v1/agents" || paths[1] != "/v1/sessions" || + paths[2] != "/v1/tools/builtin" { + t.Fatalf("paths = %#v", paths) + } +} + func TestChatReplayAndResumeUseExistingWSContracts(t *testing.T) { defer resetTestFlag(chatReplayCmd, "session", "") defer resetTestFlag(chatReplayCmd, "before", "") diff --git a/cmd/sessions.go b/cmd/sessions.go index 714a281..000297b 100644 --- a/cmd/sessions.go +++ b/cmd/sessions.go @@ -41,13 +41,14 @@ var sessionsListCmd = &cobra.Command{ return err } if cfg.OutputFormat != "table" { - printer.Print(unmarshalList(data)) + printer.Print(unmarshalNamedList(data, "sessions")) return nil } tbl := output.NewTable("KEY", "AGENT", "USER", "LABEL", "INPUT_TOKENS", "OUTPUT_TOKENS") - for _, s := range unmarshalList(data) { - tbl.AddRow(str(s, "session_key"), str(s, "agent_id"), str(s, "user_id"), - str(s, "label"), str(s, "input_tokens"), str(s, "output_tokens")) + for _, s := range unmarshalNamedList(data, "sessions") { + tbl.AddRow(strFirst(s, "session_key", "key"), strFirst(s, "agent_id", "agentID", "agentName"), + strFirst(s, "user_id", "userID"), str(s, "label"), + strFirst(s, "input_tokens", "inputTokens"), strFirst(s, "output_tokens", "outputTokens")) } printer.Print(tbl) return nil diff --git a/cmd/tools.go b/cmd/tools.go index 8364f50..3d04196 100644 --- a/cmd/tools.go +++ b/cmd/tools.go @@ -23,7 +23,7 @@ var toolsBuiltinListCmd = &cobra.Command{ if err != nil { return err } - printer.Print(unmarshalList(data)) + printer.Print(unmarshalNamedList(data, "tools")) return nil }, } diff --git a/cmd/tools_custom.go b/cmd/tools_custom.go index ba8bfd2..261729c 100644 --- a/cmd/tools_custom.go +++ b/cmd/tools_custom.go @@ -1,12 +1,7 @@ package cmd import ( - "encoding/json" - "fmt" - "net/url" - "github.com/nextlevelbuilder/goclaw-cli/internal/output" - "github.com/nextlevelbuilder/goclaw-cli/internal/tui" "github.com/spf13/cobra" ) @@ -18,126 +13,35 @@ var toolsCustomCmd = &cobra.Command{Use: "custom", Short: "Manage custom tools"} var toolsCustomListCmd = &cobra.Command{ Use: "list", Short: "List custom tools", RunE: func(cmd *cobra.Command, args []string) error { - c, err := newHTTP() - if err != nil { - return err - } - path := "/v1/tools/custom" - if v, _ := cmd.Flags().GetString("agent"); v != "" { - path += "?agent_id=" + url.QueryEscape(v) - } - data, err := c.Get(path) - if err != nil { - return err - } - if cfg.OutputFormat != "table" { - printer.Print(unmarshalList(data)) - return nil - } - tbl := output.NewTable("ID", "NAME", "DESCRIPTION", "ENABLED", "TIMEOUT") - for _, t := range unmarshalList(data) { - tbl.AddRow(str(t, "id"), str(t, "name"), str(t, "description"), - str(t, "enabled"), str(t, "timeout_seconds")) - } - printer.Print(tbl) - return nil + return customToolsUnsupported() }, } var toolsCustomGetCmd = &cobra.Command{ Use: "get ", Short: "Get custom tool details", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - c, err := newHTTP() - if err != nil { - return err - } - data, err := c.Get("/v1/tools/custom/" + args[0]) - if err != nil { - return err - } - printer.Print(unmarshalMap(data)) - return nil + return customToolsUnsupported() }, } var toolsCustomCreateCmd = &cobra.Command{ Use: "create", Short: "Create a custom tool", RunE: func(cmd *cobra.Command, args []string) error { - c, err := newHTTP() - if err != nil { - return err - } - name, _ := cmd.Flags().GetString("name") - desc, _ := cmd.Flags().GetString("description") - command, _ := cmd.Flags().GetString("command") - timeout, _ := cmd.Flags().GetInt("timeout") - agent, _ := cmd.Flags().GetString("agent") - paramsJSON, _ := cmd.Flags().GetString("parameters") - body := buildBody("name", name, "description", desc, - "command", command, "timeout_seconds", timeout, "agent_id", agent, "enabled", true) - if paramsJSON != "" { - var params any - if err := json.Unmarshal([]byte(paramsJSON), ¶ms); err != nil { - return fmt.Errorf("invalid parameters JSON: %w", err) - } - body["parameters"] = params - } - data, err := c.Post("/v1/tools/custom", body) - if err != nil { - return err - } - printer.Success(fmt.Sprintf("Tool created: %s", str(unmarshalMap(data), "id"))) - return nil + return customToolsUnsupported() }, } var toolsCustomUpdateCmd = &cobra.Command{ Use: "update ", Short: "Update custom tool", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - c, err := newHTTP() - if err != nil { - return err - } - body := make(map[string]any) - for _, f := range []string{"name", "description", "command"} { - if cmd.Flags().Changed(f) { - v, _ := cmd.Flags().GetString(f) - body[f] = v - } - } - if cmd.Flags().Changed("timeout") { - v, _ := cmd.Flags().GetInt("timeout") - body["timeout_seconds"] = v - } - if cmd.Flags().Changed("enabled") { - v, _ := cmd.Flags().GetBool("enabled") - body["enabled"] = v - } - _, err = c.Put("/v1/tools/custom/"+args[0], body) - if err != nil { - return err - } - printer.Success("Tool updated") - return nil + return customToolsUnsupported() }, } var toolsCustomDeleteCmd = &cobra.Command{ Use: "delete ", Short: "Delete custom tool", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - if !tui.Confirm("Delete this tool?", cfg.Yes) { - return nil - } - c, err := newHTTP() - if err != nil { - return err - } - _, err = c.Delete("/v1/tools/custom/" + args[0]) - if err != nil { - return err - } - printer.Success("Tool deleted") - return nil + return customToolsUnsupported() }, } @@ -156,7 +60,18 @@ var toolsInvokeCmd = &cobra.Command{ if err != nil { return err } - body := map[string]any{"name": args[0], "parameters": params} + agentID, _ := cmd.Flags().GetString("agent") + action, _ := cmd.Flags().GetString("action") + sessionKey, _ := cmd.Flags().GetString("session") + dryRun, _ := cmd.Flags().GetBool("dry-run") + body := buildBody( + "tool", args[0], + "args", params, + "agentId", agentID, + "action", action, + "sessionKey", sessionKey, + "dryRun", dryRun, + ) data, err := c.Post("/v1/tools/invoke", body) if err != nil { return err @@ -166,6 +81,13 @@ var toolsInvokeCmd = &cobra.Command{ }, } +func customToolsUnsupported() error { + return &output.ErrorDetail{ + Code: "INVALID_REQUEST", + Message: "custom tool management is not supported by this GoClaw server; use `tools builtin` or `tools invoke`", + } +} + func init() { toolsCustomListCmd.Flags().String("agent", "", "Filter by agent ID") for _, c := range []*cobra.Command{toolsCustomCreateCmd, toolsCustomUpdateCmd} { @@ -180,6 +102,10 @@ func init() { toolsInvokeCmd.Flags().StringSlice("param", nil, "Parameter key=value pairs") toolsInvokeCmd.Flags().String("params", "", "Parameters as JSON object") toolsInvokeCmd.Flags().String("args", "", "Alias for --params; accepts literal JSON or @filepath") + toolsInvokeCmd.Flags().String("agent", "", "Agent key or ID for tool context") + toolsInvokeCmd.Flags().String("action", "", "Optional action to pass to the tool") + toolsInvokeCmd.Flags().String("session", "", "Optional session key for tool context") + toolsInvokeCmd.Flags().Bool("dry-run", false, "Validate tool and return schema without executing it") toolsCustomCmd.AddCommand(toolsCustomListCmd, toolsCustomGetCmd, toolsCustomCreateCmd, toolsCustomUpdateCmd, toolsCustomDeleteCmd) diff --git a/cmd/traces.go b/cmd/traces.go index 5372cfd..851bff1 100644 --- a/cmd/traces.go +++ b/cmd/traces.go @@ -5,6 +5,7 @@ import ( "io" "net/url" "os" + "time" "github.com/nextlevelbuilder/goclaw-cli/internal/output" "github.com/spf13/cobra" @@ -191,10 +192,17 @@ var usageTimeseriesCmd = &cobra.Command{ return err } q := url.Values{} - for _, k := range []string{"start", "end", "granularity", "agent", "user", "tenant"} { - if v, _ := cmd.Flags().GetString(k); v != "" { - q.Set(k, v) - } + if v, _ := cmd.Flags().GetString("start"); v != "" { + q.Set("from", normalizeUsageTimestamp(v)) + } + if v, _ := cmd.Flags().GetString("end"); v != "" { + q.Set("to", normalizeUsageTimestamp(v)) + } + if v, _ := cmd.Flags().GetString("granularity"); v != "" { + q.Set("group_by", v) + } + if v, _ := cmd.Flags().GetString("agent"); v != "" { + q.Set("agent_id", v) } path := "/v1/usage/timeseries" if len(q) > 0 { @@ -217,10 +225,14 @@ var usageBreakdownCmd = &cobra.Command{ return err } q := url.Values{} - for _, k := range []string{"by", "start", "end"} { - if v, _ := cmd.Flags().GetString(k); v != "" { - q.Set(k, v) - } + if v, _ := cmd.Flags().GetString("by"); v != "" { + q.Set("group_by", v) + } + if v, _ := cmd.Flags().GetString("start"); v != "" { + q.Set("from", normalizeUsageTimestamp(v)) + } + if v, _ := cmd.Flags().GetString("end"); v != "" { + q.Set("to", normalizeUsageTimestamp(v)) } path := "/v1/usage/breakdown" if len(q) > 0 { @@ -235,6 +247,13 @@ var usageBreakdownCmd = &cobra.Command{ }, } +func normalizeUsageTimestamp(v string) string { + if t, err := time.Parse("2006-01-02", v); err == nil { + return t.Format(time.RFC3339) + } + return v +} + func init() { tracesListCmd.Flags().String("agent", "", "Filter by agent ID") tracesListCmd.Flags().String("status", "", "Filter: running, success, error") @@ -250,15 +269,15 @@ func init() { usageDetailCmd.Flags().String("from", "", "Start date") usageDetailCmd.Flags().String("to", "", "End date") - usageTimeseriesCmd.Flags().String("start", "", "Start ISO timestamp") - usageTimeseriesCmd.Flags().String("end", "", "End ISO timestamp") - usageTimeseriesCmd.Flags().String("granularity", "day", "Bucket size: hour|day") + usageTimeseriesCmd.Flags().String("start", "", "Start date or RFC3339 timestamp") + usageTimeseriesCmd.Flags().String("end", "", "End date or RFC3339 timestamp") + usageTimeseriesCmd.Flags().String("granularity", "day", "Group by: provider|model|channel|agent|day") usageTimeseriesCmd.Flags().String("agent", "", "Filter by agent") usageTimeseriesCmd.Flags().String("user", "", "Filter by user") usageTimeseriesCmd.Flags().String("tenant", "", "Filter by tenant") - usageBreakdownCmd.Flags().String("by", "agent", "Dimension: agent|user|tenant") - usageBreakdownCmd.Flags().String("start", "", "Start ISO timestamp") - usageBreakdownCmd.Flags().String("end", "", "End ISO timestamp") + usageBreakdownCmd.Flags().String("by", "agent", "Dimension: provider|model|channel|agent|day") + usageBreakdownCmd.Flags().String("start", "", "Start date or RFC3339 timestamp") + usageBreakdownCmd.Flags().String("end", "", "End date or RFC3339 timestamp") tracesCmd.AddCommand(tracesListCmd, tracesGetCmd, tracesExportCmd) usageCmd.AddCommand(usageSummaryCmd, usageDetailCmd, usageCostsCmd,