diff --git a/README.md b/README.md index 56b3032..fbc7d0b 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,12 @@ claudecm add work \ --api-key sk-ant-xxxxxxxx \ --model claude-opus-4-5 +# Or start from a convenience provider preset and inspect the generated +# fields before saving. Built-in presets include moonshot, deepseek, +# glm, and qwen; secrets still come from you. +claudecm add work --preset moonshot --api-key sk-ant-xxxxxxxx --dry-run +claudecm add work --preset moonshot --api-key sk-ant-xxxxxxxx + # 3. Switch. claudecm switch work --yes @@ -71,6 +77,35 @@ See [docs/quickstart.md](docs/quickstart.md) for a longer walk-through with expe Global flags: `--home ` (override `$HOME` for sandboxed runs), `--yes`, `--dry-run` (write commands). +## Provider Presets + +`claudecm add --preset ` expands a built-in convenience template into an ordinary profile. The current catalog is `moonshot`, `deepseek`, `glm`, and `qwen`. + +Example dry-run output shows the generated fields with secrets redacted: + +```bash +claudecm add work --preset moonshot --api-key sk-ant-xxxxxxxx --dry-run +``` + +```yaml +core: + provider: moonshot + base_url: https://api.moonshot.cn/v1 + api_key: sk-a***xxxx + model: kimi-k2-0711-preview +tools: + codex: + raw: + model: kimi-k2-0711-preview + model_provider: moonshot + model_providers.moonshot.base_url: https://api.moonshot.cn/v1 + model_providers.moonshot.env_key: OPENAI_API_KEY + model_providers.moonshot.name: Moonshot AI + model_providers.moonshot.wire_api: chat +``` + +Presets are convenience templates, not official provider support, certification, endorsement, or compatibility guarantees. Every generated field is overridable with explicit flags or `--set`; provider endpoints and model names can drift, so edit the profile when a provider changes its API. + ## Deeper reading - [`docs/prd/prd-v1.md`](docs/prd/prd-v1.md) — v1 PRD, functional + non-functional requirements, decision log. diff --git a/cmd/add.go b/cmd/add.go index 3f7662f..1df1640 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -38,6 +38,7 @@ import ( "github.com/spf13/cobra" "github.com/a2d2-dev/claudecm/internal/config" + "github.com/a2d2-dev/claudecm/internal/presets" "github.com/a2d2-dev/claudecm/internal/storage" ) @@ -66,7 +67,11 @@ const ( // this set is refused so a typo does not silently write a bad file. var addProviderAllowed = map[string]struct{}{ "anthropic": {}, + "deepseek": {}, + "glm": {}, + "moonshot": {}, "openai-compat": {}, + "qwen": {}, "custom": {}, } @@ -78,6 +83,8 @@ var ( addModelFlag string addSmallFastModelFlag string addSetFlag []string + addPresetFlag string + addListPresetsFlag bool addDryRunFlag bool addOverwriteFlag bool addOutputFlag string @@ -118,6 +125,12 @@ Any other --set path is refused. The profile name is validated against the NFR-S5 allowlist (^[a-z0-9][a-z0-9._-]{0,63}$ plus reserved-name protection). +Provider presets are convenience templates only. --preset fills +base_url, model, provider, and supported tool overlays from the built-in +catalog; users still supply secrets, every generated value can be +overridden by an explicit flag or --set, and presets are not official +provider support, certification, endorsement, or compatibility guarantees. + EXAMPLES # Minimal core fields claudecm add work \ @@ -137,6 +150,14 @@ EXAMPLES --api-key sk-... \ --set tools.codex.raw.model=gpt-5 + # Start from a built-in provider preset; generated fields are visible + # in dry-run output and can be overridden. + claudecm add work --preset moonshot --api-key sk-... --dry-run + claudecm add work --preset moonshot --api-key sk-... --model kimi-k2-latest + + # Discover built-in presets + claudecm add --list-presets + # Preview only — no writes claudecm add work --base-url ... --api-key ... --dry-run @@ -145,7 +166,12 @@ EXAMPLES add does NOT auto-activate the new profile. Use 'claudecm switch ' to make it the active profile.`, - Args: cobra.ExactArgs(1), + Args: func(cmd *cobra.Command, args []string) error { + if addListPresetsFlag { + return cobra.NoArgs(cmd, args) + } + return cobra.ExactArgs(1)(cmd, args) + }, RunE: runAdd, } @@ -156,6 +182,8 @@ func init() { addCmd.Flags().StringVar(&addAPIKeyFlag, "api-key", "", "Core API key") addCmd.Flags().StringVar(&addModelFlag, "model", "", "Core model name") addCmd.Flags().StringVar(&addSmallFastModelFlag, "small-fast-model", "", "Core small/fast auxiliary model name") + addCmd.Flags().StringVar(&addPresetFlag, "preset", "", "Built-in provider preset name (run --list-presets to discover)") + addCmd.Flags().BoolVar(&addListPresetsFlag, "list-presets", false, "List built-in provider presets and exit") addCmd.Flags().StringArrayVar(&addSetFlag, "set", nil, "Sparse overlay entry (repeatable). Format: tools..=. "+ "Supported: tools.claude_code.env.=, tools.codex.raw.=") @@ -171,6 +199,14 @@ func init() { // call this directly with a synthetic cobra.Command whose Out/Err are // bytes.Buffers. func runAdd(cmd *cobra.Command, args []string) error { + if addListPresetsFlag { + format, err := parseAddOutput(addOutputFlag) + if err != nil { + return err + } + return renderPresetList(cmd.OutOrStdout(), format) + } + name := strings.TrimSpace(args[0]) if err := storage.ValidateProfileName(name); err != nil { return err @@ -181,16 +217,52 @@ func runAdd(cmd *cobra.Command, args []string) error { return err } - if err := validateProvider(addProviderFlag); err != nil { + providerFlagSet := flagWasExplicit(cmd, "provider", addProviderFlag != addProviderDefault) + baseURLFlagSet := flagWasExplicit(cmd, "base-url", addBaseURLFlag != "") + modelFlagSet := flagWasExplicit(cmd, "model", addModelFlag != "") + + preset, hasPreset, err := resolveAddPreset(addPresetFlag) + if err != nil { + return err + } + + provider := addProviderFlag + baseURL := addBaseURLFlag + model := addModelFlag + var tools map[config.ToolID]config.ToolOverlay + if hasPreset { + provider = preset.ProviderKey + baseURL = preset.BaseURL + model = preset.Model + tools = cloneToolMap(preset.Tools) + } + if providerFlagSet { + provider = addProviderFlag + } + if baseURLFlagSet { + baseURL = addBaseURLFlag + } + if modelFlagSet { + model = addModelFlag + } + if hasPreset { + applyExplicitPresetFlagOverrides(tools, preset.Name, provider, baseURL, model, providerFlagSet, baseURLFlagSet, modelFlagSet) + } + + if err := validateProvider(provider); err != nil { return err } + if hasPreset && addAPIKeyFlag == "" { + return fmt.Errorf("preset %q requires --api-key in non-interactive add", preset.Name) + } // Build tools overlay from --set entries. Parsing is a pure // function so an invalid entry surfaces before any I/O. - tools, err := parseSetEntries(addSetFlag) + setTools, err := parseSetEntries(addSetFlag) if err != nil { return err } + tools = mergeToolMaps(tools, setTools) now := nowFn().UTC() profile := &config.Profile{ @@ -200,10 +272,10 @@ func runAdd(cmd *cobra.Command, args []string) error { CreatedAt: now, UpdatedAt: now, Core: config.CoreConfig{ - Provider: addProviderFlag, - BaseURL: addBaseURLFlag, + Provider: provider, + BaseURL: baseURL, APIKey: addAPIKeyFlag, - Model: addModelFlag, + Model: model, SmallFastModel: addSmallFastModelFlag, }, Tools: tools, @@ -257,6 +329,50 @@ func parseAddOutput(raw string) (addOutputFormat, error) { } } +func resolveAddPreset(raw string) (presets.Preset, bool, error) { + if strings.TrimSpace(raw) == "" { + return presets.Preset{}, false, nil + } + p, err := presets.Lookup(raw) + if err != nil { + return presets.Preset{}, false, err + } + return p, true, nil +} + +func flagWasExplicit(cmd *cobra.Command, name string, fallback bool) bool { + if cmd != nil && cmd.Flags() != nil { + if f := cmd.Flags().Lookup(name); f != nil && f.Changed { + return true + } + } + return fallback +} + +func applyExplicitPresetFlagOverrides( + tools map[config.ToolID]config.ToolOverlay, + presetName, provider, baseURL, model string, + providerFlagSet, baseURLFlagSet, modelFlagSet bool, +) { + if tools == nil { + return + } + ov, ok := tools[config.ToolCodex] + if !ok || ov.Raw == nil { + return + } + if providerFlagSet { + ov.Raw["model_provider"] = provider + } + if modelFlagSet { + ov.Raw["model"] = model + } + if baseURLFlagSet { + ov.Raw["model_providers."+presetName+".base_url"] = baseURL + } + tools[config.ToolCodex] = ov +} + // validateProvider enforces the closed enum on --provider. Empty is // treated as an operator error because cobra's default already stamps // addProviderDefault; getting empty here means someone explicitly passed @@ -329,6 +445,86 @@ func parseSetEntries(entries []string) (map[config.ToolID]config.ToolOverlay, er return out, nil } +func mergeToolMaps(base, override map[config.ToolID]config.ToolOverlay) map[config.ToolID]config.ToolOverlay { + out := cloneToolMap(base) + if len(override) == 0 { + return out + } + if out == nil { + out = map[config.ToolID]config.ToolOverlay{} + } + for id, ov := range override { + out[id] = mergeOverlay(out[id], ov) + } + return out +} + +func mergeOverlay(base, override config.ToolOverlay) config.ToolOverlay { + out := cloneOverlay(base) + if override.BaseURL != "" { + out.BaseURL = override.BaseURL + } + if override.APIKey != "" { + out.APIKey = override.APIKey + } + if override.Model != "" { + out.Model = override.Model + } + if override.SmallFastModel != "" { + out.SmallFastModel = override.SmallFastModel + } + if len(override.ExtraEnv) > 0 { + if out.ExtraEnv == nil { + out.ExtraEnv = map[string]string{} + } + for k, v := range override.ExtraEnv { + out.ExtraEnv[k] = v + } + } + if len(override.Raw) > 0 { + if out.Raw == nil { + out.Raw = map[string]any{} + } + for k, v := range override.Raw { + out.Raw[k] = v + } + } + return out +} + +func cloneToolMap(in map[config.ToolID]config.ToolOverlay) map[config.ToolID]config.ToolOverlay { + if len(in) == 0 { + return nil + } + out := make(map[config.ToolID]config.ToolOverlay, len(in)) + for id, ov := range in { + out[id] = cloneOverlay(ov) + } + return out +} + +func cloneOverlay(ov config.ToolOverlay) config.ToolOverlay { + out := config.ToolOverlay{ + BaseURL: ov.BaseURL, + APIKey: ov.APIKey, + Model: ov.Model, + SmallFastModel: ov.SmallFastModel, + } + if ov.ExtraEnv != nil { + out.ExtraEnv = make(map[string]string, len(ov.ExtraEnv)) + for k, v := range ov.ExtraEnv { + out.ExtraEnv[k] = v + } + } + if ov.Raw != nil { + out.Raw = make(map[string]any, len(ov.Raw)) + for k, v := range ov.Raw { + out.Raw[k] = v + } + } + return out +} + // validateEnvVarName enforces the "UPPER_SNAKE_CASE, no funny chars" // convention on env vars set via the claude_code overlay. We do not // forbid every possible env name here (that is the resolver's business) @@ -354,14 +550,15 @@ func validateEnvVarName(name string) error { // JSON mode encodes a JSON snapshot of the same struct so shell // consumers can jq the output. func renderAddDryRun(w io.Writer, format addOutputFormat, profile *config.Profile) error { - body, err := config.MarshalProfile(profile) + redacted := redactProfileForAddOutput(profile) + body, err := config.MarshalProfile(redacted) if err != nil { return fmt.Errorf("marshal profile for dry-run: %w", err) } if format == addOutputJSON { out := jsonAddDryRun{ Action: "dry-run", - Profile: profileToJSON(profile), + Profile: profileToJSON(redacted), YAML: string(body), } return writeAddJSON(w, out) @@ -380,7 +577,7 @@ func renderAddSuccess(w io.Writer, format addOutputFormat, profile *config.Profi if format == addOutputJSON { out := jsonAddSuccess{ Action: "created", - Profile: profileToJSON(profile), + Profile: profileToJSON(redactProfileForAddOutput(profile)), } return writeAddJSON(w, out) } @@ -388,6 +585,54 @@ func renderAddSuccess(w io.Writer, format addOutputFormat, profile *config.Profi return nil } +func renderPresetList(w io.Writer, format addOutputFormat) error { + switch format { + case addOutputJSON: + out := jsonAddPresetList{ + Action: "list-presets", + Presets: presetListToJSON(), + } + return writeAddJSON(w, out) + default: + fmt.Fprintln(w, "Built-in provider presets (convenience templates only; not official provider support or endorsement):") + for _, p := range presets.Catalog { + fmt.Fprintf(w, " %s\t%s\tbase_url=%s\tmodel=%s\tprovider=%s\n", + p.Name, p.DisplayName, p.BaseURL, p.Model, p.ProviderKey) + } + return nil + } +} + +func redactProfileForAddOutput(profile *config.Profile) *config.Profile { + if profile == nil { + return nil + } + out := profile.Clone() + out.Core.APIKey = redactValue(out.Core.APIKey) + for id, ov := range out.Tools { + if ov.APIKey != "" { + ov.APIKey = redactValue(ov.APIKey) + } + for k, v := range ov.ExtraEnv { + if isSecretKey(k) { + ov.ExtraEnv[k] = redactValue(v) + } + } + for k, v := range ov.Raw { + if s, ok := v.(string); ok && isSecretKey(k) { + ov.Raw[k] = redactValue(s) + } + } + out.Tools[id] = ov + } + for k, v := range out.Core.ExtraEnv { + if isSecretKey(k) { + out.Core.ExtraEnv[k] = redactValue(v) + } + } + return out +} + // --------------------------------------------------------------------------- // JSON wire types // --------------------------------------------------------------------------- @@ -424,6 +669,17 @@ type jsonAddOverlay struct { Raw map[string]any `json:"raw,omitempty"` } +type jsonAddPreset struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` + ProviderKey string `json:"provider_key"` + BaseURL string `json:"base_url"` + Model string `json:"model"` + Tools []string `json:"tools"` + Secrets []string `json:"expected_secret_fields"` + Disclaimer string `json:"disclaimer"` +} + // jsonAddDryRun is the top-level document for --dry-run --output json. // YAML carries the marshaled bytes (so a consumer can pipe the exact // wire form SaveProfile would have written), while Profile carries a @@ -440,6 +696,11 @@ type jsonAddSuccess struct { Profile jsonAddProfile `json:"profile"` } +type jsonAddPresetList struct { + Action string `json:"action"` + Presets []jsonAddPreset `json:"presets"` +} + func profileToJSON(p *config.Profile) jsonAddProfile { out := jsonAddProfile{ SchemaVersion: p.SchemaVersion, @@ -472,6 +733,32 @@ func profileToJSON(p *config.Profile) jsonAddProfile { return out } +func presetListToJSON() []jsonAddPreset { + out := make([]jsonAddPreset, 0, len(presets.Catalog)) + for _, p := range presets.Catalog { + tools := make([]string, 0, len(p.Tools)) + for id := range p.Tools { + tools = append(tools, string(id)) + } + sort.Strings(tools) + secrets := make([]string, 0, len(p.Secrets)) + for _, s := range p.Secrets { + secrets = append(secrets, s.Name) + } + out = append(out, jsonAddPreset{ + Name: p.Name, + DisplayName: p.DisplayName, + ProviderKey: p.ProviderKey, + BaseURL: p.BaseURL, + Model: p.Model, + Tools: tools, + Secrets: secrets, + Disclaimer: p.Disclaimer, + }) + } + return out +} + func writeAddJSON(w io.Writer, v any) error { enc := json.NewEncoder(w) enc.SetIndent("", " ") diff --git a/cmd/add_test.go b/cmd/add_test.go index 034dc48..67d3851 100644 --- a/cmd/add_test.go +++ b/cmd/add_test.go @@ -44,6 +44,8 @@ func resetAddFlags() { addModelFlag = "" addSmallFastModelFlag = "" addSetFlag = nil + addPresetFlag = "" + addListPresetsFlag = false addDryRunFlag = false addOverwriteFlag = false addOutputFlag = "text" @@ -289,6 +291,154 @@ func TestAdd_HappyDescriptionAndSmallFast(t *testing.T) { } } +func TestAdd_PresetMoonshotDryRunRedactedProfileDraft(t *testing.T) { + h := newAddHarness(t) + + addPresetFlag = "moonshot" + addAPIKeyFlag = "sk-preset-moonshot-1234" + addDryRunFlag = true + + stdout, _, err := runAddInner(t, "work") + if err != nil { + t.Fatalf("runAdd: %v", err) + } + for _, want := range []string{ + "provider: moonshot", + "base_url: https://api.moonshot.cn/v1", + "model: kimi-k2-0711-preview", + "model_provider: moonshot", + "model_providers.moonshot.base_url: https://api.moonshot.cn/v1", + } { + if !strings.Contains(stdout, want) { + t.Fatalf("dry-run missing %q:\n%s", want, stdout) + } + } + if strings.Contains(stdout, "sk-preset-moonshot-1234") { + t.Fatalf("dry-run leaked plaintext api key:\n%s", stdout) + } + if !strings.Contains(stdout, "sk-p***1234") { + t.Fatalf("dry-run missing redacted api key:\n%s", stdout) + } + if _, err := os.Stat(filepath.Join(h.home, ".claudecm", "profiles", "work.yaml")); !os.IsNotExist(err) { + t.Fatalf("profile file written despite --dry-run: %v", err) + } +} + +func TestAdd_PresetAllBuiltInsSaveOrdinaryProfiles(t *testing.T) { + h := newAddHarness(t) + + for _, name := range []string{"deepseek", "glm", "moonshot", "qwen"} { + resetAddFlags() + addPresetFlag = name + addAPIKeyFlag = "sk-" + name + "-1234567890" + if _, _, err := runAddInner(t, name+"-profile"); err != nil { + t.Fatalf("runAdd preset %q: %v", name, err) + } + loaded, err := h.store.LoadProfile(name + "-profile") + if err != nil { + t.Fatalf("LoadProfile(%q): %v", name+"-profile", err) + } + if loaded.SchemaVersion != config.CurrentProfileSchemaVersion { + t.Fatalf("%s SchemaVersion = %d", name, loaded.SchemaVersion) + } + if loaded.Core.Provider != name { + t.Fatalf("%s Provider = %q", name, loaded.Core.Provider) + } + if loaded.Core.APIKey != "sk-"+name+"-1234567890" { + t.Fatalf("%s APIKey not stored from user input", name) + } + ov := loaded.Tools[config.ToolCodex] + if got := ov.Raw["model_provider"]; got != name { + t.Fatalf("%s codex model_provider = %v", name, got) + } + } +} + +func TestAdd_PresetCaseInsensitiveAndOverrides(t *testing.T) { + h := newAddHarness(t) + + addPresetFlag = "MoonShot" + addAPIKeyFlag = "sk-override-1234" + addModelFlag = "kimi-k2-latest" + addBaseURLFlag = "https://override.example.com/v1" + addSetFlag = []string{ + "tools.codex.raw.model_providers.moonshot.name=Moonshot Override", + } + if _, _, err := runAddInner(t, "work"); err != nil { + t.Fatalf("runAdd: %v", err) + } + loaded, err := h.store.LoadProfile("work") + if err != nil { + t.Fatalf("LoadProfile: %v", err) + } + if loaded.Core.Provider != "moonshot" { + t.Fatalf("Provider = %q, want canonical moonshot", loaded.Core.Provider) + } + if loaded.Core.Model != "kimi-k2-latest" { + t.Fatalf("Core.Model = %q", loaded.Core.Model) + } + if loaded.Core.BaseURL != "https://override.example.com/v1" { + t.Fatalf("Core.BaseURL = %q", loaded.Core.BaseURL) + } + ov := loaded.Tools[config.ToolCodex] + if got := ov.Raw["model"]; got != "kimi-k2-latest" { + t.Fatalf("codex raw model override = %v", got) + } + if got := ov.Raw["model_providers.moonshot.base_url"]; got != "https://override.example.com/v1" { + t.Fatalf("codex raw base_url override = %v", got) + } + if got := ov.Raw["model_providers.moonshot.name"]; got != "Moonshot Override" { + t.Fatalf("codex raw --set override = %v", got) + } +} + +func TestAdd_ListPresetsText(t *testing.T) { + newAddHarness(t) + addListPresetsFlag = true + + stdout, _, err := runAddInner(t) + if err != nil { + t.Fatalf("runAdd --list-presets: %v", err) + } + for _, name := range []string{"moonshot", "deepseek", "glm", "qwen"} { + if !strings.Contains(stdout, name) { + t.Fatalf("preset list missing %q:\n%s", name, stdout) + } + } + if !strings.Contains(stdout, "convenience templates") || !strings.Contains(stdout, "not official provider support") { + t.Fatalf("preset list missing boundary text:\n%s", stdout) + } +} + +func TestAdd_PresetUnknownAndMissingSecretRefuseWithoutWrite(t *testing.T) { + h := newAddHarness(t) + + addPresetFlag = "unknown" + _, _, err := runAddInner(t, "work") + if err == nil { + t.Fatalf("unknown preset accepted") + } + if !strings.Contains(err.Error(), "available presets: deepseek, glm, moonshot, qwen") { + t.Fatalf("unknown preset error missing available list: %v", err) + } + if _, statErr := os.Stat(filepath.Join(h.home, ".claudecm", "profiles", "work.yaml")); !os.IsNotExist(statErr) { + t.Fatalf("profile file written after unknown preset: %v", statErr) + } + + resetAddFlags() + addPresetFlag = "moonshot" + _, _, err = runAddInner(t, "work") + if err == nil { + t.Fatalf("preset without secret accepted") + } + if !strings.Contains(err.Error(), "requires --api-key") { + t.Fatalf("missing secret error = %v", err) + } + if _, statErr := os.Stat(filepath.Join(h.home, ".claudecm", "profiles", "work.yaml")); !os.IsNotExist(statErr) { + t.Fatalf("profile file written after missing secret: %v", statErr) + } +} + // --------------------------------------------------------------------------- // Edge cases // --------------------------------------------------------------------------- @@ -414,8 +564,11 @@ func TestAdd_DryRunNoWrite(t *testing.T) { if !strings.Contains(stdout, "schema_version: 1") { t.Fatalf("stdout missing YAML body; got %q", stdout) } - if !strings.Contains(stdout, "sk-dryrun-1234") { - t.Fatalf("stdout missing api key; got %q", stdout) + if strings.Contains(stdout, "sk-dryrun-1234") { + t.Fatalf("stdout leaked plaintext api key; got %q", stdout) + } + if !strings.Contains(stdout, "sk-d***1234") { + t.Fatalf("stdout missing redacted api key; got %q", stdout) } path := filepath.Join(h.home, ".claudecm", "profiles", "work.yaml") @@ -454,8 +607,11 @@ func TestAdd_DryRunJSONOutputParses(t *testing.T) { if out.Profile.SchemaVersion != config.CurrentProfileSchemaVersion { t.Fatalf("SchemaVersion: got %d want %d", out.Profile.SchemaVersion, config.CurrentProfileSchemaVersion) } - if out.Profile.Core.APIKey != "sk-dryrun-json-1234" { - t.Fatalf("Core.APIKey: got %q", out.Profile.Core.APIKey) + if out.Profile.Core.APIKey != "sk-d***1234" { + t.Fatalf("Core.APIKey: got %q, want redacted", out.Profile.Core.APIKey) + } + if strings.Contains(out.YAML, "sk-dryrun-json-1234") || strings.Contains(stdout, "sk-dryrun-json-1234") { + t.Fatalf("dry-run JSON leaked plaintext api key:\n%s", stdout) } if !strings.Contains(out.YAML, "schema_version: 1") { t.Fatalf("YAML field missing schema_version; got %q", out.YAML) diff --git a/cmd/switch_test.go b/cmd/switch_test.go index 492ada3..f759298 100644 --- a/cmd/switch_test.go +++ b/cmd/switch_test.go @@ -34,6 +34,7 @@ import ( "github.com/a2d2-dev/claudecm/internal/adapter/stateio" "github.com/a2d2-dev/claudecm/internal/commit" "github.com/a2d2-dev/claudecm/internal/config" + "github.com/a2d2-dev/claudecm/internal/presets" "github.com/a2d2-dev/claudecm/internal/storage" "github.com/a2d2-dev/claudecm/internal/writepath" ) @@ -566,6 +567,76 @@ func TestSwitch_DryRunAbortsTxnPreservesTarget(t *testing.T) { } } +func TestSwitch_PresetBackedProfileUsesNormalWritePathAndPreservesNonOwnedKeys(t *testing.T) { + h := newSwitchHarness(t) + + preset, err := presets.Lookup("qwen") + if err != nil { + t.Fatalf("Lookup(qwen): %v", err) + } + p := config.NewProfile("qwen-work", preset.BaseURL, "sk-qwen-switch-1234") + p.Core.Provider = preset.ProviderKey + p.Core.Model = preset.Model + p.Tools = preset.Tools + if err := h.store.SaveProfile(p); err != nil { + t.Fatalf("SaveProfile(qwen-work): %v", err) + } + + settingsPath := claudecodeadapter.SettingsPath(h.resv) + h.writeSettingsJSON(`{"theme":"dark","env":{"ANTHROPIC_MODEL":"old-model"}}`) + configPath := codexadapter.ConfigPath(h.resv) + if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { + t.Fatalf("mkdir codex dir: %v", err) + } + if err := os.WriteFile(configPath, []byte("sandbox_mode = \"workspace-write\"\n"), 0o600); err != nil { + t.Fatalf("seed config.toml: %v", err) + } + + switchYesFlag = true + stdout, _, err := runSwitchInner(t, "qwen-work") + if err != nil { + t.Fatalf("runSwitch preset-backed profile: %v\nstdout:\n%s", err, stdout) + } + if !strings.Contains(stdout, `Switched to "qwen-work"`) { + t.Fatalf("stdout missing switched confirmation:\n%s", stdout) + } + if !strings.Contains(stdout, "model_providers.qwen.base_url") { + t.Fatalf("pre-apply diff missing qwen owned-key change:\n%s", stdout) + } + + settingsBytes, err := os.ReadFile(settingsPath) + if err != nil { + t.Fatalf("read settings.json: %v", err) + } + if !strings.Contains(string(settingsBytes), `"theme":"dark"`) { + t.Fatalf("non-owned Claude Code key was not preserved:\n%s", settingsBytes) + } + + configBytes, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("read config.toml: %v", err) + } + configBody := string(configBytes) + for _, want := range []string{ + `sandbox_mode = "workspace-write"`, + `model_provider = "qwen"`, + `[model_providers.qwen]`, + `base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1"`, + } { + if !strings.Contains(configBody, want) { + t.Fatalf("config.toml missing %q:\n%s", want, configBody) + } + } + + state, err := h.store.LoadState() + if err != nil { + t.Fatalf("LoadState: %v", err) + } + if state.CurrentProfile != "qwen-work" { + t.Fatalf("state.CurrentProfile = %q, want qwen-work", state.CurrentProfile) + } +} + // --------------------------------------------------------------------------- // Unit-level tests for the small helpers in switch.go. These exercise // paths the integration tests do not touch directly and lift coverage diff --git a/docs/quickstart.md b/docs/quickstart.md index 1cf7f93..18f883d 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -44,6 +44,39 @@ claudecm add work \ Expected: `Profile "work" created.`. +You can also start from a built-in provider preset. Presets are convenience templates, not official provider support, certification, endorsement, or compatibility guarantees. They fill generated fields such as `base_url`, `model`, `provider`, and supported tool overlays; you still supply the secret, and every generated field can be overridden. + +```bash +claudecm add work --preset moonshot --api-key sk-ant-xxxxxxxx --dry-run +``` + +Expected: a redacted profile draft, not a write. The generated fields are visible: + +```yaml +core: + provider: moonshot + base_url: https://api.moonshot.cn/v1 + api_key: sk-a***xxxx + model: kimi-k2-0711-preview +tools: + codex: + raw: + model: kimi-k2-0711-preview + model_provider: moonshot + model_providers.moonshot.base_url: https://api.moonshot.cn/v1 + model_providers.moonshot.env_key: OPENAI_API_KEY + model_providers.moonshot.name: Moonshot AI + model_providers.moonshot.wire_api: chat +``` + +Then save it: + +```bash +claudecm add work --preset moonshot --api-key sk-ant-xxxxxxxx +``` + +Available presets: `moonshot`, `deepseek`, `glm`, `qwen`. Use `claudecm add --list-presets` to inspect the current catalog. Endpoint and model names can drift, so override with `--base-url`, `--model`, `--provider`, or `--set` when a provider changes its API. + > **Name rules.** Profile names must match `^[a-z0-9][a-z0-9._-]{0,63}$` (NFR-S5). If `claudecm add` fails with a profile-name error, that regex is the reason — no uppercase, no leading dot/dash, ≤ 64 characters. ## 4. Switch to the second profile diff --git a/internal/adapter/codex/allowlist.go b/internal/adapter/codex/allowlist.go index 74111cd..7e40be9 100644 --- a/internal/adapter/codex/allowlist.go +++ b/internal/adapter/codex/allowlist.go @@ -24,10 +24,11 @@ import ( // ~/.codex/config.toml (Architecture §3.1, PRD §4.7). // // Concrete flat keys matching writepath.Flatten output. v1 supports -// 'openai' and 'anthropic' provider entries; other custom provider -// names (e.g. 'my-relay') are considered non-owned by claudecm and -// preserved via FR-5 merge-preserve. Post-v1: dynamic provider -// ownership. +// 'openai' and 'anthropic' provider entries plus the closed v1.1 +// provider-preset names admitted by ADR-0002 (deepseek, glm, +// moonshot, qwen). Other custom provider names (e.g. 'my-relay') are +// considered non-owned by claudecm and preserved via FR-5 +// merge-preserve. No wildcard provider ownership. // // Kept in sorted order so: // - The init() invariant check below stays a one-liner. @@ -45,10 +46,26 @@ var OwnedKeysConfigTOML = []string{ "model_providers.anthropic.env_key", "model_providers.anthropic.name", "model_providers.anthropic.wire_api", + "model_providers.deepseek.base_url", + "model_providers.deepseek.env_key", + "model_providers.deepseek.name", + "model_providers.deepseek.wire_api", + "model_providers.glm.base_url", + "model_providers.glm.env_key", + "model_providers.glm.name", + "model_providers.glm.wire_api", + "model_providers.moonshot.base_url", + "model_providers.moonshot.env_key", + "model_providers.moonshot.name", + "model_providers.moonshot.wire_api", "model_providers.openai.base_url", "model_providers.openai.env_key", "model_providers.openai.name", "model_providers.openai.wire_api", + "model_providers.qwen.base_url", + "model_providers.qwen.env_key", + "model_providers.qwen.name", + "model_providers.qwen.wire_api", } // OwnedKeysAuthJSON is the frozen v1 owned-key allowlist for diff --git a/internal/adapter/codex/allowlist_test.go b/internal/adapter/codex/allowlist_test.go index e771041..d0789ac 100644 --- a/internal/adapter/codex/allowlist_test.go +++ b/internal/adapter/codex/allowlist_test.go @@ -155,22 +155,38 @@ func TestOwnedKeys_NoOverlapBetweenFiles(t *testing.T) { // silently drop a Codex config knob from the merge-preserve owned set. // // v1 ships concrete flat leaves matching writepath.Flatten output — -// the provider-agnostic top-level knobs plus the explicit `openai` -// and `anthropic` provider entries. Any post-v1 provider expansion -// requires an ADR + PRD §4.7 edit. +// the provider-agnostic top-level knobs plus the explicit `openai`, +// `anthropic`, and ADR-0002 preset provider entries. Any further +// provider expansion requires an ADR + PRD §4.7 edit. func TestOwnedKeysConfigTOML_ExpectedKeysPresent(t *testing.T) { required := []string{ "approval_mode", "model", "model_provider", - "model_providers.openai.base_url", - "model_providers.openai.env_key", - "model_providers.openai.name", - "model_providers.openai.wire_api", "model_providers.anthropic.base_url", "model_providers.anthropic.env_key", "model_providers.anthropic.name", "model_providers.anthropic.wire_api", + "model_providers.deepseek.base_url", + "model_providers.deepseek.env_key", + "model_providers.deepseek.name", + "model_providers.deepseek.wire_api", + "model_providers.glm.base_url", + "model_providers.glm.env_key", + "model_providers.glm.name", + "model_providers.glm.wire_api", + "model_providers.moonshot.base_url", + "model_providers.moonshot.env_key", + "model_providers.moonshot.name", + "model_providers.moonshot.wire_api", + "model_providers.openai.base_url", + "model_providers.openai.env_key", + "model_providers.openai.name", + "model_providers.openai.wire_api", + "model_providers.qwen.base_url", + "model_providers.qwen.env_key", + "model_providers.qwen.name", + "model_providers.qwen.wire_api", } have := make(map[string]struct{}, len(OwnedKeysConfigTOML)) for _, k := range OwnedKeysConfigTOML { @@ -223,10 +239,26 @@ func TestOwnedKeysConfigTOML_NoUnexpectedKeys(t *testing.T) { "model_providers.anthropic.env_key", "model_providers.anthropic.name", "model_providers.anthropic.wire_api", + "model_providers.deepseek.base_url", + "model_providers.deepseek.env_key", + "model_providers.deepseek.name", + "model_providers.deepseek.wire_api", + "model_providers.glm.base_url", + "model_providers.glm.env_key", + "model_providers.glm.name", + "model_providers.glm.wire_api", + "model_providers.moonshot.base_url", + "model_providers.moonshot.env_key", + "model_providers.moonshot.name", + "model_providers.moonshot.wire_api", "model_providers.openai.base_url", "model_providers.openai.env_key", "model_providers.openai.name", "model_providers.openai.wire_api", + "model_providers.qwen.base_url", + "model_providers.qwen.env_key", + "model_providers.qwen.name", + "model_providers.qwen.wire_api", } if !reflect.DeepEqual(OwnedKeysConfigTOML, golden) { t.Fatalf("OwnedKeysConfigTOML drifted from golden:\n got: %v\nwant: %v", OwnedKeysConfigTOML, golden) diff --git a/internal/adapter/codex/testdata/codex/edge/auth-only/expected/plans.json b/internal/adapter/codex/testdata/codex/edge/auth-only/expected/plans.json index b0896dd..dd01ab6 100644 --- a/internal/adapter/codex/testdata/codex/edge/auth-only/expected/plans.json +++ b/internal/adapter/codex/testdata/codex/edge/auth-only/expected/plans.json @@ -24,10 +24,26 @@ "model_providers.anthropic.env_key", "model_providers.anthropic.name", "model_providers.anthropic.wire_api", + "model_providers.deepseek.base_url", + "model_providers.deepseek.env_key", + "model_providers.deepseek.name", + "model_providers.deepseek.wire_api", + "model_providers.glm.base_url", + "model_providers.glm.env_key", + "model_providers.glm.name", + "model_providers.glm.wire_api", + "model_providers.moonshot.base_url", + "model_providers.moonshot.env_key", + "model_providers.moonshot.name", + "model_providers.moonshot.wire_api", "model_providers.openai.base_url", "model_providers.openai.env_key", "model_providers.openai.name", - "model_providers.openai.wire_api" + "model_providers.openai.wire_api", + "model_providers.qwen.base_url", + "model_providers.qwen.env_key", + "model_providers.qwen.name", + "model_providers.qwen.wire_api" ], "reason": "codex config: switch to profile \"auth-only\"" } diff --git a/internal/adapter/codex/testdata/codex/edge/config-only/expected/plans.json b/internal/adapter/codex/testdata/codex/edge/config-only/expected/plans.json index 42ca2b6..5c4213a 100644 --- a/internal/adapter/codex/testdata/codex/edge/config-only/expected/plans.json +++ b/internal/adapter/codex/testdata/codex/edge/config-only/expected/plans.json @@ -24,10 +24,26 @@ "model_providers.anthropic.env_key", "model_providers.anthropic.name", "model_providers.anthropic.wire_api", + "model_providers.deepseek.base_url", + "model_providers.deepseek.env_key", + "model_providers.deepseek.name", + "model_providers.deepseek.wire_api", + "model_providers.glm.base_url", + "model_providers.glm.env_key", + "model_providers.glm.name", + "model_providers.glm.wire_api", + "model_providers.moonshot.base_url", + "model_providers.moonshot.env_key", + "model_providers.moonshot.name", + "model_providers.moonshot.wire_api", "model_providers.openai.base_url", "model_providers.openai.env_key", "model_providers.openai.name", - "model_providers.openai.wire_api" + "model_providers.openai.wire_api", + "model_providers.qwen.base_url", + "model_providers.qwen.env_key", + "model_providers.qwen.name", + "model_providers.qwen.wire_api" ], "reason": "codex config: switch to profile \"config-only\"" } diff --git a/internal/adapter/codex/testdata/codex/edge/unknown-keys-preserved/expected/plans.json b/internal/adapter/codex/testdata/codex/edge/unknown-keys-preserved/expected/plans.json index cc83457..424f8a2 100644 --- a/internal/adapter/codex/testdata/codex/edge/unknown-keys-preserved/expected/plans.json +++ b/internal/adapter/codex/testdata/codex/edge/unknown-keys-preserved/expected/plans.json @@ -24,10 +24,26 @@ "model_providers.anthropic.env_key", "model_providers.anthropic.name", "model_providers.anthropic.wire_api", + "model_providers.deepseek.base_url", + "model_providers.deepseek.env_key", + "model_providers.deepseek.name", + "model_providers.deepseek.wire_api", + "model_providers.glm.base_url", + "model_providers.glm.env_key", + "model_providers.glm.name", + "model_providers.glm.wire_api", + "model_providers.moonshot.base_url", + "model_providers.moonshot.env_key", + "model_providers.moonshot.name", + "model_providers.moonshot.wire_api", "model_providers.openai.base_url", "model_providers.openai.env_key", "model_providers.openai.name", - "model_providers.openai.wire_api" + "model_providers.openai.wire_api", + "model_providers.qwen.base_url", + "model_providers.qwen.env_key", + "model_providers.qwen.name", + "model_providers.qwen.wire_api" ], "reason": "codex config: switch to profile \"unknown-keys\"" } diff --git a/internal/adapter/codex/testdata/codex/happy/anthropic-provider/expected/plans.json b/internal/adapter/codex/testdata/codex/happy/anthropic-provider/expected/plans.json index db8805b..9c15403 100644 --- a/internal/adapter/codex/testdata/codex/happy/anthropic-provider/expected/plans.json +++ b/internal/adapter/codex/testdata/codex/happy/anthropic-provider/expected/plans.json @@ -24,10 +24,26 @@ "model_providers.anthropic.env_key", "model_providers.anthropic.name", "model_providers.anthropic.wire_api", + "model_providers.deepseek.base_url", + "model_providers.deepseek.env_key", + "model_providers.deepseek.name", + "model_providers.deepseek.wire_api", + "model_providers.glm.base_url", + "model_providers.glm.env_key", + "model_providers.glm.name", + "model_providers.glm.wire_api", + "model_providers.moonshot.base_url", + "model_providers.moonshot.env_key", + "model_providers.moonshot.name", + "model_providers.moonshot.wire_api", "model_providers.openai.base_url", "model_providers.openai.env_key", "model_providers.openai.name", - "model_providers.openai.wire_api" + "model_providers.openai.wire_api", + "model_providers.qwen.base_url", + "model_providers.qwen.env_key", + "model_providers.qwen.name", + "model_providers.qwen.wire_api" ], "reason": "codex config: switch to profile \"anthropic-provider\"" } diff --git a/internal/adapter/codex/testdata/codex/happy/kitchen-sink/expected/plans.json b/internal/adapter/codex/testdata/codex/happy/kitchen-sink/expected/plans.json index 4c0e825..9134300 100644 --- a/internal/adapter/codex/testdata/codex/happy/kitchen-sink/expected/plans.json +++ b/internal/adapter/codex/testdata/codex/happy/kitchen-sink/expected/plans.json @@ -24,10 +24,26 @@ "model_providers.anthropic.env_key", "model_providers.anthropic.name", "model_providers.anthropic.wire_api", + "model_providers.deepseek.base_url", + "model_providers.deepseek.env_key", + "model_providers.deepseek.name", + "model_providers.deepseek.wire_api", + "model_providers.glm.base_url", + "model_providers.glm.env_key", + "model_providers.glm.name", + "model_providers.glm.wire_api", + "model_providers.moonshot.base_url", + "model_providers.moonshot.env_key", + "model_providers.moonshot.name", + "model_providers.moonshot.wire_api", "model_providers.openai.base_url", "model_providers.openai.env_key", "model_providers.openai.name", - "model_providers.openai.wire_api" + "model_providers.openai.wire_api", + "model_providers.qwen.base_url", + "model_providers.qwen.env_key", + "model_providers.qwen.name", + "model_providers.qwen.wire_api" ], "reason": "codex config: switch to profile \"kitchen-sink\"" } diff --git a/internal/adapter/codex/testdata/codex/happy/minimal/expected/plans.json b/internal/adapter/codex/testdata/codex/happy/minimal/expected/plans.json index ecbcdd3..0f536ea 100644 --- a/internal/adapter/codex/testdata/codex/happy/minimal/expected/plans.json +++ b/internal/adapter/codex/testdata/codex/happy/minimal/expected/plans.json @@ -24,10 +24,26 @@ "model_providers.anthropic.env_key", "model_providers.anthropic.name", "model_providers.anthropic.wire_api", + "model_providers.deepseek.base_url", + "model_providers.deepseek.env_key", + "model_providers.deepseek.name", + "model_providers.deepseek.wire_api", + "model_providers.glm.base_url", + "model_providers.glm.env_key", + "model_providers.glm.name", + "model_providers.glm.wire_api", + "model_providers.moonshot.base_url", + "model_providers.moonshot.env_key", + "model_providers.moonshot.name", + "model_providers.moonshot.wire_api", "model_providers.openai.base_url", "model_providers.openai.env_key", "model_providers.openai.name", - "model_providers.openai.wire_api" + "model_providers.openai.wire_api", + "model_providers.qwen.base_url", + "model_providers.qwen.env_key", + "model_providers.qwen.name", + "model_providers.qwen.wire_api" ], "reason": "codex config: switch to profile \"minimal\"" } diff --git a/internal/adapter/codex/testdata/codex/happy/openai-provider/expected/plans.json b/internal/adapter/codex/testdata/codex/happy/openai-provider/expected/plans.json index 9ee83af..c7f865e 100644 --- a/internal/adapter/codex/testdata/codex/happy/openai-provider/expected/plans.json +++ b/internal/adapter/codex/testdata/codex/happy/openai-provider/expected/plans.json @@ -24,10 +24,26 @@ "model_providers.anthropic.env_key", "model_providers.anthropic.name", "model_providers.anthropic.wire_api", + "model_providers.deepseek.base_url", + "model_providers.deepseek.env_key", + "model_providers.deepseek.name", + "model_providers.deepseek.wire_api", + "model_providers.glm.base_url", + "model_providers.glm.env_key", + "model_providers.glm.name", + "model_providers.glm.wire_api", + "model_providers.moonshot.base_url", + "model_providers.moonshot.env_key", + "model_providers.moonshot.name", + "model_providers.moonshot.wire_api", "model_providers.openai.base_url", "model_providers.openai.env_key", "model_providers.openai.name", - "model_providers.openai.wire_api" + "model_providers.openai.wire_api", + "model_providers.qwen.base_url", + "model_providers.qwen.env_key", + "model_providers.qwen.name", + "model_providers.qwen.wire_api" ], "reason": "codex config: switch to profile \"openai-provider\"" } diff --git a/internal/presets/catalog.go b/internal/presets/catalog.go new file mode 100644 index 0000000..2351b0b --- /dev/null +++ b/internal/presets/catalog.go @@ -0,0 +1,206 @@ +// Package presets declares the closed built-in provider preset catalog. +// +// Presets are transparent local data templates. They expand into the +// ordinary v1 Profile shape and do not carry secrets, probe endpoints, or +// validate credentials online. +package presets + +import ( + "fmt" + "sort" + "strings" + + "github.com/a2d2-dev/claudecm/internal/config" +) + +const noOfficialSupportDisclaimer = "Convenience template only; not official provider support, certification, endorsement, or a compatibility guarantee." + +// SecretField describes a required secret the normal add flow must collect. +type SecretField struct { + Name string + Description string +} + +// Preset is one immutable catalog entry. +type Preset struct { + Name string + DisplayName string + ProviderKey string + BaseURL string + Model string + + Tools map[config.ToolID]config.ToolOverlay + Secrets []SecretField + Disclaimer string +} + +// Catalog is the closed built-in preset list. It is exported so command help, +// tests, and docs generators can inspect the shipped templates without +// duplicating data. +// +// DO NOT mutate this slice at runtime. It is package-level only because Go has +// no read-only slice literals; treat it as constant, mirroring adapter owned-key +// allowlist vars. +var Catalog = []Preset{ + { + Name: "deepseek", + DisplayName: "DeepSeek", + ProviderKey: "deepseek", + BaseURL: "https://api.deepseek.com", + Model: "deepseek-v4", + Tools: map[config.ToolID]config.ToolOverlay{ + config.ToolCodex: codexOverlay("deepseek", "DeepSeek", "https://api.deepseek.com", "deepseek-v4", "OPENAI_API_KEY"), + }, + Secrets: apiKeySecret(), + Disclaimer: noOfficialSupportDisclaimer, + }, + { + Name: "glm", + DisplayName: "GLM", + ProviderKey: "glm", + BaseURL: "https://open.bigmodel.cn/api/paas/v4", + Model: "glm-4.5", + Tools: map[config.ToolID]config.ToolOverlay{ + config.ToolCodex: codexOverlay("glm", "GLM", "https://open.bigmodel.cn/api/paas/v4", "glm-4.5", "OPENAI_API_KEY"), + }, + Secrets: apiKeySecret(), + Disclaimer: noOfficialSupportDisclaimer, + }, + { + Name: "moonshot", + DisplayName: "Moonshot AI", + ProviderKey: "moonshot", + BaseURL: "https://api.moonshot.cn/v1", + Model: "kimi-k2-0711-preview", + Tools: map[config.ToolID]config.ToolOverlay{ + config.ToolCodex: codexOverlay("moonshot", "Moonshot AI", "https://api.moonshot.cn/v1", "kimi-k2-0711-preview", "OPENAI_API_KEY"), + }, + Secrets: apiKeySecret(), + Disclaimer: noOfficialSupportDisclaimer, + }, + { + Name: "qwen", + DisplayName: "Qwen", + ProviderKey: "qwen", + BaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", + Model: "qwen3-coder-plus", + Tools: map[config.ToolID]config.ToolOverlay{ + config.ToolCodex: codexOverlay("qwen", "Qwen", "https://dashscope.aliyuncs.com/compatible-mode/v1", "qwen3-coder-plus", "OPENAI_API_KEY"), + }, + Secrets: apiKeySecret(), + Disclaimer: noOfficialSupportDisclaimer, + }, +} + +// Lookup resolves user input case-insensitively and returns a defensive copy. +func Lookup(name string) (Preset, error) { + needle := strings.ToLower(strings.TrimSpace(name)) + for _, p := range Catalog { + if p.Name == needle { + return clonePreset(p), nil + } + } + return Preset{}, fmt.Errorf("unknown preset %q (available presets: %s)", name, strings.Join(Names(), ", ")) +} + +// Names returns canonical preset names in display order. +func Names() []string { + out := make([]string, len(Catalog)) + for i, p := range Catalog { + out[i] = p.Name + } + return out +} + +func init() { + names := make([]string, 0, len(Catalog)) + seen := make(map[string]struct{}, len(Catalog)) + for _, p := range Catalog { + if p.Name == "" { + panic("presets.Catalog: preset name cannot be empty") + } + if p.Name != strings.ToLower(p.Name) { + panic("presets.Catalog: preset names must be lowercase") + } + if _, dup := seen[p.Name]; dup { + panic("presets.Catalog: duplicate preset name: " + p.Name) + } + seen[p.Name] = struct{}{} + names = append(names, p.Name) + if p.DisplayName == "" || p.ProviderKey == "" || p.BaseURL == "" || p.Model == "" { + panic("presets.Catalog: preset " + p.Name + " is missing required fields") + } + if p.ProviderKey != p.Name { + panic("presets.Catalog: preset " + p.Name + " provider key must match canonical name") + } + if p.Disclaimer == "" { + panic("presets.Catalog: preset " + p.Name + " is missing disclaimer") + } + for _, secret := range p.Secrets { + if strings.Contains(strings.ToLower(secret.Name), "sk-") || + strings.Contains(strings.ToLower(secret.Description), "sk-") { + panic("presets.Catalog: preset " + p.Name + " secret metadata contains a token-looking value") + } + } + } + if !sort.StringsAreSorted(names) { + panic("presets.Catalog: preset names must be sorted") + } +} + +func apiKeySecret() []SecretField { + return []SecretField{{ + Name: "api_key", + Description: "API key supplied by the user through claudecm add", + }} +} + +func codexOverlay(provider, displayName, baseURL, model, envKey string) config.ToolOverlay { + return config.ToolOverlay{ + Raw: map[string]any{ + "model": model, + "model_provider": provider, + "model_providers." + provider + ".base_url": baseURL, + "model_providers." + provider + ".env_key": envKey, + "model_providers." + provider + ".name": displayName, + "model_providers." + provider + ".wire_api": "chat", + }, + } +} + +func clonePreset(p Preset) Preset { + out := p + if p.Tools != nil { + out.Tools = make(map[config.ToolID]config.ToolOverlay, len(p.Tools)) + for id, ov := range p.Tools { + out.Tools[id] = cloneOverlay(ov) + } + } + if p.Secrets != nil { + out.Secrets = make([]SecretField, len(p.Secrets)) + copy(out.Secrets, p.Secrets) + } + return out +} + +func cloneOverlay(ov config.ToolOverlay) config.ToolOverlay { + out := config.ToolOverlay{ + BaseURL: ov.BaseURL, + APIKey: ov.APIKey, + Model: ov.Model, + SmallFastModel: ov.SmallFastModel, + } + if ov.ExtraEnv != nil { + out.ExtraEnv = make(map[string]string, len(ov.ExtraEnv)) + for k, v := range ov.ExtraEnv { + out.ExtraEnv[k] = v + } + } + if ov.Raw != nil { + out.Raw = make(map[string]any, len(ov.Raw)) + for k, v := range ov.Raw { + out.Raw[k] = v + } + } + return out +} diff --git a/internal/presets/catalog_test.go b/internal/presets/catalog_test.go new file mode 100644 index 0000000..3183311 --- /dev/null +++ b/internal/presets/catalog_test.go @@ -0,0 +1,114 @@ +package presets + +import ( + "strings" + "testing" + + "github.com/a2d2-dev/claudecm/internal/config" +) + +func TestLookup_HappyBuiltInsExpandToProfileDraftData(t *testing.T) { + for _, name := range []string{"deepseek", "glm", "moonshot", "qwen"} { + p, err := Lookup(name) + if err != nil { + t.Fatalf("Lookup(%q): %v", name, err) + } + if p.Name != name { + t.Fatalf("Lookup(%q).Name = %q", name, p.Name) + } + if p.ProviderKey == "" || p.BaseURL == "" || p.Model == "" { + t.Fatalf("Lookup(%q) missing required non-secret fields: %+v", name, p) + } + if p.ProviderKey != name { + t.Fatalf("Lookup(%q).ProviderKey = %q, want %q", name, p.ProviderKey, name) + } + if len(p.Secrets) != 1 || p.Secrets[0].Name != "api_key" { + t.Fatalf("Lookup(%q).Secrets = %+v, want api_key only", name, p.Secrets) + } + if !strings.Contains(p.Disclaimer, "not official provider support") { + t.Fatalf("Lookup(%q).Disclaimer missing no-official-support boundary: %q", name, p.Disclaimer) + } + codex, ok := p.Tools[config.ToolCodex] + if !ok { + t.Fatalf("Lookup(%q) missing codex overlay", name) + } + if got := codex.Raw["model"]; got != p.Model { + t.Fatalf("Lookup(%q) codex raw model = %v, want %q", name, got, p.Model) + } + if got := codex.Raw["model_provider"]; got != p.ProviderKey { + t.Fatalf("Lookup(%q) codex raw model_provider = %v, want %q", name, got, p.ProviderKey) + } + if got := codex.Raw["model_providers."+name+".base_url"]; got != p.BaseURL { + t.Fatalf("Lookup(%q) codex raw base_url = %v, want %q", name, got, p.BaseURL) + } + } +} + +func TestLookup_CaseInsensitiveCanonicalizes(t *testing.T) { + p, err := Lookup("MoonShot") + if err != nil { + t.Fatalf("Lookup(MoonShot): %v", err) + } + if p.Name != "moonshot" { + t.Fatalf("Name = %q, want moonshot", p.Name) + } +} + +func TestLookup_UnknownListsAvailable(t *testing.T) { + _, err := Lookup("unknown") + if err == nil { + t.Fatalf("Lookup(unknown) = nil error") + } + msg := err.Error() + if !strings.Contains(msg, "available presets: deepseek, glm, moonshot, qwen") { + t.Fatalf("error did not list presets: %v", err) + } +} + +func TestCatalogContainsNoSecretValues(t *testing.T) { + for _, p := range Catalog { + check := map[string]string{ + "display_name": p.DisplayName, + "provider_key": p.ProviderKey, + "base_url": p.BaseURL, + "model": p.Model, + "disclaimer": p.Disclaimer, + } + for k, v := range check { + if strings.Contains(strings.ToLower(v), "sk-") { + t.Fatalf("preset %q field %s contains token-looking value %q", p.Name, k, v) + } + } + for tool, ov := range p.Tools { + if strings.Contains(strings.ToLower(ov.APIKey), "sk-") { + t.Fatalf("preset %q tool %s carries API key %q", p.Name, tool, ov.APIKey) + } + for k, v := range ov.ExtraEnv { + if strings.Contains(strings.ToLower(v), "sk-") { + t.Fatalf("preset %q tool %s extra_env %s contains token-looking value", p.Name, tool, k) + } + } + for k, v := range ov.Raw { + if s, ok := v.(string); ok && strings.Contains(strings.ToLower(s), "sk-") { + t.Fatalf("preset %q tool %s raw %s contains token-looking value", p.Name, tool, k) + } + } + } + } +} + +func TestLookupReturnsDefensiveCopy(t *testing.T) { + p, err := Lookup("qwen") + if err != nil { + t.Fatalf("Lookup(qwen): %v", err) + } + p.Tools[config.ToolCodex].Raw["model"] = "mutated" + + again, err := Lookup("qwen") + if err != nil { + t.Fatalf("Lookup(qwen) again: %v", err) + } + if got := again.Tools[config.ToolCodex].Raw["model"]; got == "mutated" { + t.Fatalf("Lookup returned shared mutable overlay map") + } +}