From 58576bf843abd68d8c854fca519d395b26511925 Mon Sep 17 00:00:00 2001 From: David Gil Date: Sat, 23 May 2026 21:07:12 +0200 Subject: [PATCH] feat(chats,auth,provider-connections): close out P3 of #11 + extract dispatchWrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the remaining `/api/v1/*` surface flagged in #11 (P3): - `provider-connections list` — read-only aggregator status (`GET /api/v1/provider_connections`). - `chats` subtree (requires AI enabled on the account): - `list` (paged at upstream's fixed 20/page; `--page` only). - `show [--page]` (messages paged at 50/page upstream). - `create --title --message --model [--apply]` (POST). - `update --title [--apply]` (PATCH). - `delete [--apply]` (DELETE). - `messages create --chat-id --content --model [--apply]` (POST). - `messages retry --chat-id [--apply]` (POST collection action). - `auth enable-ai [--apply]` — PATCH `/api/v1/auth/enable_ai` (correct verb per routes.rb:491). Refactor (behavior-preserving): Extracts `dispatchWrite(apply, method, path, body)` next to the `printDryRun`/`printPost`/`printPatch`/`printDelete` helpers in reference_cmds.go. It replaces the 4-line dry-run-or-execute pattern across chats (5 sites), auth (1 site), and the existing tags create/update/delete (3 sites). Unsupported HTTP methods fail loudly via `output.Fail` rather than silently no-oping. Other helpers like `printInvestmentDryRun`, `printFinancialDryRun`, etc. are intentionally left alone — they wrap different print helpers (scoped per output context) and consolidating them is out of scope. TDD: - Tests written first for each new command's shape, registration, and flag set. - Per-builder unit tests for `buildChatCreateBody`, `buildChatUpdateBody`, `buildMessageCreateBody`, `validateMessageRetryOpts` covering required fields, optional fields, whitespace-only titles (matching upstream `validates :title, presence: true`), and `--per-page` absence on `chats list` (upstream has no per_page param). - `TestDispatchWrite_DryRun_POST` and `_DryRun_DELETE_OmitsNilBody` capture stdout via `os.Pipe` and parse the JSON envelope to verify the dry-run shape end-to-end. - Tightened `Find`-based registration tests across chats, auth, provider-connections — cobra's `Find` silently returns the parent when a leaf is missing, so tests now assert the resolved cmd's `Name()` matches the expected leaf. Closes the read coverage half of we-promise/sure-cli#11. Refs we-promise/sure-cli#11. --- README.md | 17 ++ cmd/sure-cli/root/auth_cmd.go | 26 ++ cmd/sure-cli/root/auth_cmd_test.go | 43 ++++ cmd/sure-cli/root/chats_cmd.go | 238 ++++++++++++++++++ cmd/sure-cli/root/chats_cmd_test.go | 212 ++++++++++++++++ cmd/sure-cli/root/dispatch_write_test.go | 87 +++++++ cmd/sure-cli/root/provider_connections_cmd.go | 20 ++ .../root/provider_connections_cmd_test.go | 40 +++ cmd/sure-cli/root/reference_cmds.go | 50 ++-- cmd/sure-cli/root/root.go | 3 + docs/ROADMAP.md | 3 + 11 files changed, 715 insertions(+), 24 deletions(-) create mode 100644 cmd/sure-cli/root/auth_cmd.go create mode 100644 cmd/sure-cli/root/auth_cmd_test.go create mode 100644 cmd/sure-cli/root/chats_cmd.go create mode 100644 cmd/sure-cli/root/chats_cmd_test.go create mode 100644 cmd/sure-cli/root/dispatch_write_test.go create mode 100644 cmd/sure-cli/root/provider_connections_cmd.go create mode 100644 cmd/sure-cli/root/provider_connections_cmd_test.go diff --git a/README.md b/README.md index 9bf7867..f417209 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,23 @@ sure-cli usage show sure-cli imports preflight --type TransactionImport --file data.csv \ --date-col-label Date --amount-col-label Amount --name-col-label Name sure-cli imports preflight --type SureImport --raw-file-content "$(cat backup.ndjson)" + +# Provider connections (aggregator status) +sure-cli provider-connections list + +# AI chats (requires AI enabled on the account) +sure-cli chats list --page 1 +sure-cli chats show --page 1 +sure-cli chats create --title "Tax planning" --apply +sure-cli chats create --title "Brainstorm" --message "Let's start" --model gpt-4o --apply +sure-cli chats update --title "Renamed" --apply +sure-cli chats delete --apply +sure-cli chats messages create --chat-id --content "Hello" --apply +sure-cli chats messages retry --chat-id --apply + +# Auth (account-level writes) +sure-cli auth enable-ai # dry-run +sure-cli auth enable-ai --apply ``` ## Auth diff --git a/cmd/sure-cli/root/auth_cmd.go b/cmd/sure-cli/root/auth_cmd.go new file mode 100644 index 0000000..cf40ea9 --- /dev/null +++ b/cmd/sure-cli/root/auth_cmd.go @@ -0,0 +1,26 @@ +package root + +import ( + "github.com/spf13/cobra" +) + +func newAuthCmd() *cobra.Command { + cmd := &cobra.Command{Use: "auth", Short: "Auth-related write operations on the current account"} + cmd.AddCommand(newAuthEnableAICmd()) + return cmd +} + +func newAuthEnableAICmd() *cobra.Command { + var apply bool + cmd := &cobra.Command{ + Use: "enable-ai", + Short: "Enable AI on the current account (PATCH /api/v1/auth/enable_ai; default dry-run; use --apply to execute)", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + // upstream auth#enable_ai ignores the request body; send {} on apply. + dispatchWrite(apply, "PATCH", "/api/v1/auth/enable_ai", map[string]any{}) + }, + } + cmd.Flags().BoolVar(&apply, "apply", false, "execute the enable (otherwise dry-run)") + return cmd +} diff --git a/cmd/sure-cli/root/auth_cmd_test.go b/cmd/sure-cli/root/auth_cmd_test.go new file mode 100644 index 0000000..8215938 --- /dev/null +++ b/cmd/sure-cli/root/auth_cmd_test.go @@ -0,0 +1,43 @@ +package root + +import ( + "testing" +) + +func TestAuthCommandShape(t *testing.T) { + cmd := newAuthCmd() + if cmd.Use != "auth" { + t.Fatalf("Use = %q", cmd.Use) + } + + enableAI := findSub(t, cmd, "enable-ai") + if enableAI.Args == nil { + t.Fatal("auth enable-ai should reject extra args") + } + if enableAI.Flags().Lookup("apply") == nil { + t.Fatal("auth enable-ai missing --apply (this is a write op)") + } +} + +func TestAuthRegistered(t *testing.T) { + root := New() + // cobra's Find returns the nearest matching ancestor with no error if a + // leaf is missing, so we must compare the resolved cmd's Name to confirm + // the actual subcommand is registered. + cases := []struct { + path []string + want string + }{ + {[]string{"auth"}, "auth"}, + {[]string{"auth", "enable-ai"}, "enable-ai"}, + } + for _, c := range cases { + got, _, err := root.Find(c.path) + if err != nil { + t.Fatalf("path %v not registered: %v", c.path, err) + } + if got.Name() != c.want { + t.Fatalf("path %v resolved to %q, want %q", c.path, got.Name(), c.want) + } + } +} diff --git a/cmd/sure-cli/root/chats_cmd.go b/cmd/sure-cli/root/chats_cmd.go new file mode 100644 index 0000000..34ba535 --- /dev/null +++ b/cmd/sure-cli/root/chats_cmd.go @@ -0,0 +1,238 @@ +package root + +import ( + "errors" + "fmt" + "net/url" + "strings" + + "github.com/spf13/cobra" + "github.com/we-promise/sure-cli/internal/output" +) + +func newChatsCmd() *cobra.Command { + cmd := &cobra.Command{Use: "chats", Short: "AI chat sessions (requires AI enabled on the account)"} + + cmd.AddCommand(newChatsListCmd()) + cmd.AddCommand(newChatsShowCmd()) + cmd.AddCommand(newChatsCreateCmd()) + cmd.AddCommand(newChatsUpdateCmd()) + cmd.AddCommand(newChatsDeleteCmd()) + cmd.AddCommand(newChatsMessagesCmd()) + + return cmd +} + +func newChatsListCmd() *cobra.Command { + var page int + cmd := &cobra.Command{ + Use: "list", + Short: "List chats", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + q := url.Values{} + if page > 0 { + q.Set("page", fmt.Sprintf("%d", page)) + } + printGet(pathWithQuery("/api/v1/chats", q)) + }, + } + cmd.Flags().IntVar(&page, "page", 0, "page number (upstream uses a fixed page size of 20)") + return cmd +} + +func newChatsShowCmd() *cobra.Command { + var page int + cmd := &cobra.Command{ + Use: "show ", + Short: "Show a chat with its messages (paged at 50/page)", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + q := url.Values{} + if page > 0 { + q.Set("page", fmt.Sprintf("%d", page)) + } + printGet(pathWithQuery(fmt.Sprintf("/api/v1/chats/%s", url.PathEscape(args[0])), q)) + }, + } + cmd.Flags().IntVar(&page, "page", 0, "messages page number") + return cmd +} + +// ---------- create ---------- + +type chatCreateOpts struct { + Title string + Message string + Model string + Apply bool +} + +func buildChatCreateBody(o chatCreateOpts) (map[string]any, error) { + if strings.TrimSpace(o.Title) == "" { + return nil, errors.New("title is required (upstream validates presence)") + } + body := map[string]any{"title": o.Title} + if o.Message != "" { + body["message"] = o.Message + } + if o.Model != "" { + body["model"] = o.Model + } + return body, nil +} + +func newChatsCreateCmd() *cobra.Command { + var o chatCreateOpts + cmd := &cobra.Command{ + Use: "create", + Short: "Create a chat (default dry-run; use --apply to execute)", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + body, err := buildChatCreateBody(o) + if err != nil { + output.Fail("validation_failed", err.Error(), nil) + return + } + dispatchWrite(o.Apply, "POST", "/api/v1/chats", body) + }, + } + cmd.Flags().StringVar(&o.Title, "title", "", "chat title (required)") + cmd.Flags().StringVar(&o.Message, "message", "", "optional first user message") + cmd.Flags().StringVar(&o.Model, "model", "", "optional AI model identifier") + cmd.Flags().BoolVar(&o.Apply, "apply", false, "execute the create (otherwise dry-run)") + return cmd +} + +// ---------- update ---------- + +type chatUpdateOpts struct { + Title string + Apply bool +} + +func buildChatUpdateBody(o chatUpdateOpts) (map[string]any, error) { + if strings.TrimSpace(o.Title) == "" { + return nil, errors.New("title is required") + } + return map[string]any{"title": o.Title}, nil +} + +func newChatsUpdateCmd() *cobra.Command { + var o chatUpdateOpts + cmd := &cobra.Command{ + Use: "update ", + Short: "Rename a chat (default dry-run; use --apply to execute)", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + body, err := buildChatUpdateBody(o) + if err != nil { + output.Fail("validation_failed", err.Error(), nil) + return + } + dispatchWrite(o.Apply, "PATCH", fmt.Sprintf("/api/v1/chats/%s", url.PathEscape(args[0])), body) + }, + } + cmd.Flags().StringVar(&o.Title, "title", "", "new chat title (required)") + cmd.Flags().BoolVar(&o.Apply, "apply", false, "execute the update (otherwise dry-run)") + return cmd +} + +// ---------- delete ---------- + +func newChatsDeleteCmd() *cobra.Command { + var apply bool + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a chat (default dry-run; use --apply to execute)", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + dispatchWrite(apply, "DELETE", fmt.Sprintf("/api/v1/chats/%s", url.PathEscape(args[0])), nil) + }, + } + cmd.Flags().BoolVar(&apply, "apply", false, "execute the delete (otherwise dry-run)") + return cmd +} + +// ---------- messages ---------- + +func newChatsMessagesCmd() *cobra.Command { + cmd := &cobra.Command{Use: "messages", Short: "Messages within a chat"} + cmd.AddCommand(newChatsMessagesCreateCmd()) + cmd.AddCommand(newChatsMessagesRetryCmd()) + return cmd +} + +type messageCreateOpts struct { + ChatID string + Content string + Model string + Apply bool +} + +func buildMessageCreateBody(o messageCreateOpts) (map[string]any, error) { + if o.ChatID == "" { + return nil, errors.New("chat-id is required") + } + if strings.TrimSpace(o.Content) == "" { + return nil, errors.New("content is required") + } + body := map[string]any{"content": o.Content} + if o.Model != "" { + body["model"] = o.Model + } + return body, nil +} + +func newChatsMessagesCreateCmd() *cobra.Command { + var o messageCreateOpts + cmd := &cobra.Command{ + Use: "create", + Short: "Send a user message in a chat (default dry-run; use --apply to execute)", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + body, err := buildMessageCreateBody(o) + if err != nil { + output.Fail("validation_failed", err.Error(), nil) + return + } + dispatchWrite(o.Apply, "POST", fmt.Sprintf("/api/v1/chats/%s/messages", url.PathEscape(o.ChatID)), body) + }, + } + cmd.Flags().StringVar(&o.ChatID, "chat-id", "", "chat id (required)") + cmd.Flags().StringVar(&o.Content, "content", "", "message content (required)") + cmd.Flags().StringVar(&o.Model, "model", "", "optional AI model identifier") + cmd.Flags().BoolVar(&o.Apply, "apply", false, "execute the create (otherwise dry-run)") + return cmd +} + +type messageRetryOpts struct { + ChatID string + Apply bool +} + +func validateMessageRetryOpts(o messageRetryOpts) error { + if o.ChatID == "" { + return errors.New("chat-id is required") + } + return nil +} + +func newChatsMessagesRetryCmd() *cobra.Command { + var o messageRetryOpts + cmd := &cobra.Command{ + Use: "retry", + Short: "Retry the last assistant response in a chat (default dry-run; use --apply to execute)", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + if err := validateMessageRetryOpts(o); err != nil { + output.Fail("validation_failed", err.Error(), nil) + return + } + dispatchWrite(o.Apply, "POST", fmt.Sprintf("/api/v1/chats/%s/messages/retry", url.PathEscape(o.ChatID)), map[string]any{}) + }, + } + cmd.Flags().StringVar(&o.ChatID, "chat-id", "", "chat id (required)") + cmd.Flags().BoolVar(&o.Apply, "apply", false, "execute the retry (otherwise dry-run)") + return cmd +} diff --git a/cmd/sure-cli/root/chats_cmd_test.go b/cmd/sure-cli/root/chats_cmd_test.go new file mode 100644 index 0000000..e90b444 --- /dev/null +++ b/cmd/sure-cli/root/chats_cmd_test.go @@ -0,0 +1,212 @@ +package root + +import ( + "strings" + "testing" +) + +// ---------- builder unit tests ---------- + +func TestBuildChatCreateBody_RequiresTitle(t *testing.T) { + if _, err := buildChatCreateBody(chatCreateOpts{}); err == nil { + t.Fatal("expected missing title to error") + } +} + +func TestBuildChatCreateBody_TitleOnly(t *testing.T) { + body, err := buildChatCreateBody(chatCreateOpts{Title: "Hello"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if body["title"] != "Hello" { + t.Fatalf("title = %v", body["title"]) + } + if _, ok := body["message"]; ok { + t.Fatal("message should be omitted when empty") + } + if _, ok := body["model"]; ok { + t.Fatal("model should be omitted when empty") + } +} + +func TestBuildChatCreateBody_AllFields(t *testing.T) { + body, err := buildChatCreateBody(chatCreateOpts{Title: "T", Message: "hi", Model: "gpt-4o"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if body["title"] != "T" || body["message"] != "hi" || body["model"] != "gpt-4o" { + t.Fatalf("body = %#v", body) + } +} + +func TestBuildChatCreateBody_RejectsWhitespaceTitle(t *testing.T) { + if _, err := buildChatCreateBody(chatCreateOpts{Title: " "}); err == nil { + t.Fatal("expected whitespace-only title to be rejected (upstream validates presence after strip)") + } +} + +func TestBuildChatUpdateBody_RequiresTitle(t *testing.T) { + if _, err := buildChatUpdateBody(chatUpdateOpts{}); err == nil { + t.Fatal("expected missing title to error") + } +} + +func TestBuildChatUpdateBody_RejectsWhitespaceTitle(t *testing.T) { + if _, err := buildChatUpdateBody(chatUpdateOpts{Title: "\t\n"}); err == nil { + t.Fatal("expected whitespace-only title to be rejected") + } +} + +func TestBuildChatUpdateBody_OK(t *testing.T) { + body, err := buildChatUpdateBody(chatUpdateOpts{Title: "renamed"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if body["title"] != "renamed" { + t.Fatalf("title = %v", body["title"]) + } +} + +func TestBuildMessageCreateBody_RequiresChatID(t *testing.T) { + if _, err := buildMessageCreateBody(messageCreateOpts{Content: "hi"}); err == nil { + t.Fatal("expected missing chat-id to error") + } +} + +func TestBuildMessageCreateBody_RequiresContent(t *testing.T) { + if _, err := buildMessageCreateBody(messageCreateOpts{ChatID: "c1"}); err == nil { + t.Fatal("expected missing content to error") + } +} + +func TestBuildMessageCreateBody_OptionalModel(t *testing.T) { + body, err := buildMessageCreateBody(messageCreateOpts{ChatID: "c1", Content: "hello"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if body["content"] != "hello" { + t.Fatalf("content = %v", body["content"]) + } + if _, ok := body["model"]; ok { + t.Fatal("model should be omitted when empty") + } +} + +func TestBuildMessageCreateBody_WithModel(t *testing.T) { + body, err := buildMessageCreateBody(messageCreateOpts{ChatID: "c1", Content: "hi", Model: "claude"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if body["model"] != "claude" { + t.Fatalf("model = %v", body["model"]) + } +} + +func TestBuildMessageRetry_RequiresChatID(t *testing.T) { + if err := validateMessageRetryOpts(messageRetryOpts{}); err == nil { + t.Fatal("expected missing chat-id to error") + } +} + +// ---------- command-shape tests ---------- + +func TestChatsCommandShape(t *testing.T) { + cmd := newChatsCmd() + if cmd.Use != "chats" { + t.Fatalf("Use = %q", cmd.Use) + } + + list := findSub(t, cmd, "list") + if list.Flags().Lookup("page") == nil { + t.Fatal("chats list missing --page") + } + // Upstream uses a FIXED items: 20 — there is no per_page; do NOT expose --per-page. + if list.Flags().Lookup("per-page") != nil { + t.Fatal("chats list must not expose --per-page (upstream ignores it)") + } + + show := findSub(t, cmd, "show") + if show.Args == nil { + t.Fatal("chats show should require an id") + } + if show.Flags().Lookup("page") == nil { + t.Fatal("chats show missing --page for messages paging") + } + + create := findSub(t, cmd, "create") + for _, f := range []string{"title", "message", "model", "apply"} { + if create.Flags().Lookup(f) == nil { + t.Fatalf("chats create missing --%s", f) + } + } + + update := findSub(t, cmd, "update") + for _, f := range []string{"title", "apply"} { + if update.Flags().Lookup(f) == nil { + t.Fatalf("chats update missing --%s", f) + } + } + if update.Args == nil { + t.Fatal("chats update should require an id") + } + + del := findSub(t, cmd, "delete") + if del.Flags().Lookup("apply") == nil { + t.Fatal("chats delete missing --apply") + } + if del.Args == nil { + t.Fatal("chats delete should require an id") + } + + msgs := findSub(t, cmd, "messages") + if msgs == nil { + t.Fatal("chats messages subtree missing") + } + msgCreate := findSub(t, msgs, "create") + for _, f := range []string{"chat-id", "content", "model", "apply"} { + if msgCreate.Flags().Lookup(f) == nil { + t.Fatalf("chats messages create missing --%s", f) + } + } + msgRetry := findSub(t, msgs, "retry") + for _, f := range []string{"chat-id", "apply"} { + if msgRetry.Flags().Lookup(f) == nil { + t.Fatalf("chats messages retry missing --%s", f) + } + } +} + +func TestChatsRegistered(t *testing.T) { + root := New() + cases := []struct { + path []string + want string + }{ + {[]string{"chats"}, "chats"}, + {[]string{"chats", "list"}, "list"}, + {[]string{"chats", "show"}, "show"}, + {[]string{"chats", "create"}, "create"}, + {[]string{"chats", "update"}, "update"}, + {[]string{"chats", "delete"}, "delete"}, + {[]string{"chats", "messages", "create"}, "create"}, + {[]string{"chats", "messages", "retry"}, "retry"}, + } + for _, c := range cases { + got, _, err := root.Find(c.path) + if err != nil { + t.Fatalf("path %v not registered: %v", c.path, err) + } + if got.Name() != c.want { + t.Fatalf("path %v resolved to %q, want %q", c.path, got.Name(), c.want) + } + } +} + +// Defense in depth: the upstream short description should mention the AI-enabled requirement +// so users get a hint before hitting a 403. +func TestChatsCommand_HelpMentionsAIEnabled(t *testing.T) { + short := strings.ToLower(newChatsCmd().Short) + if !strings.Contains(short, "ai") { + t.Fatalf("chats short description should mention AI requirement, got: %q", short) + } +} diff --git a/cmd/sure-cli/root/dispatch_write_test.go b/cmd/sure-cli/root/dispatch_write_test.go new file mode 100644 index 0000000..96149b8 --- /dev/null +++ b/cmd/sure-cli/root/dispatch_write_test.go @@ -0,0 +1,87 @@ +package root + +import ( + "bytes" + "encoding/json" + "io" + "os" + "testing" +) + +// captureStdout swaps os.Stdout for a pipe, runs fn, and returns whatever was +// written. It restores the original Stdout afterwards. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + orig := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + os.Stdout = w + + done := make(chan struct{}) + var buf bytes.Buffer + go func() { + _, _ = io.Copy(&buf, r) + close(done) + }() + + fn() + + _ = w.Close() + <-done + os.Stdout = orig + return buf.String() +} + +func TestDispatchWrite_DryRun_POST(t *testing.T) { + out := captureStdout(t, func() { + // Reset to json format so the envelope is parseable. + format = "json" + dispatchWrite(false, "POST", "/api/v1/chats", map[string]any{"title": "x"}) + }) + + var env struct { + Data map[string]any `json:"data"` + } + if err := json.Unmarshal([]byte(out), &env); err != nil { + t.Fatalf("unmarshal envelope: %v\nout=%q", err, out) + } + if env.Data["dry_run"] != true { + t.Fatalf("expected dry_run=true, got %v", env.Data["dry_run"]) + } + req, ok := env.Data["request"].(map[string]any) + if !ok { + t.Fatalf("request not map: %#v", env.Data["request"]) + } + if req["method"] != "POST" || req["path"] != "/api/v1/chats" { + t.Fatalf("method/path = %v / %v", req["method"], req["path"]) + } + body, ok := req["body"].(map[string]any) + if !ok || body["title"] != "x" { + t.Fatalf("body = %#v", req["body"]) + } +} + +func TestDispatchWrite_DryRun_DELETE_OmitsNilBody(t *testing.T) { + out := captureStdout(t, func() { + format = "json" + dispatchWrite(false, "DELETE", "/api/v1/chats/abc", nil) + }) + var env struct { + Data map[string]any `json:"data"` + } + if err := json.Unmarshal([]byte(out), &env); err != nil { + t.Fatalf("unmarshal envelope: %v\nout=%q", err, out) + } + req, ok := env.Data["request"].(map[string]any) + if !ok { + t.Fatalf("request not map: %#v", env.Data["request"]) + } + if req["method"] != "DELETE" || req["path"] != "/api/v1/chats/abc" { + t.Fatalf("method/path = %v / %v", req["method"], req["path"]) + } + if _, hasBody := req["body"]; hasBody { + t.Fatalf("nil body should be omitted from dry-run output, got %#v", req) + } +} diff --git a/cmd/sure-cli/root/provider_connections_cmd.go b/cmd/sure-cli/root/provider_connections_cmd.go new file mode 100644 index 0000000..8d7b9d4 --- /dev/null +++ b/cmd/sure-cli/root/provider_connections_cmd.go @@ -0,0 +1,20 @@ +package root + +import ( + "github.com/spf13/cobra" +) + +func newProviderConnectionsCmd() *cobra.Command { + cmd := &cobra.Command{Use: "provider-connections", Short: "Aggregator / data-provider connection status"} + + cmd.AddCommand(&cobra.Command{ + Use: "list", + Short: "List provider connections for the current family", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + printGet("/api/v1/provider_connections") + }, + }) + + return cmd +} diff --git a/cmd/sure-cli/root/provider_connections_cmd_test.go b/cmd/sure-cli/root/provider_connections_cmd_test.go new file mode 100644 index 0000000..8cc3dc5 --- /dev/null +++ b/cmd/sure-cli/root/provider_connections_cmd_test.go @@ -0,0 +1,40 @@ +package root + +import ( + "testing" +) + +func TestProviderConnectionsCommandShape(t *testing.T) { + cmd := newProviderConnectionsCmd() + if cmd.Use != "provider-connections" { + t.Fatalf("Use = %q, want provider-connections", cmd.Use) + } + + list := findSub(t, cmd, "list") + if list.Args == nil { + t.Fatal("provider-connections list should reject extra args") + } + if list.Short == "" { + t.Fatal("list should have a Short description") + } +} + +func TestProviderConnectionsRegistered(t *testing.T) { + root := New() + cases := []struct { + path []string + want string + }{ + {[]string{"provider-connections"}, "provider-connections"}, + {[]string{"provider-connections", "list"}, "list"}, + } + for _, c := range cases { + got, _, err := root.Find(c.path) + if err != nil { + t.Fatalf("path %v not registered: %v", c.path, err) + } + if got.Name() != c.want { + t.Fatalf("path %v resolved to %q, want %q", c.path, got.Name(), c.want) + } + } +} diff --git a/cmd/sure-cli/root/reference_cmds.go b/cmd/sure-cli/root/reference_cmds.go index a73edf6..501558c 100644 --- a/cmd/sure-cli/root/reference_cmds.go +++ b/cmd/sure-cli/root/reference_cmds.go @@ -103,12 +103,7 @@ func newCategoriesCreateCmd() *cobra.Command { if err != nil { failValidation(err) } - path := "/api/v1/categories" - if !o.Apply { - printDryRun("POST", path, payload) - return - } - printPost(path, payload) + dispatchWrite(o.Apply, "POST", "/api/v1/categories", payload) }, } cmd.Flags().StringVar(&o.Name, "name", "", "category name (required, unique within family)") @@ -192,12 +187,7 @@ func newTagsCreateCmd() *cobra.Command { if err != nil { failValidation(err) } - path := "/api/v1/tags" - if !o.Apply { - printDryRun("POST", path, payload) - return - } - printPost(path, payload) + dispatchWrite(o.Apply, "POST", "/api/v1/tags", payload) }, } cmd.Flags().StringVar(&o.Name, "name", "", "tag name (required)") @@ -217,12 +207,7 @@ func newTagsUpdateCmd() *cobra.Command { if err != nil { failValidation(err) } - path := fmt.Sprintf("/api/v1/tags/%s", url.PathEscape(args[0])) - if !o.Apply { - printDryRun("PATCH", path, payload) - return - } - printPatch(path, payload) + dispatchWrite(o.Apply, "PATCH", fmt.Sprintf("/api/v1/tags/%s", url.PathEscape(args[0])), payload) }, } cmd.Flags().StringVar(&o.Name, "name", "", "tag name") @@ -238,12 +223,7 @@ func newTagsDeleteCmd() *cobra.Command { Short: "Delete tag (default dry-run; use --apply to execute)", Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { - path := fmt.Sprintf("/api/v1/tags/%s", url.PathEscape(args[0])) - if !apply { - printDryRun("DELETE", path, nil) - return - } - printDelete(path) + dispatchWrite(apply, "DELETE", fmt.Sprintf("/api/v1/tags/%s", url.PathEscape(args[0])), nil) }, } cmd.Flags().BoolVar(&apply, "apply", false, "execute the delete (otherwise dry-run)") @@ -428,6 +408,28 @@ func printDelete(path string) { } } +// dispatchWrite is the canonical entry point for write commands that share the +// dry-run-by-default pattern. When apply is false it prints the dry-run +// envelope; otherwise it dispatches to the matching print* helper. Only POST, +// PATCH, and DELETE are supported — adding a new method requires extending the +// switch deliberately rather than silently no-oping. +func dispatchWrite(apply bool, method, path string, body any) { + if !apply { + printDryRun(method, path, body) + return + } + switch method { + case "POST": + printPost(path, body) + case "PATCH": + printPatch(path, body) + case "DELETE": + printDelete(path) + default: + output.Fail("internal_error", "dispatchWrite: unsupported HTTP method "+method, nil) + } +} + func printDryRun(method, path string, body any) { request := map[string]any{ "method": method, diff --git a/cmd/sure-cli/root/root.go b/cmd/sure-cli/root/root.go index 57b0bd7..6c03022 100644 --- a/cmd/sure-cli/root/root.go +++ b/cmd/sure-cli/root/root.go @@ -76,6 +76,9 @@ func New() *cobra.Command { cmd.AddCommand(newUsersCmd()) cmd.AddCommand(newTransfersCmd()) cmd.AddCommand(newRejectedTransfersCmd()) + cmd.AddCommand(newProviderConnectionsCmd()) + cmd.AddCommand(newChatsCmd()) + cmd.AddCommand(newAuthCmd()) cmd.AddCommand(&cobra.Command{ Use: "version", Short: "Print version information", diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 9c8c815..2795daf 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -44,5 +44,8 @@ Future features and ideas for `sure-cli`. - Transfer review surface via `transfers list/show` and `rejected-transfers list/show`. - Sync history surface via `syncs list/latest/show`. - API usage / rate-limit visibility via `usage show`. +- Provider connection inspection via `provider-connections list`. +- AI chats CRUD + message send/retry via `chats` subtree. +- Account-level AI enable via `auth enable-ai`. See [CHANGELOG](../CHANGELOG.md) or GitHub releases for shipped features.