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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ Both `BASECAMP_OAUTH_CLIENT_ID` and `BASECAMP_OAUTH_CLIENT_SECRET` must be set t

`basecamp` works with any AI agent that can run shell commands.

**Claude Code:** `claude plugin install basecamp` — installs the plugin with skills, hooks, and agent workflow support.
**Claude Code:** `basecamp setup claude` — installs the plugin with skills, hooks, and agent workflow support.

**Other agents:** Point your agent at [`skills/basecamp/SKILL.md`](skills/basecamp/SKILL.md) for Basecamp workflow coverage.

Expand Down
4 changes: 2 additions & 2 deletions install.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,10 @@ EXECUTE NOW: Start with Step 1. Mark TODO items complete as you go. Stop when `b
### Claude Code

```bash
claude plugin install basecamp
basecamp setup claude
```

This installs the plugin with skills, hooks, and agent workflow support.
This registers the marketplace and installs the plugin with skills, hooks, and agent workflow support.

### Other Agents

Expand Down
2 changes: 2 additions & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,8 @@ func Execute() {
cmd.AddCommand(commands.NewReportsCmd())
cmd.AddCommand(commands.NewCompletionCmd())
cmd.AddCommand(commands.NewSetupCmd())
cmd.AddCommand(commands.NewLoginCmd())
cmd.AddCommand(commands.NewLogoutCmd())
cmd.AddCommand(commands.NewDoctorCmd())
cmd.AddCommand(commands.NewUpgradeCmd())
cmd.AddCommand(commands.NewMigrateCmd())
Expand Down
209 changes: 121 additions & 88 deletions internal/commands/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package commands

import (
"fmt"
"io"
"os"
"strings"
"time"
Expand All @@ -11,6 +12,7 @@ import (

"github.com/basecamp/basecamp-cli/internal/appctx"
"github.com/basecamp/basecamp-cli/internal/auth"
"github.com/basecamp/basecamp-cli/internal/harness"
"github.com/basecamp/basecamp-cli/internal/output"
"github.com/basecamp/basecamp-cli/internal/tui"
)
Expand All @@ -35,97 +37,11 @@ func NewAuthCmd() *cobra.Command {
}

func newAuthLoginCmd() *cobra.Command {
var scope string
var noBrowser bool

cmd := &cobra.Command{
Use: "login",
Short: "Authenticate with Basecamp",
Long: "Start the OAuth flow to authenticate with Basecamp.",
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 != "" && scope != "read" && scope != "full" {
return output.ErrUsage("Invalid scope. Use 'read' or 'full'")
}

if scope == "" {
scope = "read"
}

w := cmd.OutOrStdout()
r := output.NewRendererWithTheme(w, false, tui.ResolveTheme())

if app.Config.ActiveProfile != "" {
fmt.Fprintln(w, r.Summary.Render(fmt.Sprintf("Starting authentication for profile %q...", app.Config.ActiveProfile)))
} else {
fmt.Fprintln(w, r.Summary.Render("Starting Basecamp authentication..."))
}
if scope == "read" {
fmt.Fprintln(w, r.Muted.Render("Scope: read-only (use --scope full for write access)"))
} else {
fmt.Fprintln(w, r.Muted.Render("Scope: full (read and write access)"))
}

if err := app.Auth.Login(cmd.Context(), auth.LoginOptions{
Scope: scope,
NoBrowser: noBrowser,
Logger: func(msg string) { fmt.Fprintln(w, msg) },
}); err != nil {
return err
}

fmt.Fprintln(w)
fmt.Fprintln(w, r.Success.Render("Authentication successful!"))

// Try to fetch and store user profile
resp, err := app.SDK.Get(cmd.Context(), "/my/profile.json")
if err == nil {
var profile struct {
ID int `json:"id"`
Name string `json:"name"`
}
if err := resp.UnmarshalData(&profile); err == nil {
if err := app.Auth.SetUserID(fmt.Sprintf("%d", profile.ID)); err == nil {
fmt.Fprintln(w, r.Data.Render(fmt.Sprintf("Logged in as: %s", profile.Name)))
}
}
}

return nil
},
}

cmd.Flags().StringVar(&scope, "scope", "", "OAuth scope: 'read' (default) or 'full'")
cmd.Flags().BoolVar(&noBrowser, "no-browser", false, "Don't open browser automatically")

return cmd
return buildLoginCmd("login")
}

func newAuthLogoutCmd() *cobra.Command {
return &cobra.Command{
Use: "logout",
Short: "Remove stored credentials",
Long: "Remove stored authentication credentials for the current origin.",
RunE: func(cmd *cobra.Command, args []string) error {
app := appctx.FromContext(cmd.Context())
if app == nil {
return fmt.Errorf("app not initialized")
}

if err := app.Auth.Logout(); err != nil {
return err
}

return app.OK(map[string]string{
"status": "logged_out",
}, output.WithSummary("Successfully logged out"))
},
}
return buildLogoutCmd("logout")
}

func newAuthStatusCmd() *cobra.Command {
Expand Down Expand Up @@ -287,3 +203,120 @@ Output modes:

return cmd
}

// NewLoginCmd creates the top-level login shortcut.
func NewLoginCmd() *cobra.Command {
return buildLoginCmd("login")
}

// NewLogoutCmd creates the top-level logout shortcut.
func NewLogoutCmd() *cobra.Command {
return buildLogoutCmd("logout")
}

// buildLoginCmd constructs a login command with the given Use name.
// Shared by newAuthLoginCmd ("login" under auth) and NewLoginCmd (top-level).
func buildLoginCmd(use string) *cobra.Command {
var scope string
var noBrowser bool

cmd := &cobra.Command{
Use: use,
Short: "Authenticate with Basecamp",
Long: "Start the OAuth flow to authenticate with Basecamp.",
RunE: func(cmd *cobra.Command, args []string) error {
app := appctx.FromContext(cmd.Context())
if app == nil {
return fmt.Errorf("app not initialized")
}

if scope != "" && scope != "read" && scope != "full" {
return output.ErrUsage("Invalid scope. Use 'read' or 'full'")
}

if scope == "" {
scope = "read"
}

w := cmd.OutOrStdout()
r := output.NewRendererWithTheme(w, false, tui.ResolveTheme())

if app.Config.ActiveProfile != "" {
fmt.Fprintln(w, r.Summary.Render(fmt.Sprintf("Starting authentication for profile %q...", app.Config.ActiveProfile)))
} else {
fmt.Fprintln(w, r.Summary.Render("Starting Basecamp authentication..."))
}
if scope == "read" {
fmt.Fprintln(w, r.Muted.Render("Scope: read-only (use --scope full for write access)"))
} else {
fmt.Fprintln(w, r.Muted.Render("Scope: full (read and write access)"))
}

if err := app.Auth.Login(cmd.Context(), auth.LoginOptions{
Scope: scope,
NoBrowser: noBrowser,
Logger: func(msg string) { fmt.Fprintln(w, msg) },
}); err != nil {
return err
}

fmt.Fprintln(w)
fmt.Fprintln(w, r.Success.Render("Authentication successful!"))

resp, err := app.SDK.Get(cmd.Context(), "/my/profile.json")
if err == nil {
var profile struct {
ID int `json:"id"`
Name string `json:"name"`
}
if err := resp.UnmarshalData(&profile); err == nil {
if err := app.Auth.SetUserID(fmt.Sprintf("%d", profile.ID)); err == nil {
fmt.Fprintln(w, r.Data.Render(fmt.Sprintf("Logged in as: %s", profile.Name)))
}
}
}

printClaudeNudge(w, r)

return nil
},
}

cmd.Flags().StringVar(&scope, "scope", "", "OAuth scope: 'read' (default) or 'full'")
cmd.Flags().BoolVar(&noBrowser, "no-browser", false, "Don't open browser automatically")

return cmd
}

// buildLogoutCmd constructs a logout command with the given Use name.
// Shared by newAuthLogoutCmd ("logout" under auth) and NewLogoutCmd (top-level).
func buildLogoutCmd(use string) *cobra.Command {
return &cobra.Command{
Use: use,
Short: "Remove stored credentials",
Long: "Remove stored authentication credentials for the current origin.",
RunE: func(cmd *cobra.Command, args []string) error {
app := appctx.FromContext(cmd.Context())
if app == nil {
return fmt.Errorf("app not initialized")
}

if err := app.Auth.Logout(); err != nil {
return err
}

return app.OK(map[string]string{
"status": "logged_out",
}, output.WithSummary("Successfully logged out"))
},
}
}

// printClaudeNudge prints a hint about Claude Code plugin setup after login.
func printClaudeNudge(w io.Writer, r *output.Renderer) {
if harness.IsPluginNeeded() {
fmt.Fprintln(w)
fmt.Fprintln(w, r.Muted.Render(" Claude Code detected. Connect it to Basecamp:"))
fmt.Fprintln(w, r.Data.Render(" basecamp setup claude"))
}
}
2 changes: 2 additions & 0 deletions internal/commands/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ func commandCategories() []CommandCategory {
Name: "Auth & Config",
Commands: []CommandInfo{
{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"},
{Name: "config", Category: "auth", Description: "Manage configuration", Actions: []string{"show", "init", "set", "unset", "project", "trust", "untrust"}},
{Name: "me", Category: "auth", Description: "Show current user profile"},
{Name: "setup", Category: "auth", Description: "Interactive first-time setup"},
Expand Down
2 changes: 2 additions & 0 deletions internal/commands/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ func TestCatalogMatchesRegisteredCommands(t *testing.T) {
root.AddCommand(commands.NewReportsCmd())
root.AddCommand(commands.NewCompletionCmd())
root.AddCommand(commands.NewSetupCmd())
root.AddCommand(commands.NewLoginCmd())
root.AddCommand(commands.NewLogoutCmd())
root.AddCommand(commands.NewDoctorCmd())
root.AddCommand(commands.NewUpgradeCmd())
root.AddCommand(commands.NewMigrateCmd())
Expand Down
5 changes: 3 additions & 2 deletions internal/commands/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ func newConfigSetCmd() *cobra.Command {
Long: `Set a configuration value in the local or global config file.

