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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
/tmp/
/tapes
/config.toml
/credentials.toml
/tapes.db
/tapes.db~
/tapes.demo.sqlite
Expand Down
197 changes: 197 additions & 0 deletions cmd/tapes/auth/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
// Package authcmder provides the auth command for storing API credentials.
package authcmder

import (
"bufio"
"errors"
"fmt"
"os"
"strings"

"github.com/spf13/cobra"
"golang.org/x/term"

"github.com/papercomputeco/tapes/pkg/credentials"
)

const authLongDesc string = `Store API credentials for LLM providers.

Credentials are stored in credentials.toml in the .tapes/ directory and
automatically injected as environment variables when launching agents
via tapes start.

For OpenAI, use a service account key (sk-svcacct-...) with "All"
permissions from platform.openai.com/api-keys. Personal project keys
(sk-proj-...) may lack the required API scopes for codex.

Supported providers: openai, anthropic

Examples:
tapes auth openai Prompt for OpenAI API key
tapes auth anthropic Prompt for Anthropic API key
tapes auth --list List stored credentials
tapes auth --remove openai Remove stored OpenAI credentials
echo $KEY | tapes auth openai Pipe API key from stdin`

const authShortDesc string = "Store API credentials for LLM providers"

func NewAuthCmd() *cobra.Command {
var listFlag bool
var removeFlag string

cmd := &cobra.Command{
Use: "auth [provider]",
Short: authShortDesc,
Long: authLongDesc,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
configDir, _ := cmd.Flags().GetString("config-dir")

switch {
case listFlag:
return runList(configDir)
case removeFlag != "":
return runRemove(removeFlag, configDir)
default:
if len(args) == 0 {
return fmt.Errorf("provider argument required\n\nSupported providers: %s",
strings.Join(credentials.SupportedProviders(), ", "))
}
return runAuth(args[0], configDir)
}
},
ValidArgsFunction: func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
return credentials.SupportedProviders(), cobra.ShellCompDirectiveNoFileComp
}
return nil, cobra.ShellCompDirectiveNoFileComp
},
}

cmd.Flags().BoolVar(&listFlag, "list", false, "List stored credentials")
cmd.Flags().StringVar(&removeFlag, "remove", "", "Remove stored credentials for a provider")

return cmd
}

func runAuth(provider, configDir string) error {
provider = strings.ToLower(strings.TrimSpace(provider))

if !credentials.IsSupportedProvider(provider) {
return fmt.Errorf("unsupported provider: %q\n\nSupported providers: %s",
provider, strings.Join(credentials.SupportedProviders(), ", "))
}

apiKey, err := readAPIKey(provider)
if err != nil {
return err
}

apiKey = strings.TrimSpace(apiKey)
if apiKey == "" {
return errors.New("API key cannot be empty")
}

mgr, err := credentials.NewManager(configDir)
if err != nil {
return fmt.Errorf("loading credentials: %w", err)
}

if err := mgr.SetKey(provider, apiKey); err != nil {
return err
}

envVar := credentials.EnvVarForProvider(provider)
fmt.Printf("Stored %s credentials (will be injected as %s)\n", provider, envVar)

if provider == "openai" {
if strings.HasPrefix(apiKey, "sk-proj-") {
fmt.Println("Warning: project keys (sk-proj-...) may lack required API scopes for codex.")
fmt.Println("Consider using a service account key (sk-svcacct-...) from platform.openai.com/api-keys.")
}
fmt.Println("Codex auth.json will be temporarily configured when running 'tapes start codex'.")
}

return nil
}

func runList(configDir string) error {
mgr, err := credentials.NewManager(configDir)
if err != nil {
return fmt.Errorf("loading credentials: %w", err)
}

providers, err := mgr.ListProviders()
if err != nil {
return err
}

if len(providers) == 0 {
fmt.Println("No stored credentials.")
fmt.Printf("\nUse 'tapes auth <provider>' to store credentials.\nSupported providers: %s\n",
strings.Join(credentials.SupportedProviders(), ", "))
return nil
}

fmt.Println("Stored credentials:")
for _, p := range providers {
envVar := credentials.EnvVarForProvider(p)
if envVar != "" {
fmt.Printf(" %s → %s\n", p, envVar)
} else {
fmt.Printf(" %s\n", p)
}
}

return nil
}

func runRemove(provider, configDir string) error {
provider = strings.ToLower(strings.TrimSpace(provider))

mgr, err := credentials.NewManager(configDir)
if err != nil {
return fmt.Errorf("loading credentials: %w", err)
}

if err := mgr.RemoveKey(provider); err != nil {
return err
}

fmt.Printf("Removed %s credentials.\n", provider)

return nil
}

