From 7d54448d0150906721db52d2cd97be572c7c8ae4 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 29 Apr 2026 16:26:50 -0400 Subject: [PATCH 1/5] Add personal access token commands Adds `fizzy token list|create|delete` backed by AccessTokensService on the root SDK client. Covers the three SDK methods that previously had no CLI surface (CreateAccessToken, ListAccessTokens, DeleteAccessToken). Includes an e2e CRUD test mirroring the webhook pattern, and the regenerated SURFACE.txt snapshot. --- SURFACE.txt | 115 +++++++++++++++++++++++++++++++ e2e/cli_tests/token_test.go | 48 +++++++++++++ internal/commands/columns.go | 7 ++ internal/commands/token.go | 128 +++++++++++++++++++++++++++++++++++ 4 files changed, 298 insertions(+) create mode 100644 e2e/cli_tests/token_test.go create mode 100644 internal/commands/token.go diff --git a/SURFACE.txt b/SURFACE.txt index 45d4400..43c1e99 100644 --- a/SURFACE.txt +++ b/SURFACE.txt @@ -24,6 +24,7 @@ ARG fizzy signup help 00 [command] ARG fizzy skill help 00 [command] ARG fizzy step help 00 [command] ARG fizzy tag help 00 [command] +ARG fizzy token help 00 [command] ARG fizzy upload help 00 [command] ARG fizzy user help 00 [command] ARG fizzy webhook help 00 [command] @@ -194,6 +195,13 @@ CMD fizzy tag CMD fizzy tag help CMD fizzy tag list CMD fizzy tag ls +CMD fizzy token +CMD fizzy token create +CMD fizzy token delete +CMD fizzy token help +CMD fizzy token list +CMD fizzy token ls +CMD fizzy token rm CMD fizzy upload CMD fizzy upload file CMD fizzy upload help @@ -2737,6 +2745,106 @@ FLAG fizzy tag ls --quiet type=bool FLAG fizzy tag ls --styled type=bool FLAG fizzy tag ls --token type=string FLAG fizzy tag ls --verbose type=bool +FLAG fizzy token --agent type=bool +FLAG fizzy token --api-url type=string +FLAG fizzy token --count type=bool +FLAG fizzy token --help type=bool +FLAG fizzy token --ids-only type=bool +FLAG fizzy token --jq type=string +FLAG fizzy token --json type=bool +FLAG fizzy token --limit type=int +FLAG fizzy token --markdown type=bool +FLAG fizzy token --profile type=string +FLAG fizzy token --quiet type=bool +FLAG fizzy token --styled type=bool +FLAG fizzy token --token type=string +FLAG fizzy token --verbose type=bool +FLAG fizzy token create --agent type=bool +FLAG fizzy token create --api-url type=string +FLAG fizzy token create --count type=bool +FLAG fizzy token create --description type=string +FLAG fizzy token create --help type=bool +FLAG fizzy token create --ids-only type=bool +FLAG fizzy token create --jq type=string +FLAG fizzy token create --json type=bool +FLAG fizzy token create --limit type=int +FLAG fizzy token create --markdown type=bool +FLAG fizzy token create --permission type=string +FLAG fizzy token create --profile type=string +FLAG fizzy token create --quiet type=bool +FLAG fizzy token create --styled type=bool +FLAG fizzy token create --token type=string +FLAG fizzy token create --verbose type=bool +FLAG fizzy token delete --agent type=bool +FLAG fizzy token delete --api-url type=string +FLAG fizzy token delete --count type=bool +FLAG fizzy token delete --help type=bool +FLAG fizzy token delete --ids-only type=bool +FLAG fizzy token delete --jq type=string +FLAG fizzy token delete --json type=bool +FLAG fizzy token delete --limit type=int +FLAG fizzy token delete --markdown type=bool +FLAG fizzy token delete --profile type=string +FLAG fizzy token delete --quiet type=bool +FLAG fizzy token delete --styled type=bool +FLAG fizzy token delete --token type=string +FLAG fizzy token delete --verbose type=bool +FLAG fizzy token help --agent type=bool +FLAG fizzy token help --api-url type=string +FLAG fizzy token help --count type=bool +FLAG fizzy token help --help type=bool +FLAG fizzy token help --ids-only type=bool +FLAG fizzy token help --jq type=string +FLAG fizzy token help --json type=bool +FLAG fizzy token help --limit type=int +FLAG fizzy token help --markdown type=bool +FLAG fizzy token help --profile type=string +FLAG fizzy token help --quiet type=bool +FLAG fizzy token help --styled type=bool +FLAG fizzy token help --token type=string +FLAG fizzy token help --verbose type=bool +FLAG fizzy token list --agent type=bool +FLAG fizzy token list --api-url type=string +FLAG fizzy token list --count type=bool +FLAG fizzy token list --help type=bool +FLAG fizzy token list --ids-only type=bool +FLAG fizzy token list --jq type=string +FLAG fizzy token list --json type=bool +FLAG fizzy token list --limit type=int +FLAG fizzy token list --markdown type=bool +FLAG fizzy token list --profile type=string +FLAG fizzy token list --quiet type=bool +FLAG fizzy token list --styled type=bool +FLAG fizzy token list --token type=string +FLAG fizzy token list --verbose type=bool +FLAG fizzy token ls --agent type=bool +FLAG fizzy token ls --api-url type=string +FLAG fizzy token ls --count type=bool +FLAG fizzy token ls --help type=bool +FLAG fizzy token ls --ids-only type=bool +FLAG fizzy token ls --jq type=string +FLAG fizzy token ls --json type=bool +FLAG fizzy token ls --limit type=int +FLAG fizzy token ls --markdown type=bool +FLAG fizzy token ls --profile type=string +FLAG fizzy token ls --quiet type=bool +FLAG fizzy token ls --styled type=bool +FLAG fizzy token ls --token type=string +FLAG fizzy token ls --verbose type=bool +FLAG fizzy token rm --agent type=bool +FLAG fizzy token rm --api-url type=string +FLAG fizzy token rm --count type=bool +FLAG fizzy token rm --help type=bool +FLAG fizzy token rm --ids-only type=bool +FLAG fizzy token rm --jq type=string +FLAG fizzy token rm --json type=bool +FLAG fizzy token rm --limit type=int +FLAG fizzy token rm --markdown type=bool +FLAG fizzy token rm --profile type=string +FLAG fizzy token rm --quiet type=bool +FLAG fizzy token rm --styled type=bool +FLAG fizzy token rm --token type=string +FLAG fizzy token rm --verbose type=bool FLAG fizzy upload --agent type=bool FLAG fizzy upload --api-url type=string FLAG fizzy upload --count type=bool @@ -3385,6 +3493,13 @@ SUB fizzy tag SUB fizzy tag help SUB fizzy tag list SUB fizzy tag ls +SUB fizzy token +SUB fizzy token create +SUB fizzy token delete +SUB fizzy token help +SUB fizzy token list +SUB fizzy token ls +SUB fizzy token rm SUB fizzy upload SUB fizzy upload file SUB fizzy upload help diff --git a/e2e/cli_tests/token_test.go b/e2e/cli_tests/token_test.go new file mode 100644 index 0000000..c70473c --- /dev/null +++ b/e2e/cli_tests/token_test.go @@ -0,0 +1,48 @@ +package clitests + +import ( + "strconv" + "testing" + "time" +) + +func TestAccessTokenCRUD(t *testing.T) { + h := newHarness(t) + description := "CLI Test Token " + strconv.FormatInt(time.Now().UnixNano(), 10) + + create := h.Run("token", "create", "--description", description, "--permission", "read") + assertOK(t, create) + tokenID := create.GetDataString("id") + if tokenID == "" { + t.Fatal("no token ID in create response") + } + if create.GetDataString("token") == "" { + t.Fatal("expected raw token value in create response") + } + deleted := false + t.Cleanup(func() { + if !deleted { + newHarness(t).Run("token", "delete", tokenID) + } + }) + + list := h.Run("token", "list") + assertOK(t, list) + found := false + for _, item := range list.GetDataArray() { + if mapValueString(asMap(item), "id") == tokenID { + found = true + break + } + } + if !found { + t.Fatalf("expected token list to include %q", tokenID) + } + + deleteResult := h.Run("token", "delete", tokenID) + assertOK(t, deleteResult) + deleted = true + if !deleteResult.GetDataBool("deleted") { + t.Fatal("expected deleted=true") + } +} diff --git a/internal/commands/columns.go b/internal/commands/columns.go index 18ffd67..9c9db9c 100644 --- a/internal/commands/columns.go +++ b/internal/commands/columns.go @@ -91,4 +91,11 @@ var ( {Header: "Created", Field: "created_at"}, {Header: "Updated", Field: "updated_at"}, } + + tokenColumns = render.Columns{ + {Header: "ID", Field: "id"}, + {Header: "Description", Field: "description"}, + {Header: "Permission", Field: "permission"}, + {Header: "Created", Field: "created_at"}, + } ) diff --git a/internal/commands/token.go b/internal/commands/token.go new file mode 100644 index 0000000..8a376e5 --- /dev/null +++ b/internal/commands/token.go @@ -0,0 +1,128 @@ +package commands + +import ( + "fmt" + + "github.com/basecamp/fizzy-sdk/go/pkg/generated" + "github.com/spf13/cobra" +) + +var tokenCmd = &cobra.Command{ + Use: "token", + Short: "Manage personal access tokens", + Long: "Commands for managing your personal access tokens.", +} + +var tokenListCmd = &cobra.Command{ + Use: "list", + Short: "List personal access tokens", + Long: "Lists your personal access tokens.", + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireAuthAndAccount(); err != nil { + return err + } + + ac := getSDKClient() + data, _, err := ac.AccessTokens().List(cmd.Context()) + if err != nil { + return convertSDKError(err) + } + + items := normalizeAny(data) + + count := dataCount(items) + summary := fmt.Sprintf("%d access tokens", count) + + breadcrumbs := []Breadcrumb{ + breadcrumb("create", "fizzy token create --description --permission ", "Create a token"), + breadcrumb("delete", "fizzy token delete ", "Delete a token"), + } + + printList(items, tokenColumns, summary, breadcrumbs) + return nil + }, +} + +var ( + tokenCreateDescription string + tokenCreatePermission string +) + +var tokenCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a personal access token", + Long: "Creates a new personal access token. The token value is shown once at creation and cannot be retrieved later.", + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireAuthAndAccount(); err != nil { + return err + } + + if tokenCreateDescription == "" { + return newRequiredFlagError("description") + } + if tokenCreatePermission == "" { + return newRequiredFlagError("permission") + } + + ac := getSDKClient() + req := &generated.CreateAccessTokenRequest{ + Description: tokenCreateDescription, + Permission: tokenCreatePermission, + } + raw, _, err := ac.AccessTokens().Create(cmd.Context(), req) + if err != nil { + return convertSDKError(err) + } + + result := normalizeAny(raw) + id := "" + if m, ok := result.(map[string]any); ok { + id = getStringField(m, "id") + } + + breadcrumbs := []Breadcrumb{ + breadcrumb("list", "fizzy token list", "List tokens"), + breadcrumb("delete", fmt.Sprintf("fizzy token delete %s", id), "Delete this token"), + } + + notice := "Save the token now — it will not be shown again." + printMutation(result, notice, breadcrumbs) + return nil + }, +} + +var tokenDeleteCmd = &cobra.Command{ + Use: "delete TOKEN_ID", + Short: "Delete a personal access token", + Long: "Deletes a personal access token by ID.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireAuthAndAccount(); err != nil { + return err + } + + ac := getSDKClient() + if _, err := ac.AccessTokens().Delete(cmd.Context(), args[0]); err != nil { + return convertSDKError(err) + } + + breadcrumbs := []Breadcrumb{ + breadcrumb("list", "fizzy token list", "List remaining tokens"), + } + + printMutation(map[string]any{"deleted": true, "id": args[0]}, "", breadcrumbs) + return nil + }, +} + +func init() { + rootCmd.AddCommand(tokenCmd) + + tokenCmd.AddCommand(tokenListCmd) + + tokenCreateCmd.Flags().StringVar(&tokenCreateDescription, "description", "", "Token description (required)") + tokenCreateCmd.Flags().StringVar(&tokenCreatePermission, "permission", "", "Token permission (required)") + tokenCmd.AddCommand(tokenCreateCmd) + + tokenCmd.AddCommand(tokenDeleteCmd) +} From 9b86152630cab2cf87708da00d4e446830e97838 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 29 Apr 2026 16:53:49 -0400 Subject: [PATCH 2/5] Address PR review feedback on token commands - Use requireAuth + requireSDK instead of requireAuthAndAccount: the /my/access_tokens endpoint is on the root SDK client (not account-scoped), so requiring an account blocks valid users who haven't selected one (matches the pattern used by `identity show`). - Gate the "delete" breadcrumb on a non-empty id so we don't render `fizzy token delete ` when the API response has no id. - E2e: register the cleanup hook before the second t.Fatal check so a missing-token-value failure can't strand the created token. --- e2e/cli_tests/token_test.go | 6 +++--- internal/commands/token.go | 19 +++++++++++++++---- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/e2e/cli_tests/token_test.go b/e2e/cli_tests/token_test.go index c70473c..6abd99f 100644 --- a/e2e/cli_tests/token_test.go +++ b/e2e/cli_tests/token_test.go @@ -16,15 +16,15 @@ func TestAccessTokenCRUD(t *testing.T) { if tokenID == "" { t.Fatal("no token ID in create response") } - if create.GetDataString("token") == "" { - t.Fatal("expected raw token value in create response") - } deleted := false t.Cleanup(func() { if !deleted { newHarness(t).Run("token", "delete", tokenID) } }) + if create.GetDataString("token") == "" { + t.Fatal("expected raw token value in create response") + } list := h.Run("token", "list") assertOK(t, list) diff --git a/internal/commands/token.go b/internal/commands/token.go index 8a376e5..128d1e0 100644 --- a/internal/commands/token.go +++ b/internal/commands/token.go @@ -18,7 +18,10 @@ var tokenListCmd = &cobra.Command{ Short: "List personal access tokens", Long: "Lists your personal access tokens.", RunE: func(cmd *cobra.Command, args []string) error { - if err := requireAuthAndAccount(); err != nil { + if err := requireAuth(); err != nil { + return err + } + if err := requireSDK(); err != nil { return err } @@ -53,7 +56,10 @@ var tokenCreateCmd = &cobra.Command{ Short: "Create a personal access token", Long: "Creates a new personal access token. The token value is shown once at creation and cannot be retrieved later.", RunE: func(cmd *cobra.Command, args []string) error { - if err := requireAuthAndAccount(); err != nil { + if err := requireAuth(); err != nil { + return err + } + if err := requireSDK(); err != nil { return err } @@ -82,7 +88,9 @@ var tokenCreateCmd = &cobra.Command{ breadcrumbs := []Breadcrumb{ breadcrumb("list", "fizzy token list", "List tokens"), - breadcrumb("delete", fmt.Sprintf("fizzy token delete %s", id), "Delete this token"), + } + if id != "" { + breadcrumbs = append(breadcrumbs, breadcrumb("delete", fmt.Sprintf("fizzy token delete %s", id), "Delete this token")) } notice := "Save the token now — it will not be shown again." @@ -97,7 +105,10 @@ var tokenDeleteCmd = &cobra.Command{ Long: "Deletes a personal access token by ID.", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - if err := requireAuthAndAccount(); err != nil { + if err := requireAuth(); err != nil { + return err + } + if err := requireSDK(); err != nil { return err } From 2ff3806b00c726dcc457c29410fa62bca2ec01cb Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 29 Apr 2026 21:37:29 -0400 Subject: [PATCH 3/5] Add token command to human catalog --- internal/commands/commands.go | 2 +- internal/commands/commands_test.go | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/internal/commands/commands.go b/internal/commands/commands.go index ca164f9..ab79fbb 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -33,7 +33,7 @@ var commandCatalogTitles = map[string]string{ var commandCatalogGroups = map[string][]string{ "core": {"activity", "board", "card", "column", "comment", "search", "step"}, "collaboration": {"notification", "pin", "reaction", "tag", "user"}, - "admin": {"auth", "account", "identity", "webhook", "upload", "migrate"}, + "admin": {"auth", "account", "identity", "token", "webhook", "upload", "migrate"}, "utilities": {"setup", "signup", "completion", "doctor", "config", "skill", "commands", "version"}, } diff --git a/internal/commands/commands_test.go b/internal/commands/commands_test.go index 586589c..e478229 100644 --- a/internal/commands/commands_test.go +++ b/internal/commands/commands_test.go @@ -70,6 +70,25 @@ func TestCommandsFilterFindsActivity(t *testing.T) { } } +func TestCommandsFilterFindsToken(t *testing.T) { + mock := NewMockClient() + SetTestModeWithSDK(mock) + SetTestFormat(output.FormatStyled) + defer resetTest() + + if err := commandsCmd.RunE(commandsCmd, []string{"token"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + raw := TestOutput() + if !strings.Contains(raw, "token") || !strings.Contains(raw, "create, delete, list") { + t.Fatalf("expected filtered catalog to include token actions, got:\n%s", raw) + } + if strings.Contains(raw, "No commands match") { + t.Fatalf("expected token to be discoverable, got:\n%s", raw) + } +} + func TestCommandsJSONOutputReturnsStructuredCatalog(t *testing.T) { mock := NewMockClient() result := SetTestModeWithSDK(mock) From f73886cb04a61522a4a9993e2bb3382fe6bc3aeb Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 29 Apr 2026 22:04:38 -0400 Subject: [PATCH 4/5] Fix token command validation and help --- internal/commands/help.go | 2 +- internal/commands/help_test.go | 2 +- internal/commands/token.go | 2 ++ internal/commands/token_test.go | 21 +++++++++++++++++++++ 4 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 internal/commands/token_test.go diff --git a/internal/commands/help.go b/internal/commands/help.go index 2d35de0..ef85932 100644 --- a/internal/commands/help.go +++ b/internal/commands/help.go @@ -379,7 +379,7 @@ var rootCommandGroupTitles = map[string]string{ } var rootCommandGroups = map[string][]string{ - "core": {"auth", "activity", "board", "card", "search"}, + "core": {"auth", "token", "activity", "board", "card", "search"}, "collaboration": {"comment", "notification"}, "getting-started": {"setup", "signup"}, "discover": {"doctor", "config", "commands", "version"}, diff --git a/internal/commands/help_test.go b/internal/commands/help_test.go index 33b8a98..7829b79 100644 --- a/internal/commands/help_test.go +++ b/internal/commands/help_test.go @@ -15,7 +15,7 @@ func TestRenderRootHelp(t *testing.T) { renderHelp(rootCmd, &buf) out := buf.String() - for _, want := range []string{"CORE COMMANDS", "activity", "GETTING STARTED", "DISCOVER", "FLAGS", "--profile", "LEARN MORE", "Use `fizzy commands` to see the full command catalog.", "implies --json"} { + for _, want := range []string{"CORE COMMANDS", "activity", "token", "GETTING STARTED", "DISCOVER", "FLAGS", "--profile", "LEARN MORE", "Use `fizzy commands` to see the full command catalog.", "implies --json"} { if !strings.Contains(out, want) { t.Fatalf("expected root help to contain %q, got:\n%s", want, out) } diff --git a/internal/commands/token.go b/internal/commands/token.go index 128d1e0..eef3e2c 100644 --- a/internal/commands/token.go +++ b/internal/commands/token.go @@ -17,6 +17,7 @@ var tokenListCmd = &cobra.Command{ Use: "list", Short: "List personal access tokens", Long: "Lists your personal access tokens.", + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { if err := requireAuth(); err != nil { return err @@ -55,6 +56,7 @@ var tokenCreateCmd = &cobra.Command{ Use: "create", Short: "Create a personal access token", Long: "Creates a new personal access token. The token value is shown once at creation and cannot be retrieved later.", + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { if err := requireAuth(); err != nil { return err diff --git a/internal/commands/token_test.go b/internal/commands/token_test.go new file mode 100644 index 0000000..d1c762d --- /dev/null +++ b/internal/commands/token_test.go @@ -0,0 +1,21 @@ +package commands + +import "testing" + +func TestTokenListRejectsUnexpectedArgs(t *testing.T) { + if err := tokenListCmd.Args(tokenListCmd, []string{"extra"}); err == nil { + t.Fatal("expected token list to reject unexpected positional args") + } + if err := tokenListCmd.Args(tokenListCmd, []string{}); err != nil { + t.Fatalf("expected token list to allow no positional args, got %v", err) + } +} + +func TestTokenCreateRejectsUnexpectedArgs(t *testing.T) { + if err := tokenCreateCmd.Args(tokenCreateCmd, []string{"extra"}); err == nil { + t.Fatal("expected token create to reject unexpected positional args") + } + if err := tokenCreateCmd.Args(tokenCreateCmd, []string{}); err != nil { + t.Fatalf("expected token create to allow no positional args, got %v", err) + } +} From 5b0047545f4073b259da51d1f3401545ed780f85 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Thu, 30 Apr 2026 08:41:56 -0400 Subject: [PATCH 5/5] Harden token e2e cleanup --- e2e/cli_tests/token_test.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/e2e/cli_tests/token_test.go b/e2e/cli_tests/token_test.go index 6abd99f..e346450 100644 --- a/e2e/cli_tests/token_test.go +++ b/e2e/cli_tests/token_test.go @@ -4,6 +4,8 @@ import ( "strconv" "testing" "time" + + "github.com/basecamp/fizzy-cli/e2e/harness" ) func TestAccessTokenCRUD(t *testing.T) { @@ -18,8 +20,16 @@ func TestAccessTokenCRUD(t *testing.T) { } deleted := false t.Cleanup(func() { - if !deleted { - newHarness(t).Run("token", "delete", tokenID) + if deleted { + return + } + cleanupDelete := newHarness(t).Run("token", "delete", tokenID) + if cleanupDelete.ExitCode != harness.ExitSuccess { + t.Errorf("cleanup failed deleting token %q: exit=%d stdout=%s stderr=%s", tokenID, cleanupDelete.ExitCode, cleanupDelete.Stdout, cleanupDelete.Stderr) + return + } + if !cleanupDelete.GetDataBool("deleted") { + t.Errorf("cleanup delete for token %q did not report deleted=true", tokenID) } }) if create.GetDataString("token") == "" {