Valid keys: account_id, project_id, todolist_id, base_url, cache_dir, cache_enabled,
format, scope, default_profile, hints, stats, verbose`,
format, scope, default_profile, hints, stats, verbose, onboarded`,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
app := appctx.FromContext(cmd.Context())
Expand All @@ -192,6 +192,7 @@ Valid keys: account_id, project_id, todolist_id, base_url, cache_dir, cache_enab
"hints": true,
"stats": true,
"verbose": true,
"onboarded": true,
}
if !validKeys[key] {
names := make([]string, 0, len(validKeys))
Expand Down Expand Up @@ -243,7 +244,7 @@ Valid keys: account_id, project_id, todolist_id, base_url, cache_dir, cache_enab
// Set value with type-specific validation
valueOut := value
switch key {
case "cache_enabled", "hints", "stats":
case "cache_enabled", "hints", "stats", "onboarded":
boolVal, ok := parseBoolFlag(value)
if !ok {
return output.ErrUsage(fmt.Sprintf("%s must be true/false (or 1/0)", key))
Expand Down
6 changes: 6 additions & 0 deletions internal/commands/quickstart.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"github.com/spf13/cobra"

"github.com/basecamp/basecamp-cli/internal/appctx"
"github.com/basecamp/basecamp-cli/internal/harness"
"github.com/basecamp/basecamp-cli/internal/output"
"github.com/basecamp/basecamp-cli/internal/version"
)
Expand Down Expand Up @@ -129,6 +130,11 @@ func runQuickStart(cmd *cobra.Command, args []string) error {
Action: "authenticate", Cmd: "basecamp auth login", Description: "Login",
})
}
if harness.IsPluginNeeded() {
breadcrumbs = append(breadcrumbs, output.Breadcrumb{
Action: "setup_claude", Cmd: "basecamp setup claude", Description: "Connect Claude to Basecamp",
})
}

return app.OK(json.RawMessage(data),
output.WithSummary(summary),
Expand Down
Loading
Loading