Skip to content
Open
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: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Changelog

## Unreleased
### Added
- System keychain support for secure API key storage via `sag keychain set/get/delete`. Supports macOS Keychain, Windows Credential Manager, and Linux Secret Service. (#6)

## 0.2.2 - 2026-01-24
### Fixed
Expand Down
26 changes: 24 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,31 @@ go install ./cmd/sag
Requires Go 1.24+.

## Configuration
- `ELEVENLABS_API_KEY` (required)

API key (in order of priority):
1. `--api-key` flag
2. `--api-key-file` flag or `ELEVENLABS_API_KEY_FILE`/`SAG_API_KEY_FILE` env vars
3. System keychain (see below)
4. `ELEVENLABS_API_KEY` or `SAG_API_KEY` env vars

### System Keychain (recommended)
Store your API key securely in the system keychain:
```bash
sag keychain set # prompts for API key
sag keychain status # check if key is stored
sag keychain delete # remove from keychain
```

Supported backends:
- **macOS**: Keychain
- **Windows**: Credential Manager
- **Linux**: Secret Service (GNOME Keyring)

### Other options
- `--api-key-file` or `ELEVENLABS_API_KEY_FILE`/`SAG_API_KEY_FILE` to load the key from a file
- Optional defaults: `ELEVENLABS_VOICE_ID` or `SAG_VOICE_ID`
- `ELEVENLABS_API_KEY` or `SAG_API_KEY` environment variables

Optional defaults: `ELEVENLABS_VOICE_ID` or `SAG_VOICE_ID`

## Usage

Expand Down
5 changes: 4 additions & 1 deletion cmd/api_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,17 @@ func ensureAPIKey() error {
}
cfg.APIKey = key
}
if cfg.APIKey == "" {
cfg.APIKey = getAPIKeyFromKeychain()
}
if cfg.APIKey == "" {
cfg.APIKey = os.Getenv("ELEVENLABS_API_KEY")
}
if cfg.APIKey == "" {
cfg.APIKey = os.Getenv("SAG_API_KEY")
}
if cfg.APIKey == "" {
return fmt.Errorf("missing ElevenLabs API key (set --api-key, --api-key-file, or ELEVENLABS_API_KEY)")
return fmt.Errorf("missing ElevenLabs API key (set --api-key, --api-key-file, keychain, or ELEVENLABS_API_KEY)")
}
return nil
}
Expand Down
106 changes: 106 additions & 0 deletions cmd/keychain.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package cmd

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

"github.com/spf13/cobra"
"github.com/zalando/go-keyring"
)

const (
keychainService = "sag"
keychainUser = "elevenlabs-api-key"
)

var keychainCmd = &cobra.Command{
Use: "keychain",
Short: "Manage API key in system keychain",
Long: "Store, retrieve, or delete the ElevenLabs API key from the system keychain.\n\nSupported backends:\n - macOS: Keychain\n - Windows: Credential Manager\n - Linux: Secret Service (GNOME Keyring)",
}

var keychainSetCmd = &cobra.Command{
Use: "set",
Short: "Store API key in system keychain",
Long: "Store the ElevenLabs API key securely in the system keychain.\nYou will be prompted to enter the key (input is hidden).",
RunE: func(_ *cobra.Command, args []string) error {
var apiKey string
if len(args) > 0 {
apiKey = args[0]
} else {
fmt.Print("Enter ElevenLabs API key: ")
reader := bufio.NewReader(os.Stdin)
input, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("failed to read input: %w", err)
}
apiKey = strings.TrimSpace(input)
}

if apiKey == "" {
return fmt.Errorf("API key cannot be empty")
}

if err := keyring.Set(keychainService, keychainUser, apiKey); err != nil {
return fmt.Errorf("failed to store API key in keychain: %w", err)
}

fmt.Println("API key stored in system keychain")
return nil
},
}

var keychainStatusCmd = &cobra.Command{
Use: "status",
Short: "Check if API key is stored in system keychain",
Long: "Check whether an ElevenLabs API key is stored in the system keychain.",
RunE: func(_ *cobra.Command, _ []string) error {
_, err := keyring.Get(keychainService, keychainUser)
if err != nil {
if err == keyring.ErrNotFound {
fmt.Println("No API key stored in keychain")
return nil
}
return fmt.Errorf("failed to check keychain: %w", err)
}

fmt.Println("API key is stored in keychain")
return nil
},
}

var keychainDeleteCmd = &cobra.Command{
Use: "delete",
Short: "Delete API key from system keychain",
Long: "Remove the ElevenLabs API key from the system keychain.",
RunE: func(_ *cobra.Command, _ []string) error {
if err := keyring.Delete(keychainService, keychainUser); err != nil {
if err == keyring.ErrNotFound {
return fmt.Errorf("no API key found in keychain")
}
return fmt.Errorf("failed to delete API key from keychain: %w", err)
}

fmt.Println("API key deleted from system keychain")
return nil
},
}

func init() {
keychainCmd.AddCommand(keychainSetCmd)
keychainCmd.AddCommand(keychainStatusCmd)
keychainCmd.AddCommand(keychainDeleteCmd)
rootCmd.AddCommand(keychainCmd)
}

