diff --git a/README.md b/README.md index 0323f3b..da939b7 100644 --- a/README.md +++ b/README.md @@ -246,6 +246,7 @@ Prefer managing profiles via the `ttscli profile` subcommands (see [Command Refe - `ttscli profile delete `: Delete a profile. - `ttscli profile use `: Set the active profile. - `ttscli profile get `: Show profile details. +- `ttscli profile set `: Update default voice and/or language for an existing profile. - `ttscli --version`: Print build metadata. - `ttscli --help`: Show top-level help. @@ -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:** diff --git a/internal/app/command_profile.go b/internal/app/command_profile.go index 56c218c..b3414ab 100644 --- a/internal/app/command_profile.go +++ b/internal/app/command_profile.go @@ -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) } @@ -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 { diff --git a/internal/app/command_profile_test.go b/internal/app/command_profile_test.go index 056602d..629ab1d 100644 --- a/internal/app/command_profile_test.go +++ b/internal/app/command_profile_test.go @@ -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) @@ -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) + } +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 65e38df..996736d 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -36,6 +36,7 @@ const ( ProfileDelete = "delete" ProfileUse = "use" ProfileGet = "get" + ProfileSet = "set" ) var supportedCompletionShells = map[string]struct{}{ @@ -61,6 +62,7 @@ type Config struct { Provider string ProfileName string DefaultVoice string + DefaultLang string } func ParseArgs(args []string, stderr io.Writer) (Config, error) { @@ -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] @@ -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) } } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 08e95f1..b2d180c 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -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) + } +}