Skip to content
Merged
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
61 changes: 61 additions & 0 deletions .surface
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
CMD basecamp
CMD basecamp accounts
CMD basecamp accounts list
CMD basecamp accounts use
CMD basecamp api
CMD basecamp api delete
CMD basecamp api get
Expand Down Expand Up @@ -300,6 +303,61 @@ FLAG basecamp --stats type=bool
FLAG basecamp --styled type=bool
FLAG basecamp --todolist type=string
FLAG basecamp --verbose type=count
FLAG basecamp accounts --account type=string
FLAG basecamp accounts --agent type=bool
FLAG basecamp accounts --cache-dir type=string
FLAG basecamp accounts --count type=bool
FLAG basecamp accounts --hints type=bool
FLAG basecamp accounts --ids-only type=bool
FLAG basecamp accounts --json type=bool
FLAG basecamp accounts --markdown type=bool
FLAG basecamp accounts --md type=bool
FLAG basecamp accounts --no-hints type=bool
FLAG basecamp accounts --no-stats type=bool
FLAG basecamp accounts --profile type=string
FLAG basecamp accounts --project type=string
FLAG basecamp accounts --quiet type=bool
FLAG basecamp accounts --stats type=bool
FLAG basecamp accounts --styled type=bool
FLAG basecamp accounts --todolist type=string
FLAG basecamp accounts --verbose type=count
FLAG basecamp accounts list --account type=string
FLAG basecamp accounts list --agent type=bool
FLAG basecamp accounts list --cache-dir type=string
FLAG basecamp accounts list --count type=bool
FLAG basecamp accounts list --hints type=bool
FLAG basecamp accounts list --ids-only type=bool
FLAG basecamp accounts list --json type=bool
FLAG basecamp accounts list --markdown type=bool
FLAG basecamp accounts list --md type=bool
FLAG basecamp accounts list --no-hints type=bool
FLAG basecamp accounts list --no-stats type=bool
FLAG basecamp accounts list --profile type=string
FLAG basecamp accounts list --project type=string
FLAG basecamp accounts list --quiet type=bool
FLAG basecamp accounts list --stats type=bool
FLAG basecamp accounts list --styled type=bool
FLAG basecamp accounts list --todolist type=string
FLAG basecamp accounts list --verbose type=count
FLAG basecamp accounts use --account type=string
FLAG basecamp accounts use --agent type=bool
FLAG basecamp accounts use --cache-dir type=string
FLAG basecamp accounts use --count type=bool
FLAG basecamp accounts use --hints type=bool
FLAG basecamp accounts use --ids-only type=bool
FLAG basecamp accounts use --json type=bool
FLAG basecamp accounts use --markdown type=bool
FLAG basecamp accounts use --md type=bool
FLAG basecamp accounts use --no-hints type=bool
FLAG basecamp accounts use --no-stats type=bool
FLAG basecamp accounts use --profile type=string
FLAG basecamp accounts use --project type=string
FLAG basecamp accounts use --quiet type=bool
FLAG basecamp accounts use --scope type=string
FLAG basecamp accounts use --stats type=bool
FLAG basecamp accounts use --styled type=bool
FLAG basecamp accounts use --todolist type=string
FLAG basecamp accounts use --verbose type=count
FLAG basecamp api --account type=string
FLAG basecamp api --agent type=bool
FLAG basecamp api --cache-dir type=string
Expand Down Expand Up @@ -6115,6 +6173,9 @@ FLAG basecamp webhooks update --todolist type=string
FLAG basecamp webhooks update --types type=string
FLAG basecamp webhooks update --url type=string
FLAG basecamp webhooks update --verbose type=count
SUB basecamp accounts
SUB basecamp accounts list
SUB basecamp accounts use
SUB basecamp api
SUB basecamp api delete
SUB basecamp api get
Expand Down
1 change: 1 addition & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ func Execute() {
cmd := NewRootCmd()

// Add subcommands
cmd.AddCommand(commands.NewAccountsCmd())
cmd.AddCommand(commands.NewAuthCmd())
cmd.AddCommand(commands.NewProjectsCmd())
cmd.AddCommand(commands.NewTodosCmd())
Expand Down
163 changes: 163 additions & 0 deletions internal/commands/accounts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package commands

import (
"fmt"
"strconv"

"github.com/spf13/cobra"

"github.com/basecamp/basecamp-cli/internal/appctx"
"github.com/basecamp/basecamp-cli/internal/output"
"github.com/basecamp/basecamp-cli/internal/tui/resolve"
)

// NewAccountsCmd creates the accounts command group.
func NewAccountsCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "accounts",
Aliases: []string{"account"},
Short: "Manage accounts",
Long: "List authorized Basecamp accounts and set the default.",
}

cmd.AddCommand(
newAccountsListCmd(),
newAccountsUseCmd(),
)

return cmd
}

func newAccountsListCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "List authorized accounts",
Long: "List all Basecamp accounts you have access to.",
RunE: func(cmd *cobra.Command, args []string) error {
Comment thread
jeremy marked this conversation as resolved.
app := appctx.FromContext(cmd.Context())
if app == nil {
return fmt.Errorf("app not initialized")
}

accounts, err := app.Resolve().ListAccounts(cmd.Context())
if err != nil {
return err
}

// Convert to a serializable format
type accountRow struct {
ID int64 `json:"id"`
Name string `json:"name"`
Href string `json:"href"`
}
rows := make([]accountRow, len(accounts))
for i, acct := range accounts {
rows[i] = accountRow{
ID: acct.ID,
Name: acct.Name,
Href: acct.HREF,
}
}

Comment thread
jeremy marked this conversation as resolved.
count := len(rows)
label := "accounts"
if count == 1 {
label = "account"
}

return app.OK(rows,
output.WithSummary(fmt.Sprintf("%d %s", count, label)),
output.WithBreadcrumbs(
output.Breadcrumb{
Action: "use",
Cmd: "basecamp accounts use <id>",
Description: "Set default account",
},
),
)
},
}

return cmd
}

func newAccountsUseCmd() *cobra.Command {
var scope string

cmd := &cobra.Command{
Use: "use <id>",
Short: "Set default account",
Long: "Set the default Basecamp account for CLI commands.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
app := appctx.FromContext(cmd.Context())
if app == nil {
return fmt.Errorf("app not initialized")
}

// Validate scope
if scope != "global" && scope != "local" {
return output.ErrUsage("--scope must be \"global\" or \"local\"")
}

accountIDStr := args[0]

// Validate it's a number
accountID, err := strconv.ParseInt(accountIDStr, 10, 64)
if err != nil {
return output.ErrUsage("Invalid account ID")
}

// Validate account exists
accounts, err := app.Resolve().ListAccounts(cmd.Context())
if err != nil {
return err
}

var found bool
var accountName string
for _, acct := range accounts {
if acct.ID == accountID {
found = true
accountName = acct.Name
break
}
}
if !found {
return output.ErrNotFound("account", accountIDStr)
}

// Persist the canonical account ID (e.g. "007" → "7")
canonicalID := strconv.FormatInt(accountID, 10)
if err := resolve.PersistValue("account_id", canonicalID, scope); err != nil {
return fmt.Errorf("failed to save account: %w", err)
}

summary := fmt.Sprintf("Default account set to %s (#%s, %s)", accountName, canonicalID, scope)

return app.OK(map[string]any{
"id": accountID,
"name": accountName,
"scope": scope,
},
output.WithSummary(summary),
output.WithBreadcrumbs(
output.Breadcrumb{
Action: "list",
Cmd: "basecamp accounts list",
Description: "List accounts",
},
output.Breadcrumb{
Action: "projects",
Cmd: "basecamp projects list",
Description: "List projects",
},
),
)
},
}

cmd.Flags().StringVar(&scope, "scope", "global", "Config scope (global or local)")

return cmd
}
1 change: 1 addition & 0 deletions internal/commands/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ func CommandCategories() []CommandCategory {
{
Name: "Auth & Config",
Commands: []CommandInfo{
{Name: "accounts", Category: "auth", Description: "Manage accounts", Actions: []string{"list", "use"}},
{Name: "auth", Category: "auth", Description: "Authenticate with Basecamp", Actions: []string{"login", "logout", "status", "refresh"}},
{Name: "login", Category: "auth", Description: "Authenticate with Basecamp"},
{Name: "logout", Category: "auth", Description: "Remove stored credentials"},
Expand Down
1 change: 1 addition & 0 deletions internal/commands/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ func TestCatalogMatchesRegisteredCommands(t *testing.T) {
// mirroring cli.Execute. Shared by TestCatalog and TestSurfaceSnapshot.
func buildRootWithAllCommands() *cobra.Command {
root := cli.NewRootCmd()
root.AddCommand(commands.NewAccountsCmd())
root.AddCommand(commands.NewAuthCmd())
root.AddCommand(commands.NewProjectsCmd())
root.AddCommand(commands.NewTodosCmd())
Expand Down
7 changes: 7 additions & 0 deletions internal/tui/resolve/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ func (r *Resolver) Account(ctx context.Context) (*ResolvedValue, error) {
accountID := fmt.Sprintf("%d", accounts[0].ID)
return &ResolvedValue{
Value: accountID,
Label: accounts[0].Name,
Source: SourceDefault,
}, nil
}
Comment thread
jeremy marked this conversation as resolved.
Expand All @@ -91,6 +92,7 @@ func (r *Resolver) Account(ctx context.Context) (*ResolvedValue, error) {

return &ResolvedValue{
Value: selected.ID,
Label: selected.Title,
Source: SourcePrompt,
}, nil
}
Expand All @@ -111,6 +113,11 @@ func (r *Resolver) AccountWithPersist(ctx context.Context) (*ResolvedValue, erro
return resolved, nil
}

// ListAccounts returns the list of available Basecamp accounts.
func (r *Resolver) ListAccounts(ctx context.Context) ([]basecamp.AuthorizedAccount, error) {
return r.fetchAccounts(ctx)
}

// fetchAccounts retrieves the list of available Basecamp accounts.
func (r *Resolver) fetchAccounts(ctx context.Context) ([]basecamp.AuthorizedAccount, error) {
// Check authentication
Expand Down
Loading