// getAPIKeyFromKeychain attempts to retrieve the API key from the system keychain.
// Returns empty string if not found or on error.
func getAPIKeyFromKeychain() string {
secret, err := keyring.Get(keychainService, keychainUser)
if err != nil {
return ""
}
return secret
}
130 changes: 130 additions & 0 deletions cmd/keychain_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package cmd

import (
"os"
"testing"

"github.com/zalando/go-keyring"
)

func TestGetAPIKeyFromKeychain(t *testing.T) {
keyring.MockInit()

// Test empty keychain returns empty string
key := getAPIKeyFromKeychain()
if key != "" {
t.Fatalf("expected empty string for empty keychain, got %q", key)
}

// Store a key and verify it can be retrieved
testKey := "test-api-key-12345"
if err := keyring.Set(keychainService, keychainUser, testKey); err != nil {
t.Fatalf("failed to set keychain key: %v", err)
}

key = getAPIKeyFromKeychain()
if key != testKey {
t.Fatalf("expected %q, got %q", testKey, key)
}

// Clean up
_ = keyring.Delete(keychainService, keychainUser)
}

func TestEnsureAPIKeyFromKeychain(t *testing.T) {
keyring.MockInit()
defer keepEnv(t)()

cfg.APIKey = ""
cfg.APIKeyFile = ""
_ = os.Unsetenv("ELEVENLABS_API_KEY")
_ = os.Unsetenv("SAG_API_KEY")
_ = os.Unsetenv("ELEVENLABS_API_KEY_FILE")
_ = os.Unsetenv("SAG_API_KEY_FILE")

// Store key in mock keychain
testKey := "keychain-api-key"
if err := keyring.Set(keychainService, keychainUser, testKey); err != nil {
t.Fatalf("failed to set keychain key: %v", err)
}

if err := ensureAPIKey(); err != nil {
t.Fatalf("ensureAPIKey error: %v", err)
}
if cfg.APIKey != testKey {
t.Fatalf("expected keychain key to be used, got %q", cfg.APIKey)
}

// Clean up
_ = keyring.Delete(keychainService, keychainUser)
}

func TestKeychainPriorityAfterFile(t *testing.T) {
keyring.MockInit()
defer keepEnv(t)()

cfg.APIKey = ""
cfg.APIKeyFile = ""
_ = os.Unsetenv("ELEVENLABS_API_KEY")
_ = os.Unsetenv("SAG_API_KEY")
_ = os.Unsetenv("ELEVENLABS_API_KEY_FILE")
_ = os.Unsetenv("SAG_API_KEY_FILE")

// Create temp file with API key
tmp, err := os.CreateTemp("", "sag_api_key")
if err != nil {
t.Fatalf("temp file: %v", err)
}
defer func() { _ = os.Remove(tmp.Name()) }()
if _, err := tmp.WriteString("file-key\n"); err != nil {
t.Fatalf("write temp: %v", err)
}
if err := tmp.Close(); err != nil {
t.Fatalf("close temp: %v", err)
}

// Store key in mock keychain
if err := keyring.Set(keychainService, keychainUser, "keychain-key"); err != nil {
t.Fatalf("failed to set keychain key: %v", err)
}

// File should take priority over keychain
cfg.APIKeyFile = tmp.Name()
if err := ensureAPIKey(); err != nil {
t.Fatalf("ensureAPIKey error: %v", err)
}
if cfg.APIKey != "file-key" {
t.Fatalf("expected file key to take priority, got %q", cfg.APIKey)
}

// Clean up
_ = keyring.Delete(keychainService, keychainUser)
}

func TestKeychainPriorityOverEnv(t *testing.T) {
keyring.MockInit()
defer keepEnv(t)()

cfg.APIKey = ""
cfg.APIKeyFile = ""
_ = os.Setenv("ELEVENLABS_API_KEY", "env-key")
_ = os.Unsetenv("SAG_API_KEY")
_ = os.Unsetenv("ELEVENLABS_API_KEY_FILE")
_ = os.Unsetenv("SAG_API_KEY_FILE")

// Store key in mock keychain
if err := keyring.Set(keychainService, keychainUser, "keychain-key"); err != nil {
t.Fatalf("failed to set keychain key: %v", err)
}

// Keychain should take priority over env
if err := ensureAPIKey(); err != nil {
t.Fatalf("ensureAPIKey error: %v", err)
}
if cfg.APIKey != "keychain-key" {
t.Fatalf("expected keychain key to take priority over env, got %q", cfg.APIKey)
}

// Clean up
_ = keyring.Delete(keychainService, keychainUser)
}
4 changes: 4 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,14 @@ require (
github.com/ebitengine/oto/v3 v3.4.0
github.com/hajimehoshi/go-mp3 v0.3.4
github.com/spf13/cobra v1.10.2
github.com/zalando/go-keyring v0.2.6
)

require (
al.essio.dev/pkg/shellescape v1.5.1 // indirect
github.com/danieljoos/wincred v1.2.2 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
golang.org/x/sys v0.40.0 // indirect
Expand Down
20 changes: 20 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,21 +1,41 @@
al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho=
al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0=
github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/ebitengine/oto/v3 v3.4.0 h1:br0PgASsEWaoWn38b2Goe7m1GKFYfNgnsjSd5Gg+/bQ=
github.com/ebitengine/oto/v3 v3.4.0/go.mod h1:IOleLVD0m+CMak3mRVwsYY8vTctQgOM0iiL6S7Ar7eI=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68=
github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo=
github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s=
github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=