diff --git a/README.md b/README.md index 07ae941..53625d3 100644 --- a/README.md +++ b/README.md @@ -71,9 +71,10 @@ claudecm add work --preset moonshot --api-key sk-ant-xxxxxxxx # use the network; --dry-run previews the redacted draft without writing. claudecm add work --from-text 'ANTHROPIC_BASE_URL=https://api.anthropic.com ANTHROPIC_AUTH_TOKEN=sk-ant-xxxxxxxx' --dry-run -# Or sweep local sources at once and skip credentials already recorded -# by the same base_url + api_key. -claudecm add work --auto --dry-run +# Or sweep local sources at once. Each new credential becomes one +# auto-named profile; already-recorded credentials are skipped. +claudecm add --auto --dry-run +claudecm add --auto --yes # Optional AI parse is opt-in per invocation and requires an interactive TTY. # claudecm strips secret-shaped tokens locally, shows the desensitized payload @@ -151,9 +152,9 @@ Presets are convenience templates, not official provider support, certification, - `--from-env` reads the Claude Code / Codex environment-variable allowlist. - `--from-file ` parses dotenv, shell, JSON, YAML, or TOML config files. - `--from-text ` or `--from-text -` parses pasted text with local heuristics. -- `--auto` / `-a` sweeps clipboard, environment, `~/.claude/settings.json`, and `~/.codex/{auth.json,config.toml}` in order, drops candidates without an API key, and marks credentials whose `(base_url, api_key)` are already recorded. +- `--auto` / `-a` takes no profile name. It sweeps clipboard, environment, `~/.claude/settings.json`, and `~/.codex/{auth.json,config.toml}` in order, drops candidates without an API key, collapses duplicate credentials, skips credentials already recorded, and creates one profile per remaining credential. -These paths are local-first. `--auto` is zero-network and best-effort: a missing clipboard tool or absent config file is reported and does not stop the other sources. If it finds one new credential, it enters the normal redacted preview/save path; if it finds several, non-interactive runs refuse with a redacted list and interactive runs ask which one to use. Without `--ai`, pasted text never leaves the machine. `--ai` is an explicit escalation for `--from-text`: claudecm requires an interactive terminal, runs the local redaction pass first, shows the full desensitized payload for confirmation, keeps captured secrets in-process, sends only the confirmed desensitized text to an Anthropic-compatible Messages endpoint using the active profile's credentials (or `--ai-profile `), then re-injects the secret locally before the normal redacted preview/save path. Non-interactive or piped `--ai` runs refuse before any parse request is sent. +These paths are local-first. `--auto` is zero-network and best-effort: a missing clipboard tool, absent config file, or malformed optional source is reported and does not stop the other sources. It reads Codex credentials leniently, so an unreadable `config.toml` never suppresses an `auth.json` API key. Use `--dry-run` to preview every profile that would be created. Interactive terminals prompt for each new credential name with a derived default and accept Enter to keep it, then ask for confirmation before writing; `--yes` and non-interactive runs use derived names without prompting, and non-interactive runs require `--yes`. Without `--ai`, pasted text never leaves the machine. `--ai` is an explicit escalation for `--from-text`: claudecm requires an interactive terminal, runs the local redaction pass first, shows the full desensitized payload for confirmation, keeps captured secrets in-process, sends only the confirmed desensitized text to an Anthropic-compatible Messages endpoint using the active profile's credentials (or `--ai-profile `), then re-injects the secret locally before the normal redacted preview/save path. Non-interactive or piped `--ai` runs refuse before any parse request is sent. ## Deeper reading diff --git a/cmd/add.go b/cmd/add.go index 2ec774e..8879c07 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -98,6 +98,7 @@ var ( addAIProfileFlag string addListPresetsFlag bool addDryRunFlag bool + addYesFlag bool addOverwriteFlag bool addOutputFlag string ) @@ -189,8 +190,11 @@ EXAMPLES claudecm add work --from-text 'ANTHROPIC_BASE_URL=https://api.anthropic.com ANTHROPIC_AUTH_TOKEN=sk-...' --dry-run cat provider.txt | claudecm add work --from-text - - # Sweep local sources and skip credentials already recorded. - claudecm add work --auto --dry-run + # Sweep local sources and auto-name one profile per new credential. + # On an interactive terminal, each new credential prompts: + # Save profile for as [derived-name]: + claudecm add --auto --dry-run + claudecm add --auto --yes # Opt in to one secret-free LLM parse from an interactive terminal. claudecm add work --from-text 'messy provider note with sk-...' --ai --dry-run @@ -210,6 +214,12 @@ to make it the active profile.`, if addListPresetsFlag { return cobra.NoArgs(cmd, args) } + if addAutoFlag { + if len(args) > 0 { + return fmt.Errorf("--auto does not take a profile name; names are derived from discovered sources") + } + return cobra.NoArgs(cmd, args) + } return cobra.ExactArgs(1)(cmd, args) }, RunE: runAdd, @@ -234,6 +244,7 @@ func init() { "Sparse overlay entry (repeatable). Format: tools..=. "+ "Supported: tools.claude_code.env.=, tools.codex.raw.=") addCmd.Flags().BoolVar(&addDryRunFlag, "dry-run", false, "Print the would-be profile and exit without writing") + addCmd.Flags().BoolVar(&addYesFlag, "yes", false, "Skip interactive naming and confirmation for --auto") addCmd.Flags().BoolVar(&addOverwriteFlag, "overwrite", false, "Allow replacing an existing profile with the same name") addCmd.Flags().StringVarP(&addOutputFlag, "output", "o", "text", "Output format (text|json)") @@ -246,6 +257,9 @@ func init() { // bytes.Buffers. func runAdd(cmd *cobra.Command, args []string) error { if addListPresetsFlag { + if len(args) != 0 { + return fmt.Errorf("--list-presets does not take a profile name") + } format, err := parseAddOutput(addOutputFlag) if err != nil { return err @@ -253,9 +267,12 @@ func runAdd(cmd *cobra.Command, args []string) error { return renderPresetList(cmd.OutOrStdout(), format) } - name := strings.TrimSpace(args[0]) - if err := storage.ValidateProfileName(name); err != nil { - return err + if addAutoFlag { + if len(args) != 0 { + return fmt.Errorf("--auto does not take a profile name; names are derived from discovered sources") + } + } else if len(args) != 1 { + return fmt.Errorf("add requires exactly one profile name unless --auto is used") } format, err := parseAddOutput(addOutputFlag) @@ -298,6 +315,15 @@ func runAdd(cmd *cobra.Command, args []string) error { } store := storage.NewFileStorage(resv) + if addAutoFlag { + return runAddAuto(cmd, resv, store, format) + } + + name := strings.TrimSpace(args[0]) + if err := storage.ValidateProfileName(name); err != nil { + return err + } + provider := addProviderFlag baseURL := addBaseURLFlag apiKey := addAPIKeyFlag @@ -310,23 +336,6 @@ func runAdd(cmd *cobra.Command, args []string) error { model = preset.Model tools = cloneToolMap(preset.Tools) } - if addAutoFlag { - core, autoTools, done, err := profileDraftFromAuto(cmd, resv, store, format) - if err != nil { - return err - } - if done { - return nil - } - if core.Provider != "" { - provider = core.Provider - } - baseURL = core.BaseURL - apiKey = core.APIKey - model = core.Model - smallFastModel = core.SmallFastModel - tools = mergeToolMaps(tools, autoTools) - } if addFromEnvFlag { core, envTools, err := profileDraftFromEnv() if err != nil { diff --git a/cmd/add_auto.go b/cmd/add_auto.go index 511c560..a4d6ea5 100644 --- a/cmd/add_auto.go +++ b/cmd/add_auto.go @@ -1,23 +1,27 @@ package cmd import ( - "bufio" "context" + "encoding/json" "errors" "fmt" "io" "net" "net/url" "os" - "strconv" "strings" + "time" + "unicode" "github.com/spf13/cobra" - "github.com/a2d2-dev/claudecm/internal/adapter" + claudecodeadapter "github.com/a2d2-dev/claudecm/internal/adapter/claudecode" + codexadapter "github.com/a2d2-dev/claudecm/internal/adapter/codex" + codextoml "github.com/a2d2-dev/claudecm/internal/adapter/codex/toml" "github.com/a2d2-dev/claudecm/internal/blobparse" "github.com/a2d2-dev/claudecm/internal/config" "github.com/a2d2-dev/claudecm/internal/storage" + "github.com/a2d2-dev/claudecm/internal/writepath" ) type addAutoSource string @@ -25,14 +29,17 @@ type addAutoSource string const ( addAutoSourceClipboard addAutoSource = "clipboard" addAutoSourceEnv addAutoSource = "environment" - addAutoSourceClaudeCode addAutoSource = "~/.claude/settings.json" - addAutoSourceCodex addAutoSource = "~/.codex/auth.json + config.toml" + addAutoSourceClaudeCode addAutoSource = "claude-code" + addAutoSourceCodex addAutoSource = "codex" ) type addAutoCandidate struct { - Source string - Core config.CoreConfig - Tools map[config.ToolID]config.ToolOverlay + Source string + NameBase string + Sources []string + Core config.CoreConfig + Tools map[config.ToolID]config.ToolOverlay + ProfileName string AlreadyProfile string DuplicateOf string @@ -46,55 +53,107 @@ type addAutoSourceResult struct { type addAutoClipboardReader func() (string, bool, error) -func profileDraftFromAuto( +type addAutoProfileStore interface { + LoadAllProfiles() ([]*config.Profile, error) + ProfileExists(name string) (bool, error) + SaveProfile(profile *config.Profile) error +} + +func runAddAuto( cmd *cobra.Command, resv *storage.Resolver, - store *storage.FileStorage, + store addAutoProfileStore, format addOutputFormat, -) (config.CoreConfig, map[config.ToolID]config.ToolOverlay, bool, error) { +) error { results := sweepAddAutoSources(context.Background(), resv, readClipboardText) if err := markExistingAddAutoCandidates(results, store); err != nil { - return config.CoreConfig{}, nil, false, err + return err } markDuplicateAddAutoCandidates(results) if err := renderAddAutoDiscovery(cmd.OutOrStdout(), format, results); err != nil { - return config.CoreConfig{}, nil, false, err + return err } - var newCandidates []addAutoCandidate - for _, result := range results { - for _, candidate := range result.Candidates { - if candidate.AlreadyProfile == "" && candidate.DuplicateOf == "" { - newCandidates = append(newCandidates, candidate) - } - } + newCandidates, err := newAddAutoCandidates(results) + if err != nil { + return err } - switch { case len(newCandidates) == 0 && countAddAutoCandidatesWithKey(results) == 0: - return config.CoreConfig{}, nil, false, fmt.Errorf("no credentials with API keys found in swept sources: %s", addAutoSourceList(results)) + return fmt.Errorf("no credentials with API keys found in swept sources: %s", addAutoSourceList(results)) case len(newCandidates) == 0: if format == addOutputText { fmt.Fprintln(cmd.OutOrStdout(), "all discovered credentials are already recorded") + } else if err := renderAddAutoResultJSON(cmd.OutOrStdout(), "already-recorded", results, nil, nil); err != nil { + return err } - return config.CoreConfig{}, nil, true, nil - case len(newCandidates) == 1: - return newCandidates[0].Core, newCandidates[0].Tools, false, nil - default: - chosen, err := chooseAddAutoCandidate(cmd.OutOrStdout(), os.Stdin, format, newCandidates) - if err != nil { - return config.CoreConfig{}, nil, false, err + return nil + } + + existingNames, err := loadAddAutoProfileNames(store) + if err != nil { + return err + } + assignAddAutoProfileNames(newCandidates, existingNames) + + if addDryRunFlag { + profiles := buildAddAutoProfiles(newCandidates, nowFn().UTC()) + return renderAddAutoDryRun(cmd.OutOrStdout(), format, results, profiles) + } + if !addYesFlag { + if !isTerminal(os.Stdin) { + profiles := buildAddAutoProfiles(newCandidates, nowFn().UTC()) + if format == addOutputText { + fmt.Fprintln(cmd.OutOrStdout(), "profiles to create:") + renderAddAutoProfileTextList(cmd.OutOrStdout(), profiles) + } else if err := renderAddAutoResultJSON(cmd.OutOrStdout(), "confirm-required", results, profiles, nil); err != nil { + return err + } + return fmt.Errorf("non-interactive session: pass --yes to create discovered profiles or --dry-run to preview") + } + if err := promptAddAutoProfileNames(cmd.OutOrStdout(), os.Stdin, newCandidates, existingNames); err != nil { + return err } - return chosen.Core, chosen.Tools, false, nil } + profiles := buildAddAutoProfiles(newCandidates, nowFn().UTC()) + if !addYesFlag { + if format == addOutputText { + fmt.Fprintln(cmd.OutOrStdout(), "profiles to create:") + renderAddAutoProfileTextList(cmd.OutOrStdout(), profiles) + } + ok, promptErr := promptConfirm(cmd.OutOrStdout(), os.Stdin, "Create these profiles?") + if promptErr != nil { + return fmt.Errorf("read confirmation: %w", promptErr) + } + if !ok { + return fmt.Errorf("auto add refused by user") + } + } + + created := make([]*config.Profile, 0, len(profiles)) + for _, profile := range profiles { + if err := storage.ValidateProfileName(profile.Name); err != nil { + return fmt.Errorf("derived profile name %q is invalid: %w", profile.Name, err) + } + if exists, err := store.ProfileExists(profile.Name); err != nil { + return fmt.Errorf("failed to check whether profile %q exists: %w", profile.Name, err) + } else if exists { + return fmt.Errorf("derived profile name %q already exists", profile.Name) + } + if err := store.SaveProfile(profile); err != nil { + return addAutoPartialCreateError(created, profile.Name, err) + } + created = append(created, profile) + } + return renderAddAutoCreated(cmd.OutOrStdout(), format, results, created) } func sweepAddAutoSources(ctx context.Context, resv *storage.Resolver, clipboard addAutoClipboardReader) []addAutoSourceResult { results := make([]addAutoSourceResult, 0, len(addAutoSourceOrder())) results = append(results, scanAddAutoClipboard(clipboard)) results = append(results, scanAddAutoEnv()) - results = append(results, scanAddAutoAdapter(ctx, resv, addAutoSourceClaudeCode, adapter.ToolClaudeCode)) - results = append(results, scanAddAutoAdapter(ctx, resv, addAutoSourceCodex, adapter.ToolCodex)) + results = append(results, scanAddAutoClaudeCode(ctx, resv)) + results = append(results, scanAddAutoCodex(ctx, resv)) return results } @@ -124,31 +183,82 @@ func scanAddAutoClipboard(reader addAutoClipboardReader) addAutoSourceResult { return result } result.Candidates = []addAutoCandidate{{ - Source: result.Source, - Core: parsed, + Source: result.Source, + NameBase: addAutoBaseNameFor(result.Source, parsed.BaseURL), + Sources: []string{result.Source}, + Core: parsed, }} return result } func scanAddAutoEnv() addAutoSourceResult { - core, tools, err := profileDraftFromEnv() result := addAutoSourceResult{Source: string(addAutoSourceEnv)} - if err != nil { - result.Note = "skipped: " + err.Error() - return result - } - if strings.TrimSpace(core.APIKey) == "" { + candidates := scanAddAutoEnvCandidates() + if len(candidates) == 0 { result.Note = "no API key found" return result } - result.Candidates = []addAutoCandidate{{ - Source: result.Source, - Core: core, - Tools: tools, - }} + result.Candidates = candidates return result } +func scanAddAutoEnvCandidates() []addAutoCandidate { + var out []addAutoCandidate + anthropic := config.CoreConfig{Provider: addProviderDefault} + var anthropicTools map[config.ToolID]config.ToolOverlay + if v := lookupNonEmptyEnv("ANTHROPIC_BASE_URL"); v != "" { + anthropic.BaseURL = v + } + if v := lookupNonEmptyEnv("ANTHROPIC_AUTH_TOKEN"); v != "" { + anthropic.APIKey = v + } + if v := lookupNonEmptyEnv("ANTHROPIC_API_KEY"); v != "" { + if anthropic.APIKey == "" { + anthropic.APIKey = v + } else { + anthropicTools = putClaudeCodeEnv(anthropicTools, "ANTHROPIC_API_KEY", v) + } + } + if v := lookupNonEmptyEnv("ANTHROPIC_MODEL"); v != "" { + anthropic.Model = v + } + if v := lookupNonEmptyEnv("ANTHROPIC_SMALL_FAST_MODEL"); v != "" { + anthropic.SmallFastModel = v + } + if strings.TrimSpace(anthropic.APIKey) != "" { + out = append(out, addAutoCandidate{ + Source: string(addAutoSourceEnv), + NameBase: addAutoBaseNameFor(string(addAutoSourceEnv), anthropic.BaseURL), + Sources: []string{string(addAutoSourceEnv)}, + Core: anthropic, + Tools: anthropicTools, + }) + } + + codex := config.CoreConfig{Provider: "openai-compat"} + if v := lookupNonEmptyEnv("OPENAI_BASE_URL"); v != "" { + codex.BaseURL = v + } + if v := lookupNonEmptyEnv("OPENAI_API_KEY"); v != "" { + codex.APIKey = v + } + if v := lookupNonEmptyEnv("CODEX_MODEL"); v != "" { + codex.Model = v + } + if v := normalizeCodexProvider(lookupNonEmptyEnv("CODEX_MODEL_PROVIDER")); v != "" { + codex.Provider = v + } + if strings.TrimSpace(codex.APIKey) != "" { + out = append(out, addAutoCandidate{ + Source: string(addAutoSourceEnv), + NameBase: addAutoBaseNameFor(string(addAutoSourceEnv), codex.BaseURL), + Sources: []string{string(addAutoSourceEnv)}, + Core: codex, + }) + } + return out +} + func coreFromAddAutoAdapterOverlay(core config.CoreConfig, overlay config.ToolOverlay) config.CoreConfig { providerRaw := firstStringRawValue(overlay.Raw, "model_provider") if strings.TrimSpace(core.BaseURL) == "" { @@ -180,36 +290,358 @@ func firstStringRawValue(raw map[string]any, key string) string { return "" } -func scanAddAutoAdapter(ctx context.Context, resv *storage.Resolver, source addAutoSource, tool adapter.ToolID) addAutoSourceResult { - result := addAutoSourceResult{Source: string(source)} - a, ok := adapter.DefaultRegistry.Get(tool) - if !ok { - result.Note = "skipped: no adapter registered" - return result +func scanAddAutoClaudeCode(ctx context.Context, resv *storage.Resolver) addAutoSourceResult { + result := addAutoSourceResult{Source: string(addAutoSourceClaudeCode)} + core, overlay, note := lenientReadClaudeCode(ctx, resv) + if note != "" { + result.Note = note } - core, overlay, err := a.Import(ctx, resv) - if err != nil { - result.Note = "skipped: " + err.Error() + if strings.TrimSpace(core.APIKey) == "" { + if result.Note == "" { + result.Note = "no API key found" + } return result } - core = coreFromAddAutoAdapterOverlay(core, overlay) + tools := map[config.ToolID]config.ToolOverlay{} + if !isEmptyOverlay(overlay) { + tools[config.ToolClaudeCode] = overlay + } + result.Candidates = []addAutoCandidate{{ + Source: result.Source, + NameBase: "claude-code", + Sources: []string{result.Source}, + Core: normalizeParsedProvider(core), + Tools: tools, + }} + return result +} + +func scanAddAutoCodex(ctx context.Context, resv *storage.Resolver) addAutoSourceResult { + result := addAutoSourceResult{Source: string(addAutoSourceCodex)} + core, overlay, note := lenientReadCodex(ctx, resv) + if note != "" { + result.Note = note + } if strings.TrimSpace(core.APIKey) == "" { - result.Note = "no API key found" + if result.Note == "" { + result.Note = "no API key found" + } return result } tools := map[config.ToolID]config.ToolOverlay{} if !isEmptyOverlay(overlay) { - tools[tool] = overlay + tools[config.ToolCodex] = overlay } result.Candidates = []addAutoCandidate{{ - Source: result.Source, - Core: normalizeParsedProvider(core), - Tools: tools, + Source: result.Source, + NameBase: "codex", + Sources: []string{result.Source}, + Core: normalizeParsedProvider(coreFromAddAutoAdapterOverlay(core, overlay)), + Tools: tools, }} return result } -func markExistingAddAutoCandidates(results []addAutoSourceResult, store *storage.FileStorage) error { +func lenientReadClaudeCode(ctx context.Context, resv *storage.Resolver) (config.CoreConfig, config.ToolOverlay, string) { + if err := ctx.Err(); err != nil { + return config.CoreConfig{}, config.ToolOverlay{}, "skipped: " + err.Error() + } + path := claudecodeadapter.SettingsPath(resv) + if err := claudecodeadapter.VerifyReadTargetInHome(path, resv); err != nil { + if errors.Is(err, claudecodeadapter.ErrNoConfig) || errors.Is(err, os.ErrNotExist) { + return config.CoreConfig{}, config.ToolOverlay{}, "skipped: settings.json not found" + } + return config.CoreConfig{}, config.ToolOverlay{}, "skipped: settings.json refused: " + err.Error() + } + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return config.CoreConfig{}, config.ToolOverlay{}, "skipped: settings.json not found" + } + return config.CoreConfig{}, config.ToolOverlay{}, "skipped: " + err.Error() + } + if len(strings.TrimSpace(string(data))) == 0 { + return config.CoreConfig{}, config.ToolOverlay{}, "no API key found" + } + var root map[string]any + if err := json.Unmarshal(data, &root); err != nil { + return config.CoreConfig{}, config.ToolOverlay{}, "skipped: settings.json parse failed: " + err.Error() + } + env, _ := root["env"].(map[string]any) + if len(env) == 0 { + return config.CoreConfig{}, config.ToolOverlay{}, "no API key found" + } + + core := config.CoreConfig{Provider: addProviderDefault} + var overlay config.ToolOverlay + if v, ok := stringMapValue(env, "ANTHROPIC_BASE_URL"); ok { + core.BaseURL = v + } + if v, ok := stringMapValue(env, "ANTHROPIC_MODEL"); ok { + core.Model = v + } + if v, ok := stringMapValue(env, "ANTHROPIC_SMALL_FAST_MODEL"); ok { + core.SmallFastModel = v + } + authToken, hasAuth := stringMapValue(env, "ANTHROPIC_AUTH_TOKEN") + apiKey, hasAPIKey := stringMapValue(env, "ANTHROPIC_API_KEY") + switch { + case hasAuth && hasAPIKey: + core.APIKey = authToken + overlay.ExtraEnv = map[string]string{"ANTHROPIC_API_KEY": apiKey} + case hasAuth: + core.APIKey = authToken + case hasAPIKey: + core.APIKey = apiKey + } + if v, ok := stringMapValue(env, "CLAUDE_CODE_USE_BEDROCK"); ok { + if overlay.ExtraEnv == nil { + overlay.ExtraEnv = map[string]string{} + } + overlay.ExtraEnv["CLAUDE_CODE_USE_BEDROCK"] = v + } + if v, ok := stringMapValue(env, "CLAUDE_CODE_USE_VERTEX"); ok { + if overlay.ExtraEnv == nil { + overlay.ExtraEnv = map[string]string{} + } + overlay.ExtraEnv["CLAUDE_CODE_USE_VERTEX"] = v + } + return core, overlay, "" +} + +func lenientReadCodex(ctx context.Context, resv *storage.Resolver) (config.CoreConfig, config.ToolOverlay, string) { + if err := ctx.Err(); err != nil { + return config.CoreConfig{}, config.ToolOverlay{}, "skipped: " + err.Error() + } + var notes []string + core := config.CoreConfig{Provider: "openai-compat"} + var overlay config.ToolOverlay + + authPath := codexadapter.AuthPath(resv) + authRoot, authNote := lenientReadJSONMap(authPath, "auth.json", resv, codexadapter.VerifyReadTargetInHome) + if authNote != "" { + notes = append(notes, authNote) + } + if authRoot != nil { + if v, ok := stringMapValue(authRoot, "OPENAI_API_KEY"); ok { + core.APIKey = v + } + flat, err := writepath.Flatten(authRoot) + if err == nil { + for _, key := range codexadapter.OwnedKeysAuthJSON { + if key == "OPENAI_API_KEY" { + continue + } + if v, ok := flat[key]; ok && v != nil { + putOverlayRaw(&overlay, key, v) + } + } + } + } + + configPath := codexadapter.ConfigPath(resv) + doc, configNote := lenientReadCodexConfig(configPath, resv) + if configNote != "" { + notes = append(notes, configNote) + } + if doc != nil { + for _, key := range codexadapter.OwnedKeysConfigTOML { + v, ok := doc.Get(key) + if !ok { + continue + } + putOverlayRaw(&overlay, key, v) + } + } + + return core, overlay, strings.Join(notes, "; ") +} + +func lenientReadJSONMap(path, label string, resv *storage.Resolver, verify func(string, *storage.Resolver) error) (map[string]any, string) { + if verify != nil { + if err := verify(path, resv); err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, label + " not found" + } + return nil, label + " skipped: read target refused: " + err.Error() + } + } + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, label + " not found" + } + return nil, label + " skipped: " + err.Error() + } + if len(strings.TrimSpace(string(data))) == 0 { + return nil, label + " empty" + } + var root map[string]any + if err := json.Unmarshal(data, &root); err != nil { + return nil, label + " skipped: parse failed: " + err.Error() + } + if root == nil { + return nil, label + " skipped: top-level value is null" + } + return root, "" +} + +func lenientReadCodexConfig(path string, resv *storage.Resolver) (*codextoml.Doc, string) { + if err := codexadapter.VerifyReadTargetInHome(path, resv); err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, "config.toml not found" + } + return nil, "config.toml skipped: read target refused: " + err.Error() + } + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, "config.toml not found" + } + return nil, "config.toml skipped: " + err.Error() + } + if len(strings.TrimSpace(string(data))) == 0 { + return nil, "config.toml empty" + } + doc, err := codextoml.Load(data) + if err != nil { + doc = lenientExtractCodexConfig(data) + if doc == nil { + return nil, "config.toml skipped: parse failed: " + err.Error() + } + return doc, "config.toml partially read: parse failed: " + err.Error() + } + return doc, "" +} + +func lenientExtractCodexConfig(data []byte) *codextoml.Doc { + lines := strings.Split(string(data), "\n") + values := map[string]any{} + var section string + for _, line := range lines { + line = stripAddAutoTOMLComment(strings.TrimSpace(line)) + if line == "" { + continue + } + if strings.HasPrefix(line, "[") && strings.Contains(line, "]") { + section = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(line[:strings.Index(line, "]")+1], "["), "]")) + continue + } + if !strings.HasPrefix(section, "model_providers.") { + continue + } + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + key := strings.TrimSpace(parts[0]) + if key == "" { + continue + } + value, ok := parseAddAutoTOMLString(strings.TrimSpace(parts[1])) + if !ok { + continue + } + values[section+"."+key] = value + } + if len(values) == 0 { + return nil + } + bySection := map[string][]string{} + for _, key := range codexadapter.OwnedKeysConfigTOML { + if v, ok := values[key]; ok { + section, leaf, ok := splitAddAutoTOMLPath(key) + if !ok { + continue + } + bySection[section] = append(bySection[section], fmt.Sprintf("%s = %q", leaf, fmt.Sprint(v))) + } + } + var b strings.Builder + for _, key := range codexadapter.OwnedKeysConfigTOML { + section, _, ok := splitAddAutoTOMLPath(key) + if !ok || len(bySection[section]) == 0 { + continue + } + fmt.Fprintf(&b, "[%s]\n", section) + for _, line := range bySection[section] { + fmt.Fprintln(&b, line) + } + fmt.Fprintln(&b) + delete(bySection, section) + } + if b.Len() == 0 { + return nil + } + doc, err := codextoml.Load([]byte(b.String())) + if err != nil { + return nil + } + return doc +} + +func splitAddAutoTOMLPath(path string) (string, string, bool) { + idx := strings.LastIndex(path, ".") + if idx <= 0 || idx == len(path)-1 { + return "", "", false + } + return path[:idx], path[idx+1:], true +} + +func stripAddAutoTOMLComment(line string) string { + inSingle := false + inDouble := false + escaped := false + for idx, r := range line { + switch { + case escaped: + escaped = false + case r == '\\' && inDouble: + escaped = true + case r == '\'' && !inDouble: + inSingle = !inSingle + case r == '"' && !inSingle: + inDouble = !inDouble + case r == '#' && !inSingle && !inDouble: + return strings.TrimSpace(line[:idx]) + } + } + return line +} + +func parseAddAutoTOMLString(raw string) (string, bool) { + if len(raw) < 2 { + return "", false + } + if raw[0] == '"' { + var out string + if err := json.Unmarshal([]byte(raw), &out); err != nil { + return "", false + } + return out, true + } + if raw[0] == '\'' && raw[len(raw)-1] == '\'' { + return raw[1 : len(raw)-1], true + } + return "", false +} + +func stringMapValue(m map[string]any, key string) (string, bool) { + v, ok := m[key] + if !ok || v == nil { + return "", false + } + return strings.TrimSpace(fmt.Sprint(v)), true +} + +func putOverlayRaw(overlay *config.ToolOverlay, key string, value any) { + if overlay.Raw == nil { + overlay.Raw = map[string]any{} + } + overlay.Raw[key] = value +} + +func markExistingAddAutoCandidates(results []addAutoSourceResult, store addAutoProfileStore) error { if store == nil { return nil } @@ -217,24 +649,43 @@ func markExistingAddAutoCandidates(results []addAutoSourceResult, store *storage if err != nil { return fmt.Errorf("load existing profiles for auto dedup: %w", err) } - index := map[string]string{} + exactIndex := map[string]string{} + anyKeyIndex := map[string]string{} + emptyKeyIndex := map[string]string{} for _, profile := range profiles { if profile == nil || strings.TrimSpace(profile.Core.APIKey) == "" { continue } - key := addAutoDedupKey(profile.Core.BaseURL, profile.Core.APIKey) - if key == "" { + apiKey := strings.TrimSpace(profile.Core.APIKey) + base := normalizeAddAutoBaseURL(profile.Core.BaseURL) + if _, exists := anyKeyIndex[apiKey]; !exists { + anyKeyIndex[apiKey] = profile.Name + } + if base == "" { + if _, exists := emptyKeyIndex[apiKey]; !exists { + emptyKeyIndex[apiKey] = profile.Name + } continue } - if _, exists := index[key]; !exists { - index[key] = profile.Name + exactKey := base + "\x00" + apiKey + if _, exists := exactIndex[exactKey]; !exists { + exactIndex[exactKey] = profile.Name } } for resultIdx := range results { for candidateIdx := range results[resultIdx].Candidates { candidate := &results[resultIdx].Candidates[candidateIdx] - if name := index[addAutoDedupKey(candidate.Core.BaseURL, candidate.Core.APIKey)]; name != "" { + apiKey := strings.TrimSpace(candidate.Core.APIKey) + if apiKey == "" { + continue + } + base := normalizeAddAutoBaseURL(candidate.Core.BaseURL) + if base == "" { + candidate.AlreadyProfile = anyKeyIndex[apiKey] + } else if name := exactIndex[base+"\x00"+apiKey]; name != "" { candidate.AlreadyProfile = name + } else { + candidate.AlreadyProfile = emptyKeyIndex[apiKey] } } } @@ -242,22 +693,40 @@ func markExistingAddAutoCandidates(results []addAutoSourceResult, store *storage } func markDuplicateAddAutoCandidates(results []addAutoSourceResult) { - seen := map[string]string{} + exactSeen := map[string]string{} + anyKeySeen := map[string]string{} + emptyKeySeen := map[string]string{} for resultIdx := range results { for candidateIdx := range results[resultIdx].Candidates { candidate := &results[resultIdx].Candidates[candidateIdx] if candidate.AlreadyProfile != "" { continue } - key := addAutoDedupKey(candidate.Core.BaseURL, candidate.Core.APIKey) - if key == "" { + apiKey := strings.TrimSpace(candidate.Core.APIKey) + if apiKey == "" { continue } - if firstSource := seen[key]; firstSource != "" { + base := normalizeAddAutoBaseURL(candidate.Core.BaseURL) + if base == "" { + candidate.DuplicateOf = anyKeySeen[apiKey] + } else if firstSource := exactSeen[base+"\x00"+apiKey]; firstSource != "" { candidate.DuplicateOf = firstSource + } else { + candidate.DuplicateOf = emptyKeySeen[apiKey] + } + if candidate.DuplicateOf != "" { continue } - seen[key] = candidate.Source + if _, exists := anyKeySeen[apiKey]; !exists { + anyKeySeen[apiKey] = candidate.Source + } + if base == "" { + if _, exists := emptyKeySeen[apiKey]; !exists { + emptyKeySeen[apiKey] = candidate.Source + } + } else if _, exists := exactSeen[base+"\x00"+apiKey]; !exists { + exactSeen[base+"\x00"+apiKey] = candidate.Source + } } } } @@ -294,40 +763,11 @@ func renderAddAutoDiscovery(w io.Writer, format addOutputFormat, results []addAu } fmt.Fprintln(w) } - } - return nil -} - -func chooseAddAutoCandidate( - w io.Writer, - in *os.File, - format addOutputFormat, - candidates []addAutoCandidate, -) (addAutoCandidate, error) { - if !isTerminal(in) { - if format == addOutputText { - fmt.Fprintln(w, "multiple new credentials discovered:") - renderAddAutoCandidateTextList(w, candidates, true) - } else if err := renderAddAutoDisambiguationJSON(w, candidates); err != nil { - return addAutoCandidate{}, err + if strings.TrimSpace(result.Note) != "" { + fmt.Fprintf(w, " - %s: note: %s\n", result.Source, result.Note) } - return addAutoCandidate{}, fmt.Errorf("multiple new credentials discovered; rerun in an interactive terminal or use a specific add input source to disambiguate") } - - if format == addOutputText { - fmt.Fprintln(w, "multiple new credentials discovered; choose one:") - renderAddAutoCandidateTextList(w, candidates, true) - } - fmt.Fprintf(w, "Select credential [1-%d]: ", len(candidates)) - line, err := bufio.NewReader(in).ReadString('\n') - if err != nil && !errors.Is(err, io.EOF) { - return addAutoCandidate{}, fmt.Errorf("read credential selection: %w", err) - } - choice, err := strconv.Atoi(strings.TrimSpace(line)) - if err != nil || choice < 1 || choice > len(candidates) { - return addAutoCandidate{}, fmt.Errorf("invalid credential selection %q", strings.TrimSpace(line)) - } - return candidates[choice-1], nil + return nil } func countAddAutoCandidatesWithKey(results []addAutoSourceResult) int { @@ -357,61 +797,489 @@ func displayAddAutoValue(value string) string { return value } -func renderAddAutoCandidateTextList(w io.Writer, candidates []addAutoCandidate, numbered bool) { - for idx, candidate := range candidates { - if numbered { - fmt.Fprintf(w, " %d. ", idx+1) - } else { - fmt.Fprint(w, " - ") +type jsonAddAutoCandidate struct { + Source string `json:"source"` + Sources []string `json:"sources,omitempty"` + ProfileName string `json:"profile_name,omitempty"` + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` + Model string `json:"model,omitempty"` + Status string `json:"status"` + Reason string `json:"reason,omitempty"` +} + +type jsonAddAutoDiscoverySource struct { + Source string `json:"source"` + Note string `json:"note,omitempty"` + Candidates []jsonAddAutoCandidate `json:"candidates,omitempty"` +} + +func addAutoCandidateStatus(candidate addAutoCandidate) string { + if candidate.AlreadyProfile != "" { + return "already recorded as " + candidate.AlreadyProfile + } + if candidate.DuplicateOf != "" { + return "duplicate of " + candidate.DuplicateOf + } + return "NEW" +} + +func newAddAutoCandidates(results []addAutoSourceResult) ([]addAutoCandidate, error) { + exactByKey := map[string]int{} + anyByAPIKey := map[string]int{} + emptyByAPIKey := map[string]int{} + out := []addAutoCandidate{} + for _, result := range results { + for _, candidate := range result.Candidates { + if candidate.AlreadyProfile != "" || candidate.DuplicateOf != "" { + continue + } + apiKey := strings.TrimSpace(candidate.Core.APIKey) + if apiKey == "" { + continue + } + base := normalizeAddAutoBaseURL(candidate.Core.BaseURL) + mergeIdx := -1 + if base == "" { + if idx, ok := anyByAPIKey[apiKey]; ok { + mergeIdx = idx + } + } else if idx, ok := exactByKey[base+"\x00"+apiKey]; ok { + mergeIdx = idx + } else if idx, ok := emptyByAPIKey[apiKey]; ok { + mergeIdx = idx + } + if mergeIdx >= 0 { + out[mergeIdx] = mergeAddAutoCandidate(out[mergeIdx], candidate) + indexAddAutoCandidate(out[mergeIdx], mergeIdx, exactByKey, anyByAPIKey, emptyByAPIKey) + continue + } + candidate.Core = normalizeParsedProvider(candidate.Core) + candidate.Sources = normalizeAddAutoSources(candidate.Sources, candidate.Source) + if candidate.NameBase == "" { + candidate.NameBase = addAutoBaseNameFor(candidate.Source, candidate.Core.BaseURL) + } + indexAddAutoCandidate(candidate, len(out), exactByKey, anyByAPIKey, emptyByAPIKey) + out = append(out, candidate) } - fmt.Fprintf(w, "%s base_url=%s api_key=%s", - candidate.Source, - displayAddAutoValue(candidate.Core.BaseURL), - redactedValueDisplay("api_key", candidate.Core.APIKey), - ) - if strings.TrimSpace(candidate.Core.Model) != "" { - fmt.Fprintf(w, " model=%s", candidate.Core.Model) + } + return out, nil +} + +func indexAddAutoCandidate(candidate addAutoCandidate, idx int, exactByKey, anyByAPIKey, emptyByAPIKey map[string]int) { + apiKey := strings.TrimSpace(candidate.Core.APIKey) + if apiKey == "" { + return + } + base := normalizeAddAutoBaseURL(candidate.Core.BaseURL) + if _, exists := anyByAPIKey[apiKey]; !exists { + anyByAPIKey[apiKey] = idx + } + if base == "" { + if _, exists := emptyByAPIKey[apiKey]; !exists { + emptyByAPIKey[apiKey] = idx } - fmt.Fprintln(w) + return + } + if _, exists := exactByKey[base+"\x00"+apiKey]; !exists { + exactByKey[base+"\x00"+apiKey] = idx } } -type jsonAddAutoDisambiguation struct { - Action string `json:"action"` - Candidates []jsonAddAutoCandidate `json:"candidates"` +func mergeAddAutoCandidate(base, next addAutoCandidate) addAutoCandidate { + if strings.TrimSpace(base.Core.BaseURL) == "" { + base.Core.BaseURL = next.Core.BaseURL + } + if strings.TrimSpace(base.Core.Provider) == "" { + base.Core.Provider = next.Core.Provider + } + if strings.TrimSpace(base.Core.Model) == "" { + base.Core.Model = next.Core.Model + } + if strings.TrimSpace(base.Core.SmallFastModel) == "" { + base.Core.SmallFastModel = next.Core.SmallFastModel + } + base.Tools = mergeToolMaps(base.Tools, next.Tools) + base.Sources = appendUniqueStrings(base.Sources, normalizeAddAutoSources(next.Sources, next.Source)...) + if preferAddAutoNameBase(next.NameBase, base.NameBase) { + base.NameBase = next.NameBase + } + return base } -type jsonAddAutoCandidate struct { - Source string `json:"source"` - BaseURL string `json:"base_url"` - APIKey string `json:"api_key"` - Status string `json:"status"` +func normalizeAddAutoSources(sources []string, fallback string) []string { + if len(sources) == 0 && fallback != "" { + sources = []string{fallback} + } + out := make([]string, 0, len(sources)) + for _, source := range sources { + source = strings.TrimSpace(source) + if source != "" { + out = appendUniqueStrings(out, source) + } + } + return out +} + +func appendUniqueStrings(base []string, values ...string) []string { + seen := map[string]struct{}{} + for _, v := range base { + seen[v] = struct{}{} + } + for _, v := range values { + if _, ok := seen[v]; ok { + continue + } + base = append(base, v) + seen[v] = struct{}{} + } + return base +} + +func preferAddAutoNameBase(candidate, current string) bool { + if candidate == "" { + return false + } + if current == "" { + return true + } + return addAutoNamePriority(candidate) < addAutoNamePriority(current) +} + +func addAutoNamePriority(name string) int { + switch name { + case "claude-code", "codex": + return 0 + case "env", "clipboard": + return 2 + default: + return 1 + } +} + +func loadAddAutoProfileNames(store addAutoProfileStore) (map[string]struct{}, error) { + names := map[string]struct{}{} + if store == nil { + return names, nil + } + profiles, err := store.LoadAllProfiles() + if err != nil { + return nil, fmt.Errorf("load existing profile names for auto naming: %w", err) + } + for _, profile := range profiles { + if profile == nil { + continue + } + names[profile.Name] = struct{}{} + } + return names, nil +} + +func assignAddAutoProfileNames(candidates []addAutoCandidate, used map[string]struct{}) { + if used == nil { + used = map[string]struct{}{} + } + for idx := range candidates { + base := sanitizeAddAutoProfileName(candidates[idx].NameBase) + if base == "" { + base = "auto" + } + name := base + for suffix := 2; ; suffix++ { + if _, exists := used[name]; !exists && storage.ValidateProfileName(name) == nil { + break + } + name = addAutoNameWithSuffix(base, suffix) + } + candidates[idx].ProfileName = name + used[name] = struct{}{} + } +} + +func promptAddAutoProfileNames(w io.Writer, in io.Reader, candidates []addAutoCandidate, used map[string]struct{}) error { + if used == nil { + used = map[string]struct{}{} + } + for idx := range candidates { + defaultName := candidates[idx].ProfileName + for { + fmt.Fprintf(w, "Save profile for %s, %s as [%s]: ", + candidates[idx].Source, + redactedValueDisplay("api_key", candidates[idx].Core.APIKey), + defaultName, + ) + line, err := readLine(in) + if err != nil { + return fmt.Errorf("read profile name: %w", err) + } + name := strings.TrimSpace(line) + if name == "" { + name = defaultName + } + if err := storage.ValidateProfileName(name); err != nil { + fmt.Fprintf(w, "Invalid profile name %q: %v\n", name, err) + continue + } + if _, exists := used[name]; exists && name != candidates[idx].ProfileName { + fmt.Fprintf(w, "Profile name %q already exists; choose another name.\n", name) + continue + } + candidates[idx].ProfileName = name + used[name] = struct{}{} + break + } + } + return nil +} + +func addAutoNameWithSuffix(base string, suffix int) string { + s := fmt.Sprintf("-%d", suffix) + if len(base)+len(s) <= storage.MaxProfileNameLen { + return base + s + } + trimmed := strings.TrimRight(base[:storage.MaxProfileNameLen-len(s)], ".-_") + if trimmed == "" { + trimmed = "auto" + } + return trimmed + s } -func renderAddAutoDisambiguationJSON(w io.Writer, candidates []addAutoCandidate) error { - out := jsonAddAutoDisambiguation{ - Action: "auto-disambiguation-required", - Candidates: make([]jsonAddAutoCandidate, 0, len(candidates)), +func sanitizeAddAutoProfileName(raw string) string { + raw = strings.ToLower(strings.TrimSpace(raw)) + var b strings.Builder + lastDash := false + for _, r := range raw { + valid := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') + if valid { + b.WriteRune(r) + lastDash = false + continue + } + if r == '.' || r == '_' || r == '-' || unicode.IsSpace(r) { + if !lastDash { + b.WriteByte('-') + lastDash = true + } + continue + } + if !lastDash { + b.WriteByte('-') + lastDash = true + } + } + out := strings.Trim(b.String(), "-._") + out = strings.TrimLeft(out, ".-_") + if out == "" { + return "" + } + if !((out[0] >= 'a' && out[0] <= 'z') || (out[0] >= '0' && out[0] <= '9')) { + out = "auto-" + out + } + if len(out) > storage.MaxProfileNameLen { + out = strings.TrimRight(out[:storage.MaxProfileNameLen], "-._") + } + if storage.ValidateProfileName(out) != nil { + return "" } + return out +} + +func addAutoBaseNameFor(source, baseURL string) string { + switch source { + case string(addAutoSourceClaudeCode): + return "claude-code" + case string(addAutoSourceCodex): + return "codex" + case string(addAutoSourceEnv): + if host := addAutoHostSlug(baseURL); host != "" { + return host + } + return "env" + case string(addAutoSourceClipboard): + if host := addAutoHostSlug(baseURL); host != "" { + return host + } + return "clipboard" + default: + if host := addAutoHostSlug(baseURL); host != "" { + return host + } + return source + } +} + +func addAutoHostSlug(raw string) string { + value := strings.TrimSpace(raw) + if value == "" { + return "" + } + u, err := url.Parse(value) + if err != nil || u.Hostname() == "" { + if !strings.Contains(value, "://") { + u, err = url.Parse("https://" + value) + } + if err != nil || u.Hostname() == "" { + return "" + } + } + host := strings.TrimPrefix(strings.ToLower(u.Hostname()), "www.") + return sanitizeAddAutoProfileName(host) +} + +func buildAddAutoProfiles(candidates []addAutoCandidate, now time.Time) []*config.Profile { + profiles := make([]*config.Profile, 0, len(candidates)) for _, candidate := range candidates { - out.Candidates = append(out.Candidates, jsonAddAutoCandidate{ - Source: candidate.Source, - BaseURL: candidate.Core.BaseURL, - APIKey: redactedValueDisplay("api_key", candidate.Core.APIKey), - Status: addAutoCandidateStatus(candidate), + profiles = append(profiles, &config.Profile{ + SchemaVersion: config.CurrentProfileSchemaVersion, + Name: candidate.ProfileName, + Description: addDescriptionFlag, + CreatedAt: now, + UpdatedAt: now, + Core: candidate.Core, + Tools: cloneToolMap(candidate.Tools), }) } + return profiles +} + +func renderAddAutoDryRun(w io.Writer, format addOutputFormat, results []addAutoSourceResult, profiles []*config.Profile) error { + if format == addOutputJSON { + return renderAddAutoResultJSON(w, "dry-run", results, profiles, nil) + } + fmt.Fprintln(w, "--- dry-run: auto profiles (not written) ---") + for _, profile := range profiles { + if err := renderAddDryRun(w, addOutputText, profile); err != nil { + return err + } + } + return nil +} + +func renderAddAutoCreated(w io.Writer, format addOutputFormat, results []addAutoSourceResult, profiles []*config.Profile) error { + if format == addOutputJSON { + return renderAddAutoResultJSON(w, "created", results, profiles, nil) + } + fmt.Fprintln(w, "created profiles:") + for _, profile := range profiles { + fmt.Fprintf(w, " - %s\n", profile.Name) + } + return nil +} + +func renderAddAutoProfileTextList(w io.Writer, profiles []*config.Profile) { + for _, profile := range profiles { + fmt.Fprintf(w, " - %s base_url=%s api_key=%s", + profile.Name, + displayAddAutoValue(profile.Core.BaseURL), + redactedValueDisplay("api_key", profile.Core.APIKey), + ) + if strings.TrimSpace(profile.Core.Model) != "" { + fmt.Fprintf(w, " model=%s", profile.Core.Model) + } + fmt.Fprintln(w) + } +} + +type jsonAddAutoProfiles struct { + Action string `json:"action"` + Discovery []jsonAddAutoDiscoverySource `json:"discovery,omitempty"` + Created []string `json:"created,omitempty"` + Skipped []jsonAddAutoCandidate `json:"skipped,omitempty"` + Profiles []jsonAddProfile `json:"profiles"` +} + +func renderAddAutoProfilesJSON(w io.Writer, action string, profiles []*config.Profile) error { + out := jsonAddAutoProfiles{ + Action: action, + Profiles: make([]jsonAddProfile, 0, len(profiles)), + } + for _, profile := range profiles { + out.Profiles = append(out.Profiles, profileToJSON(redactProfileForAddOutput(profile))) + } return writeAddJSON(w, out) } -func addAutoCandidateStatus(candidate addAutoCandidate) string { +func renderAddAutoResultJSON(w io.Writer, action string, results []addAutoSourceResult, profiles []*config.Profile, skipped []jsonAddAutoCandidate) error { + out := jsonAddAutoProfiles{ + Action: action, + Discovery: jsonAddAutoDiscovery(results), + Created: profileNames(profiles), + Skipped: skipped, + Profiles: make([]jsonAddProfile, 0, len(profiles)), + } + for _, profile := range profiles { + out.Profiles = append(out.Profiles, profileToJSON(redactProfileForAddOutput(profile))) + } + out.Skipped = append(out.Skipped, jsonAddAutoSkipped(results)...) + return writeAddJSON(w, out) +} + +func jsonAddAutoDiscovery(results []addAutoSourceResult) []jsonAddAutoDiscoverySource { + out := make([]jsonAddAutoDiscoverySource, 0, len(results)) + for _, result := range results { + source := jsonAddAutoDiscoverySource{ + Source: result.Source, + Note: result.Note, + } + if len(result.Candidates) == 0 && source.Note == "" { + source.Note = "no API key found" + } + for _, candidate := range result.Candidates { + source.Candidates = append(source.Candidates, jsonAddAutoCandidateFromCandidate(candidate)) + } + out = append(out, source) + } + return out +} + +func jsonAddAutoSkipped(results []addAutoSourceResult) []jsonAddAutoCandidate { + var out []jsonAddAutoCandidate + for _, result := range results { + for _, candidate := range result.Candidates { + if candidate.AlreadyProfile == "" && candidate.DuplicateOf == "" { + continue + } + out = append(out, jsonAddAutoCandidateFromCandidate(candidate)) + } + } + return out +} + +func jsonAddAutoCandidateFromCandidate(candidate addAutoCandidate) jsonAddAutoCandidate { + status := addAutoCandidateStatus(candidate) + out := jsonAddAutoCandidate{ + Source: candidate.Source, + Sources: normalizeAddAutoSources(candidate.Sources, candidate.Source), + ProfileName: candidate.ProfileName, + BaseURL: candidate.Core.BaseURL, + APIKey: redactedValueDisplay("api_key", candidate.Core.APIKey), + Model: candidate.Core.Model, + Status: status, + } if candidate.AlreadyProfile != "" { - return "already recorded as " + candidate.AlreadyProfile + out.Reason = "already-recorded" + } else if candidate.DuplicateOf != "" { + out.Reason = "duplicate" } - if candidate.DuplicateOf != "" { - return "duplicate of " + candidate.DuplicateOf + return out +} + +func profileNames(profiles []*config.Profile) []string { + names := make([]string, 0, len(profiles)) + for _, profile := range profiles { + if profile != nil { + names = append(names, profile.Name) + } } - return "NEW" + return names +} + +func addAutoPartialCreateError(created []*config.Profile, failedName string, saveErr error) error { + names := profileNames(created) + if len(names) == 0 { + return fmt.Errorf("失败于:%s: %w", failedName, saveErr) + } + return fmt.Errorf("已创建:%s; 失败于:%s: %w", strings.Join(names, ", "), failedName, saveErr) } func addAutoDedupKey(baseURL, apiKey string) string { diff --git a/cmd/add_auto_test.go b/cmd/add_auto_test.go index 9b1fe09..641a180 100644 --- a/cmd/add_auto_test.go +++ b/cmd/add_auto_test.go @@ -4,57 +4,83 @@ package cmd import ( "encoding/json" + "errors" "os" "path/filepath" "strings" "testing" + "github.com/spf13/cobra" + "github.com/a2d2-dev/claudecm/internal/config" "github.com/a2d2-dev/claudecm/internal/envextract" + "github.com/a2d2-dev/claudecm/internal/storage" ) -func TestAddAuto_EnvNewCandidateDryRun(t *testing.T) { - newAddHarness(t) +func TestAddAuto_CodexAuthJSONSurvivesProjectsConfig(t *testing.T) { + h := newAddHarness(t) addAutoFlag = true addDryRunFlag = true - restoreClipboard := setAddAutoClipboardForTest(func() (string, bool, error) { - return "", false, os.ErrNotExist - }) - defer restoreClipboard() + stubAddAutoEmptySources(t) + writeAddAutoCodexConfig(t, h.home, `model = "gpt-5" +model_provider = "openai" + +[model_providers.openai] +base_url = "https://api.openai.com/v1" + +[projects."/data/src/github.com/a2d2-dev/claudecm"] +trust_level = "trusted" +`) + writeAddAutoCodexAuth(t, h.home, `{"OPENAI_API_KEY":"sk-codex-projects-1234","auth_mode":"api_key"}`) + + stdout, _, err := runAddInner(t) + if err != nil { + t.Fatalf("runAdd --auto codex projects: %v\nstdout=%s", err, stdout) + } + if !strings.Contains(stdout, "codex: NEW") { + t.Fatalf("stdout missing codex NEW marker:\n%s", stdout) + } + if !strings.Contains(stdout, "name: codex") { + t.Fatalf("dry-run did not derive codex profile name:\n%s", stdout) + } + if strings.Contains(stdout, "sk-codex-projects-1234") { + t.Fatalf("stdout leaked codex key:\n%s", stdout) + } +} + +func TestAddAuto_EnvAndClaudeSameKeyCollapseToOneProfile(t *testing.T) { + h := newAddHarness(t) + addAutoFlag = true + addYesFlag = true + stubAddAutoEmptySources(t) restoreEnv := envextract.SetLookupForTest(addEnvUniverse(map[string]string{ "ANTHROPIC_BASE_URL": "https://api.anthropic.com", - "ANTHROPIC_AUTH_TOKEN": "sk-env-auto-123456", - "ANTHROPIC_MODEL": "claude-opus-4-5", + "ANTHROPIC_AUTH_TOKEN": "sk-same-claude-env-1234", })) defer restoreEnv() + writeAddAutoClaudeSettings(t, h.home, `{"env":{"ANTHROPIC_BASE_URL":"https://api.anthropic.com/","ANTHROPIC_AUTH_TOKEN":"sk-same-claude-env-1234"}}`) + restoreTTY := SetIsTerminalForTest(func(*os.File) bool { return false }) + defer restoreTTY() - stdout, _, err := runAddInner(t, "autoenv") + stdout, _, err := runAddInner(t) if err != nil { - t.Fatalf("runAdd --auto: %v\nstdout=%s", err, stdout) - } - if !strings.Contains(stdout, "environment: NEW") { - t.Fatalf("stdout missing environment NEW marker:\n%s", stdout) - } - if !strings.Contains(stdout, "--- dry-run: profile YAML (not written) ---") { - t.Fatalf("stdout missing dry-run profile:\n%s", stdout) + t.Fatalf("runAdd --auto same env+claude: %v\nstdout=%s", err, stdout) } - if strings.Contains(stdout, "sk-env-auto-123456") { - t.Fatalf("stdout leaked plaintext key:\n%s", stdout) - } - if !strings.Contains(stdout, "sk-e***3456") { - t.Fatalf("stdout missing redacted key:\n%s", stdout) + if !strings.Contains(stdout, "claude-code: duplicate of environment") { + t.Fatalf("stdout missing duplicate marker:\n%s", stdout) } + assertProfileNames(t, h, []string{"api-anthropic-com"}) } -func TestAddAuto_CodexAuthJSONCandidate(t *testing.T) { +func TestAddAuto_EnvAnthropicAndCodexBothNewCreateTwoProfiles(t *testing.T) { h := newAddHarness(t) addAutoFlag = true - addDryRunFlag = true - restoreClipboard := setAddAutoClipboardForTest(func() (string, bool, error) { - return "", false, os.ErrNotExist - }) - defer restoreClipboard() - restoreEnv := envextract.SetLookupForTest(addEnvUniverse(map[string]string{})) + addYesFlag = true + stubAddAutoEmptySources(t) + restoreEnv := envextract.SetLookupForTest(addEnvUniverse(map[string]string{ + "ANTHROPIC_BASE_URL": "https://bray-neov.im/v1", + "ANTHROPIC_AUTH_TOKEN": "sk-env-anthropic-1234", + })) defer restoreEnv() writeAddAutoCodexConfig(t, h.home, `model = "gpt-5" model_provider = "openai" @@ -62,71 +88,156 @@ model_provider = "openai" [model_providers.openai] base_url = "https://api.openai.com/v1" `) - writeAddAutoCodexAuth(t, h.home, `{"OPENAI_API_KEY":"sk-codex-auto-1234","auth_mode":"api_key"}`) + writeAddAutoCodexAuth(t, h.home, `{"OPENAI_API_KEY":"sk-codex-two-1234"}`) + restoreTTY := SetIsTerminalForTest(func(*os.File) bool { return false }) + defer restoreTTY() - stdout, _, err := runAddInner(t, "autocodex") + stdout, _, err := runAddInner(t) if err != nil { - t.Fatalf("runAdd --auto codex: %v\nstdout=%s", err, stdout) - } - if !strings.Contains(stdout, "~/.codex/auth.json + config.toml: NEW") { - t.Fatalf("stdout missing codex NEW marker:\n%s", stdout) + t.Fatalf("runAdd --auto two profiles: %v\nstdout=%s", err, stdout) } - if strings.Contains(stdout, "sk-codex-auto-1234") { - t.Fatalf("stdout leaked codex key:\n%s", stdout) + created := addAutoCreatedSection(stdout) + for _, want := range []string{"bray-neov-im", "codex"} { + if !strings.Contains(created, want) { + t.Fatalf("created section missing %q:\nsection=%s\nstdout=%s", want, created, stdout) + } } + assertProfileNames(t, h, []string{"bray-neov-im", "codex"}) } -func TestAddAuto_DedupAlreadyRecorded(t *testing.T) { +func TestAddAuto_PartialSaveFailureReportsCreatedAndFailed(t *testing.T) { h := newAddHarness(t) addAutoFlag = true - restoreClipboard := setAddAutoClipboardForTest(func() (string, bool, error) { - return "", false, os.ErrNotExist - }) - defer restoreClipboard() + addYesFlag = true + stubAddAutoEmptySources(t) restoreEnv := envextract.SetLookupForTest(addEnvUniverse(map[string]string{ - "ANTHROPIC_BASE_URL": "https://API.Anthropic.com/", - "ANTHROPIC_AUTH_TOKEN": "sk-existing-auto-1234", + "ANTHROPIC_BASE_URL": "https://first.example/v1", + "ANTHROPIC_AUTH_TOKEN": "sk-first-partial-1234", })) defer restoreEnv() + writeAddAutoCodexAuth(t, h.home, `{"OPENAI_API_KEY":"sk-second-partial-1234"}`) + + var out strings.Builder + cmd := &cobra.Command{Use: "add"} + cmd.SetOut(&out) + err := runAddAuto(cmd, h.resv, failingAddAutoStore{FileStorage: h.store, failName: "codex"}, addOutputText) + if err == nil { + t.Fatalf("runAddAuto partial failure unexpectedly succeeded\nstdout=%s", out.String()) + } + msg := err.Error() + for _, want := range []string{"已创建:first-example", "失败于:codex", "injected save failure"} { + if !strings.Contains(msg, want) { + t.Fatalf("partial failure error %q missing %q", msg, want) + } + } + assertProfileNames(t, h, []string{"first-example"}) +} + +func TestAddAuto_CodexEmptyBaseURLDedupsAgainstExistingKey(t *testing.T) { + h := newAddHarness(t) + addAutoFlag = true + addOutputFlag = "json" + stubAddAutoEmptySources(t) if err := h.store.SaveProfile(&config.Profile{ SchemaVersion: config.CurrentProfileSchemaVersion, - Name: "existing", + Name: "existing-codex", Core: config.CoreConfig{ - Provider: "anthropic", - BaseURL: "https://api.anthropic.com", - APIKey: "sk-existing-auto-1234", + Provider: "openai-compat", + BaseURL: "https://api.openai.com/v1", + APIKey: "sk-empty-base-dedup-1234", }, }); err != nil { - t.Fatalf("SaveProfile existing: %v", err) + t.Fatalf("SaveProfile existing-codex: %v", err) } + writeAddAutoCodexAuth(t, h.home, `{"OPENAI_API_KEY":"sk-empty-base-dedup-1234"}`) - stdout, _, err := runAddInner(t, "dup") + stdout, _, err := runAddInner(t) if err != nil { - t.Fatalf("runAdd --auto dedup should exit 0: %v\nstdout=%s", err, stdout) + t.Fatalf("runAdd --auto empty base dedup: %v\nstdout=%s", err, stdout) } - if !strings.Contains(stdout, "already recorded as existing") { - t.Fatalf("stdout missing already-recorded marker:\n%s", stdout) + var out jsonAddAutoProfiles + if err := json.Unmarshal([]byte(stdout), &out); err != nil { + t.Fatalf("json.Unmarshal: %v\n%s", err, stdout) } - if !strings.Contains(stdout, "all discovered credentials are already recorded") { - t.Fatalf("stdout missing all-recorded message:\n%s", stdout) + if out.Action != "already-recorded" || len(out.Created) != 0 { + t.Fatalf("unexpected json action/created: %+v", out) } - if exists, err := h.store.ProfileExists("dup"); err != nil { - t.Fatalf("ProfileExists dup: %v", err) - } else if exists { - t.Fatalf("duplicate auto path wrote profile dup") + if len(out.Skipped) != 1 || out.Skipped[0].Reason != "already-recorded" || !strings.Contains(out.Skipped[0].Status, "existing-codex") { + t.Fatalf("skipped = %+v, want already-recorded existing-codex", out.Skipped) + } + if strings.Contains(stdout, "sk-empty-base-dedup-1234") { + t.Fatalf("json leaked key:\n%s", stdout) } + assertProfileNames(t, h, []string{"existing-codex"}) } -func TestAddAuto_DedupAlreadyRecordedDefaultHTTPSPort(t *testing.T) { +func TestAddAuto_CodexMalformedConfigStillExtractsBaseURL(t *testing.T) { h := newAddHarness(t) addAutoFlag = true - restoreClipboard := setAddAutoClipboardForTest(func() (string, bool, error) { - return "", false, os.ErrNotExist - }) - defer restoreClipboard() + addDryRunFlag = true + stubAddAutoEmptySources(t) + writeAddAutoCodexConfig(t, h.home, `bad = [ + +[projects."/data/src/github.com/a2d2-dev/claudecm"] +trust_level = "trusted" + +[model_providers.openai] +base_url = "https://compat.example/v1" +`) + writeAddAutoCodexAuth(t, h.home, `{"OPENAI_API_KEY":"sk-codex-lenient-base-1234"}`) + + stdout, _, err := runAddInner(t) + if err != nil { + t.Fatalf("runAdd --auto lenient base_url: %v\nstdout=%s", err, stdout) + } + if !strings.Contains(stdout, "base_url=https://compat.example/v1") || !strings.Contains(stdout, "base_url: https://compat.example/v1") { + t.Fatalf("stdout missing leniently extracted base_url:\n%s", stdout) + } +} + +func TestAddAuto_LenientReadersRejectOutsideHomeSymlinks(t *testing.T) { + h := newAddHarness(t) + addAutoFlag = true + stubAddAutoEmptySources(t) + outside := t.TempDir() + if err := os.WriteFile(filepath.Join(outside, "auth.json"), []byte(`{"OPENAI_API_KEY":"sk-outside-codex-1234"}`), 0o600); err != nil { + t.Fatalf("write outside auth: %v", err) + } + if err := os.WriteFile(filepath.Join(outside, "settings.json"), []byte(`{"env":{"ANTHROPIC_AUTH_TOKEN":"sk-outside-claude-1234"}}`), 0o600); err != nil { + t.Fatalf("write outside settings: %v", err) + } + if err := os.MkdirAll(filepath.Join(h.home, ".codex"), 0o700); err != nil { + t.Fatalf("mkdir .codex: %v", err) + } + if err := os.MkdirAll(filepath.Join(h.home, ".claude"), 0o700); err != nil { + t.Fatalf("mkdir .claude: %v", err) + } + if err := os.Symlink(filepath.Join(outside, "auth.json"), filepath.Join(h.home, ".codex", "auth.json")); err != nil { + t.Fatalf("symlink auth: %v", err) + } + if err := os.Symlink(filepath.Join(outside, "settings.json"), filepath.Join(h.home, ".claude", "settings.json")); err != nil { + t.Fatalf("symlink settings: %v", err) + } + + stdout, _, err := runAddInner(t) + if err == nil { + t.Fatalf("runAdd --auto outside symlinks unexpectedly succeeded\nstdout=%s", stdout) + } + if !strings.Contains(stdout, "auth.json skipped: read target refused") || !strings.Contains(stdout, "settings.json refused") { + t.Fatalf("stdout missing symlink refusal notes:\n%s", stdout) + } + if strings.Contains(stdout, "sk-outside") || strings.Contains(err.Error(), "sk-outside") { + t.Fatalf("output leaked outside-home key:\nstdout=%s\nerr=%v", stdout, err) + } +} + +func TestAddAuto_AlreadyRecordedSkippedAndNotRecreated(t *testing.T) { + h := newAddHarness(t) + addAutoFlag = true + stubAddAutoNoClipboard(t) restoreEnv := envextract.SetLookupForTest(addEnvUniverse(map[string]string{ - "ANTHROPIC_BASE_URL": "https://h:443/v1", - "ANTHROPIC_AUTH_TOKEN": "sk-same-default-port", + "ANTHROPIC_BASE_URL": "https://API.Anthropic.com/", + "ANTHROPIC_AUTH_TOKEN": "sk-existing-auto-1234", })) defer restoreEnv() if err := h.store.SaveProfile(&config.Profile{ @@ -134,322 +245,333 @@ func TestAddAuto_DedupAlreadyRecordedDefaultHTTPSPort(t *testing.T) { Name: "existing", Core: config.CoreConfig{ Provider: "anthropic", - BaseURL: "https://h/v1", - APIKey: "sk-same-default-port", + BaseURL: "https://api.anthropic.com", + APIKey: "sk-existing-auto-1234", }, }); err != nil { t.Fatalf("SaveProfile existing: %v", err) } - stdout, _, err := runAddInner(t, "dupport") + stdout, _, err := runAddInner(t) if err != nil { - t.Fatalf("runAdd --auto default-port dedup should exit 0: %v\nstdout=%s", err, stdout) + t.Fatalf("runAdd --auto all recorded should exit 0: %v\nstdout=%s", err, stdout) } if !strings.Contains(stdout, "already recorded as existing") { t.Fatalf("stdout missing already-recorded marker:\n%s", stdout) } - if exists, err := h.store.ProfileExists("dupport"); err != nil { - t.Fatalf("ProfileExists dupport: %v", err) - } else if exists { - t.Fatalf("default-port duplicate auto path wrote profile dupport") + if !strings.Contains(stdout, "all discovered credentials are already recorded") { + t.Fatalf("stdout missing all-recorded message:\n%s", stdout) } + assertProfileNames(t, h, []string{"existing"}) } -func TestAddAuto_NoKeyRefusesWithSweptSources(t *testing.T) { +func TestAddAuto_NameArgumentRefused(t *testing.T) { newAddHarness(t) addAutoFlag = true - restoreClipboard := setAddAutoClipboardForTest(func() (string, bool, error) { - return "", false, os.ErrNotExist - }) - defer restoreClipboard() - restoreEnv := envextract.SetLookupForTest(addEnvUniverse(map[string]string{})) - defer restoreEnv() - stdout, _, err := runAddInner(t, "nokey") + stdout, _, err := runAddInner(t, "foo") + if err == nil { + t.Fatalf("runAdd add foo --auto unexpectedly succeeded\nstdout=%s", stdout) + } + if !strings.Contains(err.Error(), "--auto does not take a profile name") || !strings.Contains(err.Error(), "names are derived") { + t.Fatalf("error missing clear nameless-auto text: %v", err) + } +} + +func TestAddAuto_NoCredentialsRefusesWithSweptSources(t *testing.T) { + newAddHarness(t) + addAutoFlag = true + stubAddAutoEmptySources(t) + + stdout, _, err := runAddInner(t) if err == nil { t.Fatalf("runAdd --auto no key unexpectedly succeeded\nstdout=%s", stdout) } msg := err.Error() - for _, want := range []string{"clipboard", "environment", "~/.claude/settings.json", "~/.codex/auth.json + config.toml"} { + for _, want := range []string{"clipboard", "environment", "claude-code", "codex"} { if !strings.Contains(msg, want) { t.Fatalf("error %q missing swept source %q", msg, want) } } } -func TestAddAuto_ExplicitEmptyAPIKeyRefuses(t *testing.T) { +func TestAddAuto_NonTTYWithoutYesRefusesWithPreview(t *testing.T) { h := newAddHarness(t) addAutoFlag = true - addAPIKeyFlagExplicit = true + stubAddAutoNoClipboard(t) + restoreEnv := envextract.SetLookupForTest(addEnvUniverse(map[string]string{ + "ANTHROPIC_BASE_URL": "https://api.anthropic.com", + "ANTHROPIC_AUTH_TOKEN": "sk-nontty-auto-1234", + })) + defer restoreEnv() + restoreTTY := SetIsTerminalForTest(func(*os.File) bool { return false }) + defer restoreTTY() - stdout, _, err := runAddInner(t, "emptykey") + stdout, _, err := runAddInner(t) if err == nil { - t.Fatalf("runAdd --auto --api-key= unexpectedly succeeded\nstdout=%s", stdout) + t.Fatalf("runAdd --auto non-tty without --yes unexpectedly succeeded\nstdout=%s", stdout) } - if !strings.Contains(err.Error(), "choose only one add identity source") { - t.Fatalf("error missing identity mutual exclusion: %v", err) + if !strings.Contains(err.Error(), "pass --yes") { + t.Fatalf("error missing --yes guidance: %v", err) } - if !strings.Contains(err.Error(), "--auto") || !strings.Contains(err.Error(), "--api-key") { - t.Fatalf("error missing conflicting flags: %v", err) + if !strings.Contains(stdout, "profiles to create:") || !strings.Contains(stdout, "api-anthropic-com") { + t.Fatalf("stdout missing preview list:\n%s", stdout) } - if exists, err := h.store.ProfileExists("emptykey"); err != nil { - t.Fatalf("ProfileExists emptykey: %v", err) + if exists, err := h.store.ProfileExists("api-anthropic-com"); err != nil { + t.Fatalf("ProfileExists: %v", err) } else if exists { - t.Fatalf("explicit empty --api-key wrote keyless profile") + t.Fatalf("non-tty refusal wrote profile") } } -func TestAddAuto_IdentityOverridesRefused(t *testing.T) { - for _, tc := range []struct { - name string - baseURL string - apiKey string - wantFlag string - flagIsBase bool - }{ - {name: "apikey", apiKey: "sk-existing-identity", wantFlag: "--api-key"}, - {name: "baseurl", baseURL: "http://x", wantFlag: "--base-url", flagIsBase: true}, - } { - t.Run(tc.name, func(t *testing.T) { - h := newAddHarness(t) - addAutoFlag = true - if tc.flagIsBase { - addBaseURLFlag = tc.baseURL - } else { - addAPIKeyFlag = tc.apiKey - } - - stdout, _, err := runAddInner(t, "identity") - if err == nil { - t.Fatalf("runAdd --auto %s unexpectedly succeeded\nstdout=%s", tc.wantFlag, stdout) - } - if !strings.Contains(err.Error(), "choose only one add identity source") { - t.Fatalf("error missing identity mutual exclusion: %v", err) - } - if !strings.Contains(err.Error(), tc.wantFlag) { - t.Fatalf("error missing %s: %v", tc.wantFlag, err) - } - if exists, err := h.store.ProfileExists("identity"); err != nil { - t.Fatalf("ProfileExists identity: %v", err) - } else if exists { - t.Fatalf("--auto %s wrote profile", tc.wantFlag) - } - }) - } -} - -func TestAddAuto_ModelOverrideAllowed(t *testing.T) { +func TestAddAuto_YesCreatesAllAndDryRunWritesNothing(t *testing.T) { h := newAddHarness(t) addAutoFlag = true - addModelFlag = "foo" - restoreClipboard := setAddAutoClipboardForTest(func() (string, bool, error) { - return "", false, os.ErrNotExist - }) - defer restoreClipboard() + addDryRunFlag = true + stubAddAutoNoClipboard(t) restoreEnv := envextract.SetLookupForTest(addEnvUniverse(map[string]string{ - "ANTHROPIC_BASE_URL": "https://api.anthropic.com", - "ANTHROPIC_AUTH_TOKEN": "sk-model-override-1234", - "ANTHROPIC_MODEL": "from-env", + "ANTHROPIC_BASE_URL": "https://dry.example/v1", + "ANTHROPIC_AUTH_TOKEN": "sk-dry-auto-1234", })) defer restoreEnv() + restoreTTY := SetIsTerminalForTest(func(*os.File) bool { return false }) + defer restoreTTY() - if _, _, err := runAddInner(t, "modeloverride"); err != nil { - t.Fatalf("runAdd --auto --model: %v", err) + stdout, _, err := runAddInner(t) + if err != nil { + t.Fatalf("runAdd --auto --dry-run: %v\nstdout=%s", err, stdout) + } + if !strings.Contains(stdout, "--- dry-run: auto profiles (not written) ---") || !strings.Contains(stdout, "name: dry-example") { + t.Fatalf("stdout missing dry-run auto profile:\n%s", stdout) + } + if exists, err := h.store.ProfileExists("dry-example"); err != nil { + t.Fatalf("ProfileExists dry-example: %v", err) + } else if exists { + t.Fatalf("--dry-run wrote profile") } - loaded, err := h.store.LoadProfile("modeloverride") + + resetAddFlags() + addAutoFlag = true + addYesFlag = true + restoreEnv2 := envextract.SetLookupForTest(addEnvUniverse(map[string]string{ + "ANTHROPIC_BASE_URL": "https://dry.example/v1", + "ANTHROPIC_AUTH_TOKEN": "sk-dry-auto-1234", + })) + defer restoreEnv2() + stdout, _, err = runAddInner(t) if err != nil { - t.Fatalf("LoadProfile modeloverride: %v", err) + t.Fatalf("runAdd --auto --yes: %v\nstdout=%s", err, stdout) } - if loaded.Core.Model != "foo" { - t.Fatalf("Core.Model = %q, want explicit override foo", loaded.Core.Model) + if !strings.Contains(stdout, "created profiles:") || !strings.Contains(stdout, "dry-example") { + t.Fatalf("stdout missing created dry-example:\n%s", stdout) + } + if exists, err := h.store.ProfileExists("dry-example"); err != nil { + t.Fatalf("ProfileExists dry-example: %v", err) + } else if !exists { + t.Fatalf("--yes did not write profile") } } -func TestAddAuto_MultipleNewNonTTYRefusesRedactedList(t *testing.T) { - newAddHarness(t) +func TestAddAuto_NameCollisionGetsNumericSuffix(t *testing.T) { + h := newAddHarness(t) addAutoFlag = true - restoreClipboard := setAddAutoClipboardForTest(func() (string, bool, error) { - return "ANTHROPIC_BASE_URL=https://clip.example ANTHROPIC_AUTH_TOKEN=sk-clip-auto-1234", true, nil - }) - defer restoreClipboard() - restoreEnv := envextract.SetLookupForTest(addEnvUniverse(map[string]string{ - "OPENAI_BASE_URL": "https://env.example/v1", - "OPENAI_API_KEY": "sk-env-multi-1234", - })) - defer restoreEnv() + addYesFlag = true + stubAddAutoEmptySources(t) + if err := h.store.SaveProfile(&config.Profile{ + SchemaVersion: config.CurrentProfileSchemaVersion, + Name: "codex", + Core: config.CoreConfig{ + Provider: "openai-compat", + BaseURL: "https://old.example/v1", + APIKey: "sk-old-codex-1234", + }, + }); err != nil { + t.Fatalf("SaveProfile codex: %v", err) + } + writeAddAutoCodexAuth(t, h.home, `{"OPENAI_API_KEY":"sk-new-codex-1234"}`) restoreTTY := SetIsTerminalForTest(func(*os.File) bool { return false }) defer restoreTTY() - stdout, _, err := runAddInner(t, "multi") - if err == nil { - t.Fatalf("runAdd --auto multi non-tty unexpectedly succeeded\nstdout=%s", stdout) - } - if !strings.Contains(err.Error(), "multiple new credentials discovered") { - t.Fatalf("error missing multi-candidate refusal: %v", err) - } - if !strings.Contains(stdout, "multiple new credentials discovered:") { - t.Fatalf("stdout missing redacted list:\n%s", stdout) + stdout, _, err := runAddInner(t) + if err != nil { + t.Fatalf("runAdd --auto codex suffix: %v\nstdout=%s", err, stdout) } - for _, secret := range []string{"sk-clip-auto-1234", "sk-env-multi-1234"} { - if strings.Contains(stdout, secret) { - t.Fatalf("stdout leaked secret %q:\n%s", secret, stdout) - } + if !strings.Contains(stdout, "codex-2") { + t.Fatalf("stdout missing suffixed name:\n%s", stdout) } + assertProfileNames(t, h, []string{"codex", "codex-2"}) } -func TestAddAuto_MultipleNewNonTTYJSONOutputsRedactedCandidates(t *testing.T) { +func TestAddAuto_JSONDryRunListsAllProfilesRedacted(t *testing.T) { newAddHarness(t) addAutoFlag = true + addDryRunFlag = true addOutputFlag = "json" - restoreClipboard := setAddAutoClipboardForTest(func() (string, bool, error) { - return "ANTHROPIC_BASE_URL=https://clip.example ANTHROPIC_AUTH_TOKEN=sk-clip-json-1234", true, nil - }) - defer restoreClipboard() + stubAddAutoNoClipboard(t) restoreEnv := envextract.SetLookupForTest(addEnvUniverse(map[string]string{ - "OPENAI_BASE_URL": "https://env-json.example/v1", - "OPENAI_API_KEY": "sk-env-json-1234", + "ANTHROPIC_BASE_URL": "https://json.example/v1", + "ANTHROPIC_AUTH_TOKEN": "sk-json-auto-1234", })) defer restoreEnv() - restoreTTY := SetIsTerminalForTest(func(*os.File) bool { return false }) - defer restoreTTY() - stdout, _, err := runAddInner(t, "multijson") - if err == nil { - t.Fatalf("runAdd --auto multi json non-tty unexpectedly succeeded\nstdout=%s", stdout) - } - if !strings.Contains(err.Error(), "multiple new credentials discovered") { - t.Fatalf("error missing multi-candidate refusal: %v", err) + stdout, _, err := runAddInner(t) + if err != nil { + t.Fatalf("runAdd --auto json dry-run: %v\nstdout=%s", err, stdout) } - var out jsonAddAutoDisambiguation + var out jsonAddAutoProfiles if err := json.Unmarshal([]byte(stdout), &out); err != nil { t.Fatalf("json.Unmarshal(%q): %v", stdout, err) } - if out.Action != "auto-disambiguation-required" { - t.Fatalf("Action = %q", out.Action) - } - if len(out.Candidates) != 2 { - t.Fatalf("Candidates len = %d, want 2; stdout=%s", len(out.Candidates), stdout) + if out.Action != "dry-run" || len(out.Profiles) != 1 { + t.Fatalf("unexpected json auto dry-run: %+v", out) } - for _, candidate := range out.Candidates { - if candidate.Status != "NEW" { - t.Fatalf("candidate status = %q, want NEW", candidate.Status) - } - if candidate.Source == "" || candidate.BaseURL == "" || candidate.APIKey == "" { - t.Fatalf("candidate missing structured fields: %+v", candidate) - } + if len(out.Discovery) == 0 || len(out.Created) != 1 || out.Created[0] != "json-example" { + t.Fatalf("json missing discovery/created: %+v", out) } - for _, secret := range []string{"sk-clip-json-1234", "sk-env-json-1234"} { - if strings.Contains(stdout, secret) { - t.Fatalf("stdout leaked secret %q:\n%s", secret, stdout) - } + if out.Profiles[0].Name != "json-example" { + t.Fatalf("profile name = %q, want json-example", out.Profiles[0].Name) } - if !strings.Contains(stdout, "sk-c***1234") || !strings.Contains(stdout, "sk-e***1234") { - t.Fatalf("stdout missing redacted keys:\n%s", stdout) + if strings.Contains(stdout, "sk-json-auto-1234") { + t.Fatalf("stdout leaked key:\n%s", stdout) } } -func TestAddAuto_MultipleNewTTYSelectsOne(t *testing.T) { - newAddHarness(t) +func TestAddAuto_JSONCreatedListsDiscoveryCreatedAndSkippedRedacted(t *testing.T) { + h := newAddHarness(t) addAutoFlag = true - addDryRunFlag = true - restoreClipboard := setAddAutoClipboardForTest(func() (string, bool, error) { - return "ANTHROPIC_BASE_URL=https://clip.example ANTHROPIC_AUTH_TOKEN=sk-clip-select-1234", true, nil - }) - defer restoreClipboard() + addYesFlag = true + addOutputFlag = "json" + stubAddAutoNoClipboard(t) restoreEnv := envextract.SetLookupForTest(addEnvUniverse(map[string]string{ - "OPENAI_BASE_URL": "https://env-select.example/v1", - "OPENAI_API_KEY": "sk-env-select-1234", + "ANTHROPIC_BASE_URL": "https://json-create.example/v1", + "ANTHROPIC_AUTH_TOKEN": "sk-json-create-1234", })) defer restoreEnv() - restoreTTY := SetIsTerminalForTest(func(*os.File) bool { return true }) - defer restoreTTY() - restoreStdin := withOSStdin(t, "2\n") - defer restoreStdin() + if err := h.store.SaveProfile(&config.Profile{ + SchemaVersion: config.CurrentProfileSchemaVersion, + Name: "existing", + Core: config.CoreConfig{Provider: "openai-compat", APIKey: "sk-json-existing-1234"}, + }); err != nil { + t.Fatalf("SaveProfile existing: %v", err) + } + writeAddAutoCodexAuth(t, h.home, `{"OPENAI_API_KEY":"sk-json-existing-1234"}`) - stdout, _, err := runAddInner(t, "picked") + stdout, _, err := runAddInner(t) if err != nil { - t.Fatalf("runAdd --auto TTY pick: %v\nstdout=%s", err, stdout) + t.Fatalf("runAdd --auto json created: %v\nstdout=%s", err, stdout) + } + var out jsonAddAutoProfiles + if err := json.Unmarshal([]byte(stdout), &out); err != nil { + t.Fatalf("json.Unmarshal: %v\n%s", err, stdout) } - if !strings.Contains(stdout, "Select credential [1-2]:") { - t.Fatalf("stdout missing selection prompt:\n%s", stdout) + if out.Action != "created" || len(out.Discovery) == 0 || len(out.Created) != 1 || out.Created[0] != "json-create-example" { + t.Fatalf("unexpected json created output: %+v", out) } - if !strings.Contains(stdout, "base_url: https://env-select.example/v1") { - t.Fatalf("dry-run did not use selected env candidate:\n%s", stdout) + if len(out.Skipped) != 1 || out.Skipped[0].Reason != "already-recorded" { + t.Fatalf("json skipped = %+v, want one already-recorded", out.Skipped) } - if strings.Contains(stdout, "sk-env-select-1234") || strings.Contains(stdout, "sk-clip-select-1234") { - t.Fatalf("stdout leaked selected secret:\n%s", stdout) + if strings.Contains(stdout, "sk-json-create-1234") || strings.Contains(stdout, "sk-json-existing-1234") { + t.Fatalf("json leaked key:\n%s", stdout) } + assertProfileNames(t, h, []string{"existing", "json-create-example"}) } -func TestAddAuto_DedupsSameCredentialWithinSweep(t *testing.T) { - newAddHarness(t) +func TestAddAuto_InteractiveNamingAcceptsDefault(t *testing.T) { + h := newAddHarness(t) addAutoFlag = true - addDryRunFlag = true - restoreClipboard := setAddAutoClipboardForTest(func() (string, bool, error) { - return "ANTHROPIC_BASE_URL=https://api.anthropic.com/ ANTHROPIC_AUTH_TOKEN=sk-same-auto-1234", true, nil - }) - defer restoreClipboard() + stubAddAutoNoClipboard(t) restoreEnv := envextract.SetLookupForTest(addEnvUniverse(map[string]string{ - "ANTHROPIC_BASE_URL": "https://API.Anthropic.com", - "ANTHROPIC_AUTH_TOKEN": "sk-same-auto-1234", + "ANTHROPIC_BASE_URL": "https://interactive-default.example/v1", + "ANTHROPIC_AUTH_TOKEN": "sk-interactive-default-1234", })) defer restoreEnv() - restoreTTY := SetIsTerminalForTest(func(*os.File) bool { return false }) + restoreTTY := SetIsTerminalForTest(func(*os.File) bool { return true }) defer restoreTTY() + restoreStdin := pipeAddAutoStdin(t, "\ny\n") + defer restoreStdin() - stdout, _, err := runAddInner(t, "same") + stdout, _, err := runAddInner(t) if err != nil { - t.Fatalf("runAdd --auto same credential should not be ambiguous: %v\nstdout=%s", err, stdout) + t.Fatalf("runAdd --auto interactive default: %v\nstdout=%s", err, stdout) } - if !strings.Contains(stdout, "environment: duplicate of clipboard") { - t.Fatalf("stdout missing within-sweep duplicate marker:\n%s", stdout) - } - if strings.Contains(stdout, "multiple new credentials discovered") { - t.Fatalf("same credential was treated as multiple new choices:\n%s", stdout) + if !strings.Contains(stdout, "Save profile for environment, sk-i***1234 as [interactive-default-example]:") { + t.Fatalf("stdout missing naming prompt:\n%s", stdout) } + assertProfileNames(t, h, []string{"interactive-default-example"}) } -func TestAddAuto_NoClipboardToolDoesNotAbortEnv(t *testing.T) { - newAddHarness(t) +func TestAddAuto_InteractiveNamingUsesCustomName(t *testing.T) { + h := newAddHarness(t) addAutoFlag = true - addDryRunFlag = true - restoreClipboard := setAddAutoClipboardForTest(func() (string, bool, error) { - return "", false, os.ErrNotExist - }) - defer restoreClipboard() + stubAddAutoNoClipboard(t) restoreEnv := envextract.SetLookupForTest(addEnvUniverse(map[string]string{ - "ANTHROPIC_BASE_URL": "https://api.anthropic.com", - "ANTHROPIC_AUTH_TOKEN": "sk-env-after-clip-1234", + "ANTHROPIC_BASE_URL": "https://interactive-custom.example/v1", + "ANTHROPIC_AUTH_TOKEN": "sk-interactive-custom-1234", })) defer restoreEnv() + restoreTTY := SetIsTerminalForTest(func(*os.File) bool { return true }) + defer restoreTTY() + restoreStdin := pipeAddAutoStdin(t, "mine-custom\ny\n") + defer restoreStdin() - stdout, _, err := runAddInner(t, "clipmissing") + stdout, _, err := runAddInner(t) if err != nil { - t.Fatalf("runAdd --auto no clipboard tool: %v\nstdout=%s", err, stdout) - } - if !strings.Contains(stdout, "clipboard: skipped: file does not exist") { - t.Fatalf("stdout missing clipboard skip reason:\n%s", stdout) + t.Fatalf("runAdd --auto interactive custom: %v\nstdout=%s", err, stdout) } - if !strings.Contains(stdout, "environment: NEW") { - t.Fatalf("stdout missing env candidate after clipboard skip:\n%s", stdout) + if !strings.Contains(stdout, "mine-custom") { + t.Fatalf("stdout missing custom created name:\n%s", stdout) } + assertProfileNames(t, h, []string{"mine-custom"}) } -func TestAddAuto_MutuallyExclusiveWithOtherInputSources(t *testing.T) { - newAddHarness(t) +func TestAddAuto_YesUsesDerivedNameWithoutPrompt(t *testing.T) { + h := newAddHarness(t) addAutoFlag = true - addFromEnvFlag = true + addYesFlag = true + stubAddAutoNoClipboard(t) + restoreEnv := envextract.SetLookupForTest(addEnvUniverse(map[string]string{ + "ANTHROPIC_BASE_URL": "https://yes-derived.example/v1", + "ANTHROPIC_AUTH_TOKEN": "sk-yes-derived-1234", + })) + defer restoreEnv() + restoreTTY := SetIsTerminalForTest(func(*os.File) bool { return true }) + defer restoreTTY() - _, _, err := runAddInner(t, "exclusive") - if err == nil { - t.Fatalf("runAdd --auto --from-env unexpectedly succeeded") + stdout, _, err := runAddInner(t) + if err != nil { + t.Fatalf("runAdd --auto --yes derived: %v\nstdout=%s", err, stdout) } - if !strings.Contains(err.Error(), "choose only one add input source") { - t.Fatalf("error missing mutual exclusion style: %v", err) + if strings.Contains(stdout, "Save profile for") { + t.Fatalf("--yes unexpectedly prompted:\n%s", stdout) } - if !strings.Contains(err.Error(), "--auto") { - t.Fatalf("error missing --auto in source list: %v", err) + assertProfileNames(t, h, []string{"yes-derived-example"}) +} + +type failingAddAutoStore struct { + *storage.FileStorage + failName string +} + +func (s failingAddAutoStore) SaveProfile(profile *config.Profile) error { + if profile != nil && profile.Name == s.failName { + return errors.New("injected save failure") } + return s.FileStorage.SaveProfile(profile) +} + +func stubAddAutoNoClipboard(t *testing.T) { + t.Helper() + restoreClipboard := setAddAutoClipboardForTest(func() (string, bool, error) { + return "", false, os.ErrNotExist + }) + t.Cleanup(restoreClipboard) +} + +func stubAddAutoEmptySources(t *testing.T) { + t.Helper() + stubAddAutoNoClipboard(t) + restoreEnv := envextract.SetLookupForTest(addEnvUniverse(map[string]string{})) + t.Cleanup(restoreEnv) } func writeAddAutoCodexConfig(t *testing.T, home, body string) { @@ -474,22 +596,65 @@ func writeAddAutoCodexAuth(t *testing.T, home, body string) { } } -func withOSStdin(t *testing.T, input string) func() { +func writeAddAutoClaudeSettings(t *testing.T, home, body string) { + t.Helper() + dir := filepath.Join(home, ".claude") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatalf("mkdir .claude: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "settings.json"), []byte(body), 0o600); err != nil { + t.Fatalf("write settings.json: %v", err) + } +} + +func addAutoCreatedSection(stdout string) string { + idx := strings.Index(stdout, "created profiles:") + if idx < 0 { + return "" + } + section := stdout[idx:] + if next := strings.Index(section[len("created profiles:"):], "\n\n"); next >= 0 { + return section[:len("created profiles:")+next] + } + return section +} + +func pipeAddAutoStdin(t *testing.T, input string) func() { t.Helper() - old := os.Stdin r, w, err := os.Pipe() if err != nil { t.Fatalf("os.Pipe: %v", err) } + orig := os.Stdin + os.Stdin = r if _, err := w.WriteString(input); err != nil { t.Fatalf("write stdin pipe: %v", err) } if err := w.Close(); err != nil { - t.Fatalf("close stdin writer: %v", err) + t.Fatalf("close stdin pipe writer: %v", err) } - os.Stdin = r return func() { - os.Stdin = old + os.Stdin = orig _ = r.Close() } } + +func assertProfileNames(t *testing.T, h *addHarness, want []string) { + t.Helper() + gotProfiles, err := h.store.LoadAllProfiles() + if err != nil { + t.Fatalf("LoadAllProfiles: %v", err) + } + got := make(map[string]struct{}, len(gotProfiles)) + for _, profile := range gotProfiles { + got[profile.Name] = struct{}{} + } + if len(got) != len(want) { + t.Fatalf("profile count = %d, want %d; got=%v", len(got), len(want), got) + } + for _, name := range want { + if _, ok := got[name]; !ok { + t.Fatalf("missing profile %q; got=%v", name, got) + } + } +} diff --git a/cmd/add_test.go b/cmd/add_test.go index c737488..63abef8 100644 --- a/cmd/add_test.go +++ b/cmd/add_test.go @@ -60,6 +60,7 @@ func resetAddFlags() { addAIProfileFlag = "" addListPresetsFlag = false addDryRunFlag = false + addYesFlag = false addOverwriteFlag = false addOutputFlag = "text" } @@ -139,6 +140,10 @@ func bindSyntheticAddFlags(cmd *cobra.Command) { if addAutoFlag { _ = cmd.Flags().Set("auto", "true") } + cmd.Flags().Bool("yes", addYesFlag, "") + if addYesFlag { + _ = cmd.Flags().Set("yes", "true") + } } // --------------------------------------------------------------------------- diff --git a/docs/decisions/0003-smart-add-scope.md b/docs/decisions/0003-smart-add-scope.md index ef01f8f..97b9e91 100644 --- a/docs/decisions/0003-smart-add-scope.md +++ b/docs/decisions/0003-smart-add-scope.md @@ -47,11 +47,15 @@ zero-network paths are the default and the network path is explicit opt-in: the held secret locally, then routes through the normal `add` preview/validation. This is the only E13 path that uses the network. -5. **`add --auto` / `-a`** (E13-S6) — a convenience mode that sweeps every **local** source at once - (clipboard, environment, `~/.claude/settings.json`, `~/.codex/{auth.json,config.toml}`), dedups - discovered credentials against existing profiles by `(base_url, api_key)`, and offers only the new - ones. Zero network; never combined with `--ai`. This exists because five source flags are more - choice than the common "just find my key" case warrants. +5. **`add --auto` / `-a`** (E13-S6, corrected by E13-S7) — a nameless discovery mode that sweeps + every **local** source at once (clipboard, environment, `~/.claude/settings.json`, + `~/.codex/{auth.json,config.toml}`), collapses candidates sharing `(base_url, api_key)`, dedups + against existing profiles, and **registers each remaining new credential as its own auto-named + profile** — because a sweep finds several distinct credentials and each is a distinct profile. + Discovery reads sources **leniently** (read-only), not via the strict write-path `Import`, so a + `config.toml` with unknown `[projects."…"]` sections never suppresses the `auth.json` credential. + Zero network; never combined with `--ai`. This exists because five source flags are more choice + than the common "just find my keys" case warrants. All four paths converge on the existing `add` pipeline: they only produce a `config.Profile` draft, which is then subject to the same `--dry-run`, redaction (NFR-S8), name validation (NFR-S5), diff --git a/docs/plan/epics.md b/docs/plan/epics.md index 1aec6ff..6fa03ae 100644 --- a/docs/plan/epics.md +++ b/docs/plan/epics.md @@ -219,7 +219,7 @@ No story silently expands v1 scope: no MCP, no cloud, no GUI, no Gemini CLI / Cu - Secrets are redacted by default in every preview (`--dry-run` / prompt) per NFR-S8; profile-name validation (NFR-S5) and overwrite guard are unchanged. - Docs and `--help` state the local-first default and that only the opt-in `--ai` path makes a (secret-free) network request. -**Stories.** E13-S1, E13-S2, E13-S3, E13-S4, E13-S5, E13-S6 (`add --auto`/`-a` one-flag source sweep with dedup — added 2026-07-08; collapses the five source flags into one "just find my key" path and skips already-recorded credentials). +**Stories.** E13-S1, E13-S2, E13-S3, E13-S4, E13-S5, E13-S6, E13-S7 (`add --auto`/`-a` — S6 added the one-flag sweep; S7 corrects it to a nameless, multi-profile discovery that registers one auto-named profile per discovered credential and reads sources leniently). --- @@ -237,5 +237,5 @@ No story silently expands v1 scope: no MCP, no cloud, no GUI, no Gemini CLI / Cu - E10: 4 stories - E11: 4 stories - E12: 5 stories -- E13: 6 stories -- **Total: 13 epics, 75 stories.** +- E13: 7 stories +- **Total: 13 epics, 76 stories.** diff --git a/docs/plan/stories/E13-S7.md b/docs/plan/stories/E13-S7.md new file mode 100644 index 0000000..8363b7e --- /dev/null +++ b/docs/plan/stories/E13-S7.md @@ -0,0 +1,82 @@ +# E13-S7 — `add --auto` creates one profile per discovered credential (supersedes S6's single-name model) + +**Supersedes.** E13-S6 shipped `--auto` inside the `add ` single-profile shape: it required a +positional name and *refused* when it found more than one credential. That is the wrong model. A +sweep discovers many credentials (Claude Code's, Codex's, an env key, a clipboard key); each distinct +credential is a distinct profile. This story corrects `--auto` to a nameless, multi-profile +discovery, and fixes credential discovery to be lenient (read-only) rather than reusing the strict +write-path adapter `Import`. + +**User story.** +As a user who already has Claude Code and Codex credentials on disk, I want `claudecm add -a` (no +name) to find every credential across clipboard / env / `~/.claude` / `~/.codex`, skip the ones I +already have, and register each remaining one as its own auto-named profile — so one command adopts +everything, instead of forcing me to name a single profile or refusing because it found several. + +**Acceptance criteria.** +- `claudecm add --auto` / `-a` takes **no positional profile name**. Passing a name with `--auto` + is an error with a clear message (names are derived per source). `--auto` stays mutually exclusive + with `--from-*` / `--preset` / `--api-key` / `--base-url`. +- **Lenient discovery (read-only), not the strict write-path `Import`:** + - Codex: read `~/.codex/auth.json` for `OPENAI_API_KEY` **even when `config.toml` fails to parse or + contains unknown sections** (e.g. `[projects."…"]`); best-effort read `config.toml`'s known + provider table for `base_url`/`model`, ignoring unrecognized sections. A malformed `config.toml` + must never suppress the `auth.json` credential. + - Claude Code: read `~/.claude/settings.json` `env.ANTHROPIC_*` leniently (unknown keys ignored). + - Clipboard (`blobparse`) and env (`envextract`) as before. Each source best-effort; one failing + never aborts the others; zero network. +- **Collapse then dedup:** candidates sharing a normalized `(base_url, api_key)` collapse to one + credential even across sources; if either side lacks `base_url`, dedup falls back to matching the + same `api_key`. Each credential is then deduped against existing profiles (`LoadAllProfiles`) and + skipped if already recorded (secrets redacted in all output). +- **Interactive naming:** each NEW credential gets a derived default profile name — tool sources use + the tool name (`claude-code`, `codex`); env/clipboard use a slug of the base_url host (e.g. + `bray-neov-im`), or `env` / `clipboard` when no host. In an interactive TTY, claudecm prompts for + each NEW credential as `Save profile for as []:`; Enter + accepts the default, or the user may enter a custom name that must pass `ValidateProfileName`. + `--yes` and non-interactive mode use the derived names without prompting. Name collisions with + existing profiles get a numeric suffix (`-2`, `-3`). Derived and custom names pass + `ValidateProfileName`. +- **Create all new profiles:** for each NEW credential, build and save a profile through the normal + pipeline (redaction, `ValidateProfileName`, `SaveProfile`); never auto-activate. `--dry-run` + previews every would-be profile. In an interactive TTY, prompt for each new profile name, list the + proposed profiles (redacted), and confirm before writing; non-interactively require `--yes` + (mirrors `import`), else refuse. +- **Report:** print created profile names, skipped-because-already-recorded entries, and any source + skip reasons. If no credential is found anywhere → refuse naming swept sources. If every discovered + credential is already recorded → informational message, exit 0, nothing written. +- Refuse-don't-guess: a source that cannot be read leniently contributes no candidate (not a fatal + error); nothing keyless or half-populated is ever written. + +**PRD/architecture refs.** ADR-0003 Decision Summary item 5 (revised); supersedes E13-S6 AC on single +name / multi-candidate refusal; PRD FR-1, NFR-S1, NFR-S8; reuses `envextract`, `blobparse`, and a new +lenient codex/claude read (NOT the strict `Import`). + +**Test plan.** +- `~/.codex/auth.json` has `OPENAI_API_KEY` and `config.toml` contains `[projects."…"]` → the codex + credential is still discovered (regression for the real-world failure). +- env + `~/.claude` hold the **same** key → collapse to one credential → one profile. +- env (anthropic) + codex (openai) both new → two profiles created, auto-named `claude-code`/host-slug + and `codex`. +- A discovered credential already recorded under some profile → skipped, reported, not recreated. +- No name allowed: `add foo --auto` → error. +- Nothing found → refuse; all already recorded → exit 0 informational. +- Non-TTY without `--yes` → refuse; with `--yes` → creates all; `--dry-run` previews all. +- Injectable seams for clipboard, env, and HOME so tests need no real OS clipboard or real `~`. + +**No fallback writes.** Malformed/unknown source content yields no candidate; keyless/partial profiles +are never written. + +**Complexity.** M. + +**Dependencies.** E13-S6 (replaces its `--auto` behavior), E13-S1, E3/E4 (adapter read internals), +E5 (envextract). + +**Ready-for-dev checklist.** +- [x] PRD/architecture refs correct. +- [x] AC testable. +- [x] Happy + edge + regression (codex projects section) rows. +- [x] No-fallback reminder present. +- [x] Supersede note vs E13-S6. +- [x] Deps satisfied. +- [x] Complexity set. diff --git a/docs/quickstart.md b/docs/quickstart.md index 78b1ce8..efa469b 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -91,13 +91,14 @@ Use `--from-text -` to read stdin: cat provider-snippet.txt | claudecm add work --from-text - --dry-run ``` -For the lowest-friction local onboarding, `--auto` / `-a` sweeps the clipboard, environment, `~/.claude/settings.json`, and `~/.codex/{auth.json,config.toml}` in order. It drops candidates without an API key, marks anything already recorded by the same `(base_url, api_key)`, and never uses the network. Missing sources, such as no clipboard tool on PATH, are reported and do not stop the rest of the sweep. +For the lowest-friction local onboarding, `--auto` / `-a` takes no profile name and sweeps the clipboard, environment, `~/.claude/settings.json`, and `~/.codex/{auth.json,config.toml}` in order. It drops candidates without an API key, collapses duplicate credentials, skips anything already recorded, and creates one profile per remaining credential. It never uses the network. Missing sources, such as no clipboard tool on PATH, are reported and do not stop the rest of the sweep; Codex `auth.json` is still read even if `config.toml` contains unknown sections. ```bash -claudecm add work --auto --dry-run +claudecm add --auto --dry-run +claudecm add --auto --yes ``` -Expected: a redacted discovery list. If exactly one new credential is found, the normal add preview/save path continues. If several are found, interactive terminals ask which one to use; non-interactive runs refuse with a redacted list so you can disambiguate. +Expected: a redacted discovery list and a preview or creation report for every new profile. Interactive terminals prompt for each new credential name with a derived default, accept Enter to keep the default, then ask for confirmation before writing. `--yes` and non-interactive runs use derived names without prompting; non-interactive runs require `--yes`. If the local extractor is not enough, `--ai` is opt-in per invocation and only runs in an interactive terminal. claudecm strips secret-shaped tokens locally, keeps captured secrets in-process, shows the exact desensitized payload for confirmation, and sends only the confirmed desensitized text in one Anthropic-compatible Messages request using the active profile's credentials, or `--ai-profile ` if you choose another credential-lending profile. Non-interactive or piped `--ai` runs refuse before sending. diff --git a/internal/adapter/claudecode/import.go b/internal/adapter/claudecode/import.go index 28c669e..fa93e63 100644 --- a/internal/adapter/claudecode/import.go +++ b/internal/adapter/claudecode/import.go @@ -331,6 +331,13 @@ func coerceToString(v any) string { // side always requires the file to already exist, so it is simpler // to inline the semantics than to grow the write-path helper. func verifyReadTargetInHome(path string, r *storage.Resolver) error { + return VerifyReadTargetInHome(path, r) +} + +// VerifyReadTargetInHome performs Claude Code's read-side containment +// check for callers that need Claude-compatible lenient reads without +// using the strict Import flow. +func VerifyReadTargetInHome(path string, r *storage.Resolver) error { resolved, err := filepath.EvalSymlinks(path) if err != nil { // ErrNotExist covers both "file/parent absent" and "dangling diff --git a/internal/adapter/codex/readers.go b/internal/adapter/codex/readers.go index b1e9479..3f3a9c9 100644 --- a/internal/adapter/codex/readers.go +++ b/internal/adapter/codex/readers.go @@ -177,6 +177,13 @@ func treatAsEmpty(data []byte) bool { // treatAsEmpty: cross-adapter dependencies are forbidden by the // interface contract. func verifyReadTargetInHomeCodex(path string, r *storage.Resolver) error { + return VerifyReadTargetInHome(path, r) +} + +// VerifyReadTargetInHome performs Codex's read-side containment check +// for callers that need Codex-compatible lenient reads without using +// the strict Import flow. +func VerifyReadTargetInHome(path string, r *storage.Resolver) error { resolved, err := filepath.EvalSymlinks(path) if err != nil { if errors.Is(err, os.ErrNotExist) {