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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ Prefer managing profiles via the `ttscli profile` subcommands (see [Command Refe
- `ttscli profile delete <provider:name>`: Delete a profile.
- `ttscli profile use <provider:name>`: Set the active profile.
- `ttscli profile get <provider:name>`: Show profile details.
- `ttscli profile set <provider:name>`: Update default voice and/or language for an existing profile.
- `ttscli --version`: Print build metadata.
- `ttscli --help`: Show top-level help.

Expand Down Expand Up @@ -284,6 +285,15 @@ Prefer managing profiles via the `ttscli profile` subcommands (see [Command Refe
| `--api-key` | string | `""` | Required. API key for the provider. Alias: `-k`. |
| `--voice` | string | `""` | Optional default voice to seed into the profile. Alias: `-v`. |

### `profile set` Flags

| Flag | Type | Default | Notes |
| --- | --- | --- | --- |
| `--voice` | string | `""` | New default voice for the profile. Alias: `-v`. |
| `--lang` | string | `""` | New default language for the profile. Alias: `-l`. |

At least one of `--voice` or `--lang` must be provided.

### Basic Commands

**0. Show CLI version/build metadata:**
Expand Down
46 changes: 46 additions & 0 deletions internal/app/command_profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ func runProfileCommand(cfg cli.Config, stdout io.Writer) error {
return runProfileUse(cfg, stdout)
case cli.ProfileGet:
return runProfileGet(cfg, stdout)
case cli.ProfileSet:
return runProfileSet(cfg, stdout)
default:
return fmt.Errorf("unsupported profile subcommand: %s", cfg.DefaultSubcommand)
}
Expand Down Expand Up @@ -235,6 +237,50 @@ func runProfileUse(cfg cli.Config, stdout io.Writer) error {
return nil
}

func runProfileSet(cfg cli.Config, stdout io.Writer) error {
appCfg, err := loadConfig()
if err != nil {
return fmt.Errorf("load config: %w", err)
}

profileKey, _, _, err := config.ParseProfileKey(cfg.Profile)
if err != nil {
return err
}

profile, exists := appCfg.Profiles[profileKey]
if !exists {
return fmt.Errorf("profile %q not found. Run 'ttscli profile list' to see available profiles", profileKey)
}

if profile.Defaults == nil {
profile.Defaults = make(map[string]string)
}

if cfg.DefaultVoice != "" {
profile.Defaults["voice"] = cfg.DefaultVoice
}
if cfg.DefaultLang != "" {
profile.Defaults["lang"] = cfg.DefaultLang
}

appCfg.Profiles[profileKey] = profile
if err := saveConfig(appCfg); err != nil {
return fmt.Errorf("save config: %w", err)
}

fmt.Fprintf(stdout, "✓ Profile updated: %s\n", profileKey)
if cfg.DefaultVoice != "" {
fmt.Fprintf(stdout, " Default voice → %s\n", cfg.DefaultVoice)
}
if cfg.DefaultLang != "" {
fmt.Fprintf(stdout, " Default lang → %s\n", cfg.DefaultLang)
}
fmt.Fprintln(stdout)
fmt.Fprintf(stdout, "Run 'ttscli speak --text \"Hello\"' to use the new default.\n")
return nil
}

func runProfileGet(cfg cli.Config, stdout io.Writer) error {
appCfg, err := loadConfig()
if err != nil {
Expand Down
97 changes: 96 additions & 1 deletion internal/app/command_profile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,6 @@ func TestRunProfileGetInvalidFormat(t *testing.T) {
func TestRunProfileGetMasksAPIKey(t *testing.T) {
reset := stubAppDeps()
defer reset()

var stdout bytes.Buffer
if err := runProfileGet(cli.Config{Profile: "gcp:default"}, &stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
Expand All @@ -469,3 +468,99 @@ func TestRunProfileGetMasksAPIKey(t *testing.T) {
t.Error("expected API key to be masked in output")
}
}

func TestRunProfileSetVoice(t *testing.T) {
reset := stubAppDeps()
defer reset()

var saved config.Config
saveConfig = func(cfg config.Config) error {
saved = cfg
return nil
}

var stdout bytes.Buffer
cfg := cli.Config{Profile: "gcp:default", DefaultVoice: "en-US-Chirp3-HD-Kore"}
if err := runProfileSet(cfg, &stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if saved.Profiles["gcp:default"].Defaults["voice"] != "en-US-Chirp3-HD-Kore" {
t.Errorf("expected voice to be updated, got %q", saved.Profiles["gcp:default"].Defaults["voice"])
}
if !strings.Contains(stdout.String(), "Profile updated: gcp:default") {
t.Errorf("expected update message, got: %q", stdout.String())
}
if !strings.Contains(stdout.String(), "en-US-Chirp3-HD-Kore") {
t.Errorf("expected voice name in output, got: %q", stdout.String())
}
}

func TestRunProfileSetLang(t *testing.T) {
reset := stubAppDeps()
defer reset()

var saved config.Config
saveConfig = func(cfg config.Config) error {
saved = cfg
return nil
}

var stdout bytes.Buffer
cfg := cli.Config{Profile: "gcp:default", DefaultLang: "en-GB"}
if err := runProfileSet(cfg, &stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if saved.Profiles["gcp:default"].Defaults["lang"] != "en-GB" {
t.Errorf("expected lang to be updated, got %q", saved.Profiles["gcp:default"].Defaults["lang"])
}
// voice should be untouched
if saved.Profiles["gcp:default"].Defaults["voice"] != "en-US-Neural2-F" {
t.Errorf("expected voice to remain unchanged, got %q", saved.Profiles["gcp:default"].Defaults["voice"])
}
}

func TestRunProfileSetVoiceAndLang(t *testing.T) {
reset := stubAppDeps()
defer reset()

var saved config.Config
saveConfig = func(cfg config.Config) error {
saved = cfg
return nil
}

var stdout bytes.Buffer
cfg := cli.Config{Profile: "gcp:default", DefaultVoice: "en-US-Chirp3-HD-Kore", DefaultLang: "en-US"}
if err := runProfileSet(cfg, &stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if saved.Profiles["gcp:default"].Defaults["voice"] != "en-US-Chirp3-HD-Kore" {
t.Errorf("expected voice updated, got %q", saved.Profiles["gcp:default"].Defaults["voice"])
}
if saved.Profiles["gcp:default"].Defaults["lang"] != "en-US" {
t.Errorf("expected lang updated, got %q", saved.Profiles["gcp:default"].Defaults["lang"])
}
}

func TestRunProfileSetNotFound(t *testing.T) {
reset := stubAppDeps()
defer reset()

cfg := cli.Config{Profile: "gcp:nonexistent", DefaultVoice: "en-US-Chirp3-HD-Kore"}
err := runProfileSet(cfg, &bytes.Buffer{})
want := `profile "gcp:nonexistent" not found. Run 'ttscli profile list' to see available profiles`
if err == nil || err.Error() != want {
t.Fatalf("expected %q, got: %v", want, err)
}
}

func TestRunProfileSetInvalidFormat(t *testing.T) {
reset := stubAppDeps()
defer reset()

cfg := cli.Config{Profile: "invalid", DefaultVoice: "en-US-Chirp3-HD-Kore"}
err := runProfileSet(cfg, &bytes.Buffer{})
if err == nil || !strings.Contains(err.Error(), "invalid profile key") {
t.Fatalf("expected invalid profile key error, got: %v", err)
}
}
27 changes: 25 additions & 2 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const (
ProfileDelete = "delete"
ProfileUse = "use"
ProfileGet = "get"
ProfileSet = "set"
)

var supportedCompletionShells = map[string]struct{}{
Expand All @@ -61,6 +62,7 @@ type Config struct {
Provider string
ProfileName string
DefaultVoice string
DefaultLang string
}

func ParseArgs(args []string, stderr io.Writer) (Config, error) {
Expand Down Expand Up @@ -283,7 +285,7 @@ func printHelp(stderr io.Writer) {
func parseProfileCommand(args []string, stderr io.Writer) (Config, error) {
cfg := Config{Mode: ModeProfile}
if len(args) == 0 {
return cfg, fmt.Errorf("profile subcommand required. Use: list, create, delete, use, or get. Run 'ttscli profile --help' for details")
return cfg, fmt.Errorf("profile subcommand required. Use: list, create, delete, use, get, or set. Run 'ttscli profile --help' for details")
}

subcommand := args[0]
Expand Down Expand Up @@ -340,7 +342,28 @@ func parseProfileCommand(args []string, stderr io.Writer) (Config, error) {
}
cfg.Profile = args[1]
return cfg, nil
case ProfileSet:
if len(args) < 2 {
return cfg, fmt.Errorf("profile key required. Usage: ttscli profile set gcp:default --voice en-US-Chirp3-HD-Kore")
}
cfg.Profile = args[1]
fs := flag.NewFlagSet(appName+" profile set", flag.ContinueOnError)
fs.SetOutput(stderr)
fs.StringVar(&cfg.DefaultVoice, "voice", "", "Default voice for this profile")
fs.StringVar(&cfg.DefaultVoice, "v", "", "Default voice (shorthand)")
fs.StringVar(&cfg.DefaultLang, "lang", "", "Default language for this profile")
fs.StringVar(&cfg.DefaultLang, "l", "", "Default language (shorthand)")
if err := fs.Parse(args[2:]); err != nil {
return cfg, err
}
if fs.NArg() > 0 {
return cfg, fmt.Errorf("unexpected arguments: %s", strings.Join(fs.Args(), " "))
}
if cfg.DefaultVoice == "" && cfg.DefaultLang == "" {
return cfg, fmt.Errorf("at least one flag required: --voice or --lang")
}
return cfg, nil
default:
return cfg, fmt.Errorf("unknown profile subcommand %q. Use: list, create, delete, use, or get", subcommand)
return cfg, fmt.Errorf("unknown profile subcommand %q. Use: list, create, delete, use, get, or set", subcommand)
}
}
69 changes: 69 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -518,3 +518,72 @@ func TestParseCLIArgsProfileUnsupportedSubcommand(t *testing.T) {
t.Fatalf("expected unsupported subcommand error, got: %v", err)
}
}

func TestParseCLIArgsProfileSet(t *testing.T) {
var stderr bytes.Buffer
cfg, err := ParseArgs([]string{"profile", "set", "gcp:default", "--voice", "en-US-Chirp3-HD-Kore"}, &stderr)
if err != nil {
t.Fatalf("ParseArgs returned error: %v", err)
}
if cfg.Mode != ModeProfile || cfg.DefaultSubcommand != ProfileSet {
t.Errorf("unexpected mode/subcommand: %+v", cfg)
}
if cfg.Profile != "gcp:default" {
t.Errorf("expected profile gcp:default, got %q", cfg.Profile)
}
if cfg.DefaultVoice != "en-US-Chirp3-HD-Kore" {
t.Errorf("expected voice en-US-Chirp3-HD-Kore, got %q", cfg.DefaultVoice)
}
}

func TestParseCLIArgsProfileSetWithLang(t *testing.T) {
var stderr bytes.Buffer
cfg, err := ParseArgs([]string{"profile", "set", "gcp:default", "--voice", "en-US-Chirp3-HD-Kore", "--lang", "en-US"}, &stderr)
if err != nil {
t.Fatalf("ParseArgs returned error: %v", err)
}
if cfg.DefaultVoice != "en-US-Chirp3-HD-Kore" {
t.Errorf("expected voice en-US-Chirp3-HD-Kore, got %q", cfg.DefaultVoice)
}
if cfg.DefaultLang != "en-US" {
t.Errorf("expected lang en-US, got %q", cfg.DefaultLang)
}
}

func TestParseCLIArgsProfileSetShorthandFlags(t *testing.T) {
var stderr bytes.Buffer
cfg, err := ParseArgs([]string{"profile", "set", "gcp:default", "-v", "en-US-Chirp3-HD-Kore", "-l", "en-US"}, &stderr)
if err != nil {
t.Fatalf("ParseArgs returned error: %v", err)
}
if cfg.DefaultVoice != "en-US-Chirp3-HD-Kore" {
t.Errorf("expected voice en-US-Chirp3-HD-Kore, got %q", cfg.DefaultVoice)
}
if cfg.DefaultLang != "en-US" {
t.Errorf("expected lang en-US, got %q", cfg.DefaultLang)
}
}

func TestParseCLIArgsProfileSetMissingKey(t *testing.T) {
var stderr bytes.Buffer
_, err := ParseArgs([]string{"profile", "set"}, &stderr)
if err == nil || !strings.Contains(err.Error(), "profile key required") {
t.Fatalf("expected missing key error, got: %v", err)
}
}

func TestParseCLIArgsProfileSetMissingFlags(t *testing.T) {
var stderr bytes.Buffer
_, err := ParseArgs([]string{"profile", "set", "gcp:default"}, &stderr)
if err == nil || !strings.Contains(err.Error(), "at least one flag required") {
t.Fatalf("expected at least one flag error, got: %v", err)
}
}

func TestParseCLIArgsProfileSetRejectsExtraArgs(t *testing.T) {
var stderr bytes.Buffer
_, err := ParseArgs([]string{"profile", "set", "gcp:default", "--voice", "en-US-Chirp3-HD-Kore", "extra"}, &stderr)
if err == nil || !strings.Contains(err.Error(), "unexpected arguments") {
t.Fatalf("expected unexpected arguments error, got: %v", err)
}
}
Loading