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")
+ }
+}