// readAPIKey reads an API key from stdin. If stdin is a pipe, it reads the
// first line. Otherwise, it prompts interactively with hidden input.
func readAPIKey(provider string) (string, error) {
fi, err := os.Stdin.Stat()
if err != nil {
return "", fmt.Errorf("checking stdin: %w", err)
}

// Piped input
if (fi.Mode() & os.ModeCharDevice) == 0 {
scanner := bufio.NewScanner(os.Stdin)
if scanner.Scan() {
return scanner.Text(), nil
}
if err := scanner.Err(); err != nil {
return "", fmt.Errorf("reading stdin: %w", err)
}
return "", errors.New("no input received on stdin")
}

// Interactive terminal
envVar := credentials.EnvVarForProvider(provider)
fmt.Printf("Enter API key for %s (%s): ", provider, envVar)

keyBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println() // newline after hidden input
if err != nil {
return "", fmt.Errorf("reading API key: %w", err)
}

return string(keyBytes), nil
}
13 changes: 13 additions & 0 deletions cmd/tapes/auth/auth_suite_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package authcmder_test

import (
"testing"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

func TestAuth(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Auth Command Suite")
}
138 changes: 138 additions & 0 deletions cmd/tapes/auth/auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package authcmder_test

import (
"bytes"
"os"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/spf13/cobra"

authcmder "github.com/papercomputeco/tapes/cmd/tapes/auth"
"github.com/papercomputeco/tapes/pkg/credentials"
)

var _ = Describe("Auth Command", func() {
var tmpDir string

BeforeEach(func() {
var err error
tmpDir, err = os.MkdirTemp("", "auth-test-*")
Expect(err).NotTo(HaveOccurred())
})

AfterEach(func() {
os.RemoveAll(tmpDir)
})

Describe("NewAuthCmd", func() {
It("creates a command with expected properties", func() {
cmd := authcmder.NewAuthCmd()
Expect(cmd.Use).To(Equal("auth [provider]"))
Expect(cmd.Short).NotTo(BeEmpty())
})

It("has --list flag", func() {
cmd := authcmder.NewAuthCmd()
flag := cmd.Flags().Lookup("list")
Expect(flag).NotTo(BeNil())
})

It("has --remove flag", func() {
cmd := authcmder.NewAuthCmd()
flag := cmd.Flags().Lookup("remove")
Expect(flag).NotTo(BeNil())
})
})

Describe("--list flag", func() {
It("shows no credentials when none stored", func() {
cmd := authcmder.NewAuthCmd()
out := &bytes.Buffer{}
cmd.SetOut(out)
cmd.SetArgs([]string{"--list", "--config-dir", tmpDir})

cmd.PersistentFlags().String("config-dir", "", "Override path to .tapes/ config directory")

err := cmd.Execute()
Expect(err).NotTo(HaveOccurred())
})

It("lists stored credentials", func() {
mgr, err := credentials.NewManager(tmpDir)
Expect(err).NotTo(HaveOccurred())
err = mgr.SetKey("openai", "sk-test")
Expect(err).NotTo(HaveOccurred())

cmd := authcmder.NewAuthCmd()
out := &bytes.Buffer{}
cmd.SetOut(out)
cmd.PersistentFlags().String("config-dir", "", "Override path to .tapes/ config directory")
cmd.SetArgs([]string{"--list", "--config-dir", tmpDir})

err = cmd.Execute()
Expect(err).NotTo(HaveOccurred())
})
})

Describe("--remove flag", func() {
It("removes stored credentials", func() {
mgr, err := credentials.NewManager(tmpDir)
Expect(err).NotTo(HaveOccurred())
err = mgr.SetKey("openai", "sk-test")
Expect(err).NotTo(HaveOccurred())

cmd := authcmder.NewAuthCmd()
out := &bytes.Buffer{}
cmd.SetOut(out)
cmd.PersistentFlags().String("config-dir", "", "Override path to .tapes/ config directory")
cmd.SetArgs([]string{"--remove", "openai", "--config-dir", tmpDir})

err = cmd.Execute()
Expect(err).NotTo(HaveOccurred())

key, err := mgr.GetKey("openai")
Expect(err).NotTo(HaveOccurred())
Expect(key).To(BeEmpty())
})
})

Describe("provider argument validation", func() {
It("returns error when no provider given", func() {
cmd := authcmder.NewAuthCmd()
cmd.PersistentFlags().String("config-dir", "", "Override path to .tapes/ config directory")
cmd.SetArgs([]string{})

err := cmd.Execute()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("provider argument required"))
})

It("returns error for unsupported provider", func() {
cmd := authcmder.NewAuthCmd()
cmd.PersistentFlags().String("config-dir", "", "Override path to .tapes/ config directory")
cmd.SetIn(bytes.NewBufferString("sk-test\n"))
cmd.SetArgs([]string{"ollama", "--config-dir", tmpDir})

err := cmd.Execute()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("unsupported provider"))
})
})

Describe("shell completion", func() {
It("provides provider name completions", func() {
cmd := authcmder.NewAuthCmd()
completions, directive := cmd.ValidArgsFunction(cmd, []string{}, "")
Expect(completions).To(ConsistOf("openai", "anthropic"))
Expect(directive).To(Equal(cobra.ShellCompDirectiveNoFileComp))
})

It("provides no completions after first arg", func() {
cmd := authcmder.NewAuthCmd()
completions, directive := cmd.ValidArgsFunction(cmd, []string{"openai"}, "")
Expect(completions).To(BeNil())
Expect(directive).To(Equal(cobra.ShellCompDirectiveNoFileComp))
})
})
})
Loading