diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 2129662d7..291642c60 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -777,11 +777,13 @@ func authHelp() { fmt.Println("Login options:") fmt.Println(" --provider Provider to login with (openai, anthropic)") fmt.Println(" --device-code Use device code flow (for headless environments)") + fmt.Println(" --setup-token Use Claude CLI setup-token (anthropic only)") fmt.Println() fmt.Println("Examples:") fmt.Println(" picoclaw auth login --provider openai") fmt.Println(" picoclaw auth login --provider openai --device-code") fmt.Println(" picoclaw auth login --provider anthropic") + fmt.Println(" picoclaw auth login --provider anthropic --setup-token") fmt.Println(" picoclaw auth logout --provider openai") fmt.Println(" picoclaw auth status") } @@ -789,6 +791,7 @@ func authHelp() { func authLoginCmd() { provider := "" useDeviceCode := false + useSetupToken := false args := os.Args[3:] for i := 0; i < len(args); i++ { @@ -800,6 +803,8 @@ func authLoginCmd() { } case "--device-code": useDeviceCode = true + case "--setup-token": + useSetupToken = true } } @@ -813,13 +818,40 @@ func authLoginCmd() { case "openai": authLoginOpenAI(useDeviceCode) case "anthropic": - authLoginPasteToken(provider) + if useSetupToken { + authLoginSetupToken() + } else { + authLoginPasteToken(provider) + } default: fmt.Printf("Unsupported provider: %s\n", provider) fmt.Println("Supported providers: openai, anthropic") } } +func authLoginSetupToken() { + cred, err := auth.LoginSetupToken(os.Stdin) + if err != nil { + fmt.Printf("Login failed: %v\n", err) + os.Exit(1) + } + + if err := auth.SetCredential("anthropic", cred); err != nil { + fmt.Printf("Failed to save credentials: %v\n", err) + os.Exit(1) + } + + appCfg, err := loadConfig() + if err == nil { + appCfg.Providers.Anthropic.AuthMethod = "setup-token" + if err := config.SaveConfig(getConfigPath(), appCfg); err != nil { + fmt.Printf("Warning: could not update config: %v\n", err) + } + } + + fmt.Println("Setup-token saved for Anthropic!") +} + func authLoginOpenAI(useDeviceCode bool) { cfg := auth.OpenAIOAuthConfig() diff --git a/pkg/auth/token.go b/pkg/auth/token.go index a5a13ff03..4e69295cb 100644 --- a/pkg/auth/token.go +++ b/pkg/auth/token.go @@ -31,6 +31,40 @@ func LoginPasteToken(provider string, r io.Reader) (*AuthCredential, error) { }, nil } +const anthropicSetupTokenPrefix = "sk-ant-oat01-" + +func LoginSetupToken(r io.Reader) (*AuthCredential, error) { + fmt.Println("Paste your setup-token from Claude CLI (claude setup-token):") + fmt.Print("> ") + + scanner := bufio.NewScanner(r) + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("reading setup-token: %w", err) + } + return nil, fmt.Errorf("no input received") + } + + token := strings.TrimSpace(scanner.Text()) + if token == "" { + return nil, fmt.Errorf("setup-token cannot be empty") + } + + if !strings.HasPrefix(token, anthropicSetupTokenPrefix) { + return nil, fmt.Errorf("invalid setup-token: must start with %s", anthropicSetupTokenPrefix) + } + + if len(token) < 80 { + return nil, fmt.Errorf("invalid setup-token: too short (expected at least 80 characters)") + } + + return &AuthCredential{ + AccessToken: token, + Provider: "anthropic", + AuthMethod: "setup-token", + }, nil +} + func providerDisplayName(provider string) string { switch provider { case "anthropic": diff --git a/pkg/providers/claude_provider.go b/pkg/providers/claude_provider.go index ae6aca96d..7dd453333 100644 --- a/pkg/providers/claude_provider.go +++ b/pkg/providers/claude_provider.go @@ -13,14 +13,26 @@ import ( type ClaudeProvider struct { client *anthropic.Client tokenSource func() (string, error) + useAPIKey bool } func NewClaudeProvider(token string) *ClaudeProvider { client := anthropic.NewClient( - option.WithAuthToken(token), + option.WithAPIKey(token), option.WithBaseURL("https://api.anthropic.com"), ) - return &ClaudeProvider{client: &client} + return &ClaudeProvider{client: &client, useAPIKey: true} +} + +func NewClaudeProviderWithBearerToken(token string) *ClaudeProvider { + client := anthropic.NewClient( + option.WithAuthToken(token), + option.WithBaseURL("https://api.anthropic.com"), + option.WithHeader("anthropic-beta", "claude-code-20250219,oauth-2025-04-20"), + option.WithHeader("user-agent", "claude-cli/2.1.2 (external, cli)"), + option.WithHeader("x-app", "cli"), + ) + return &ClaudeProvider{client: &client, useAPIKey: false} } func NewClaudeProviderWithTokenSource(token string, tokenSource func() (string, error)) *ClaudeProvider { @@ -29,6 +41,12 @@ func NewClaudeProviderWithTokenSource(token string, tokenSource func() (string, return p } +func NewClaudeProviderWithTokenSourceBearer(token string, tokenSource func() (string, error)) *ClaudeProvider { + p := NewClaudeProviderWithBearerToken(token) + p.tokenSource = tokenSource + return p +} + func (p *ClaudeProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { var opts []option.RequestOption if p.tokenSource != nil { @@ -36,7 +54,11 @@ func (p *ClaudeProvider) Chat(ctx context.Context, messages []Message, tools []T if err != nil { return nil, fmt.Errorf("refreshing token: %w", err) } - opts = append(opts, option.WithAuthToken(tok)) + if p.useAPIKey { + opts = append(opts, option.WithAPIKey(tok)) + } else { + opts = append(opts, option.WithAuthToken(tok)) + } } params, err := buildClaudeParams(messages, tools, model, options) diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go index 17eb6214c..5eff3b144 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -205,6 +205,9 @@ func createClaudeAuthProvider() (LLMProvider, error) { if cred == nil { return nil, fmt.Errorf("no credentials for anthropic. Run: picoclaw auth login --provider anthropic") } + if cred.AuthMethod == "oauth" || cred.AuthMethod == "setup-token" { + return NewClaudeProviderWithTokenSourceBearer(cred.AccessToken, createClaudeTokenSource()), nil + } return NewClaudeProviderWithTokenSource(cred.AccessToken, createClaudeTokenSource()), nil } @@ -251,7 +254,7 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) { } case "anthropic", "claude": if cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != "" { - if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" { + if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" || cfg.Providers.Anthropic.AuthMethod == "setup-token" { return createClaudeAuthProvider() } apiKey = cfg.Providers.Anthropic.APIKey @@ -348,7 +351,7 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) { } case (strings.Contains(lowerModel, "claude") || strings.HasPrefix(model, "anthropic/")) && (cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != ""): - if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" { + if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" || cfg.Providers.Anthropic.AuthMethod == "setup-token" { return createClaudeAuthProvider() } apiKey = cfg.Providers.Anthropic.APIKey