From d20bf195b7aa30c7e28e69a6fb2a697aed93e6c3 Mon Sep 17 00:00:00 2001 From: imneov Date: Wed, 8 Jul 2026 07:13:07 +0000 Subject: [PATCH 1/3] docs(e13-s7): correct add --auto to nameless multi-profile discovery S6 wrongly forced --auto into add (one profile, refuse on multiple credentials). A sweep finds several distinct credentials; each is a distinct profile. S7: --auto takes no name, registers one auto-named profile per new credential, and reads sources leniently so a config.toml with [projects] sections no longer suppresses the codex auth.json key. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/decisions/0003-smart-add-scope.md | 14 +++-- docs/plan/epics.md | 6 +- docs/plan/stories/E13-S7.md | 76 ++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 8 deletions(-) create mode 100644 docs/plan/stories/E13-S7.md 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..8a2e550 --- /dev/null +++ b/docs/plan/stories/E13-S7.md @@ -0,0 +1,76 @@ +# 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; each credential is then deduped against existing profiles + (`LoadAllProfiles`) and skipped if already recorded (secrets redacted in all output). +- **Auto-naming:** each NEW credential gets a derived 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. Name collisions with existing profiles get a numeric suffix + (`-2`, `-3`). Derived 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, 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. From 985bdfae3d3f404dfecf52ec29d42a6f9a2a14d4 Mon Sep 17 00:00:00 2001 From: imneov Date: Wed, 8 Jul 2026 07:38:56 +0000 Subject: [PATCH 2/3] fix add auto multi-profile discovery --- README.md | 11 +- cmd/add.go | 51 +-- cmd/add_auto.go | 773 +++++++++++++++++++++++++++++++++++-------- cmd/add_auto_test.go | 506 +++++++++++----------------- cmd/add_test.go | 5 + docs/quickstart.md | 7 +- 6 files changed, 878 insertions(+), 475 deletions(-) diff --git a/README.md b/README.md index 07ae941..8a6a6e2 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 `(base_url, api_key)` credentials, skips credentials already recorded, and creates one auto-named 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 ask for confirmation before writing, 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..f8539f0 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,9 @@ 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. + 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 +212,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 +242,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 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 +255,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 +265,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 +313,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 +334,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..1c23440 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,90 @@ type addAutoSourceResult struct { type addAutoClipboardReader func() (string, bool, error) -func profileDraftFromAuto( +func runAddAuto( cmd *cobra.Command, resv *storage.Resolver, store *storage.FileStorage, 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") } - 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) + profiles := buildAddAutoProfiles(newCandidates, nowFn().UTC()) + + if addDryRunFlag { + return renderAddAutoDryRun(cmd.OutOrStdout(), format, profiles) + } + if !addYesFlag { + if !isTerminal(os.Stdin) { + if format == addOutputText { + fmt.Fprintln(cmd.OutOrStdout(), "profiles to create:") + renderAddAutoProfileTextList(cmd.OutOrStdout(), profiles) + } else if err := renderAddAutoProfilesJSON(cmd.OutOrStdout(), "confirm-required", profiles); err != nil { + return err + } + return fmt.Errorf("non-interactive session: pass --yes to create discovered profiles or --dry-run to preview") + } + 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") } - return chosen.Core, chosen.Tools, false, nil } + + 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 fmt.Errorf("failed to save profile %q: %w", profile.Name, err) + } + } + return renderAddAutoCreated(cmd.OutOrStdout(), format, profiles) } 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 +166,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,35 +273,221 @@ 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 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) + 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") + 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) + 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) (map[string]any, string) { + 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) (*codextoml.Doc, string) { + 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 { + return nil, "config.toml skipped: parse failed: " + err.Error() + } + return doc, "" +} + +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 *storage.FileStorage) error { if store == nil { return nil @@ -294,40 +573,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 +607,326 @@ 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"` +} + +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) { + byKey := map[string]int{} + out := []addAutoCandidate{} + for _, result := range results { + for _, candidate := range result.Candidates { + if candidate.AlreadyProfile != "" || candidate.DuplicateOf != "" { + continue + } + key := addAutoDedupKey(candidate.Core.BaseURL, candidate.Core.APIKey) + if key == "" { + continue + } + if idx, ok := byKey[key]; ok { + out[idx] = mergeAddAutoCandidate(out[idx], candidate) + continue + } + candidate.Core = normalizeParsedProvider(candidate.Core) + candidate.Sources = normalizeAddAutoSources(candidate.Sources, candidate.Source) + if candidate.NameBase == "" { + candidate.NameBase = addAutoBaseNameFor(candidate.Source, candidate.Core.BaseURL) + } + byKey[key] = len(out) + 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 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 +} + +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) } - fmt.Fprintln(w) } + return out } -type jsonAddAutoDisambiguation struct { - Action string `json:"action"` - Candidates []jsonAddAutoCandidate `json:"candidates"` +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 } -type jsonAddAutoCandidate struct { - Source string `json:"source"` - BaseURL string `json:"base_url"` - APIKey string `json:"api_key"` - Status string `json:"status"` +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 *storage.FileStorage) (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 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 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 renderAddAutoDisambiguationJSON(w io.Writer, candidates []addAutoCandidate) error { - out := jsonAddAutoDisambiguation{ - Action: "auto-disambiguation-required", - Candidates: make([]jsonAddAutoCandidate, 0, len(candidates)), +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 writeAddJSON(w, out) + return profiles } -func addAutoCandidateStatus(candidate addAutoCandidate) string { - if candidate.AlreadyProfile != "" { - return "already recorded as " + candidate.AlreadyProfile +func renderAddAutoDryRun(w io.Writer, format addOutputFormat, profiles []*config.Profile) error { + if format == addOutputJSON { + return renderAddAutoProfilesJSON(w, "dry-run", profiles) } - if candidate.DuplicateOf != "" { - return "duplicate of " + candidate.DuplicateOf + fmt.Fprintln(w, "--- dry-run: auto profiles (not written) ---") + for _, profile := range profiles { + if err := renderAddDryRun(w, addOutputText, profile); err != nil { + return err + } } - return "NEW" + return nil +} + +func renderAddAutoCreated(w io.Writer, format addOutputFormat, profiles []*config.Profile) error { + if format == addOutputJSON { + return renderAddAutoProfilesJSON(w, "created", profiles) + } + 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"` + 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 addAutoDedupKey(baseURL, apiKey string) string { diff --git a/cmd/add_auto_test.go b/cmd/add_auto_test.go index 9b1fe09..280f8f8 100644 --- a/cmd/add_auto_test.go +++ b/cmd/add_auto_test.go @@ -13,48 +13,70 @@ import ( "github.com/a2d2-dev/claudecm/internal/envextract" ) -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) - } - if strings.Contains(stdout, "sk-env-auto-123456") { - t.Fatalf("stdout leaked plaintext key:\n%s", stdout) + t.Fatalf("runAdd --auto same env+claude: %v\nstdout=%s", err, 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,27 +84,24 @@ 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) + t.Fatalf("runAdd --auto two profiles: %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) - } - if strings.Contains(stdout, "sk-codex-auto-1234") { - t.Fatalf("stdout leaked codex key:\n%s", stdout) + if !strings.Contains(stdout, "created profiles:") || !strings.Contains(stdout, "bray-neov-im") || !strings.Contains(stdout, "codex") { + t.Fatalf("stdout missing created profile names:\n%s", stdout) } + assertProfileNames(t, h, []string{"bray-neov-im", "codex"}) } -func TestAddAuto_DedupAlreadyRecorded(t *testing.T) { +func TestAddAuto_AlreadyRecordedSkippedAndNotRecreated(t *testing.T) { h := newAddHarness(t) addAutoFlag = 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-existing-auto-1234", @@ -100,9 +119,9 @@ func TestAddAuto_DedupAlreadyRecorded(t *testing.T) { t.Fatalf("SaveProfile existing: %v", err) } - 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 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) @@ -110,346 +129,190 @@ func TestAddAuto_DedupAlreadyRecorded(t *testing.T) { if !strings.Contains(stdout, "all discovered credentials are already recorded") { t.Fatalf("stdout missing all-recorded message:\n%s", stdout) } - 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") - } + assertProfileNames(t, h, []string{"existing"}) } -func TestAddAuto_DedupAlreadyRecordedDefaultHTTPSPort(t *testing.T) { - h := newAddHarness(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{ - "ANTHROPIC_BASE_URL": "https://h:443/v1", - "ANTHROPIC_AUTH_TOKEN": "sk-same-default-port", - })) - defer restoreEnv() - if err := h.store.SaveProfile(&config.Profile{ - SchemaVersion: config.CurrentProfileSchemaVersion, - Name: "existing", - Core: config.CoreConfig{ - Provider: "anthropic", - BaseURL: "https://h/v1", - APIKey: "sk-same-default-port", - }, - }); err != nil { - t.Fatalf("SaveProfile existing: %v", err) - } - stdout, _, err := runAddInner(t, "dupport") - if err != nil { - t.Fatalf("runAdd --auto default-port dedup should exit 0: %v\nstdout=%s", err, stdout) + stdout, _, err := runAddInner(t, "foo") + if err == nil { + t.Fatalf("runAdd add foo --auto unexpectedly succeeded\nstdout=%s", 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(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_NoKeyRefusesWithSweptSources(t *testing.T) { +func TestAddAuto_NoCredentialsRefusesWithSweptSources(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() + stubAddAutoEmptySources(t) - stdout, _, err := runAddInner(t, "nokey") + 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) { - h := newAddHarness(t) - addAutoFlag = true - addAPIKeyFlagExplicit = true - - stdout, _, err := runAddInner(t, "emptykey") - if err == nil { - t.Fatalf("runAdd --auto --api-key= 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(), "--auto") || !strings.Contains(err.Error(), "--api-key") { - t.Fatalf("error missing conflicting flags: %v", err) - } - if exists, err := h.store.ProfileExists("emptykey"); err != nil { - t.Fatalf("ProfileExists emptykey: %v", err) - } else if exists { - t.Fatalf("explicit empty --api-key wrote keyless 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_NonTTYWithoutYesRefusesWithPreview(t *testing.T) { h := newAddHarness(t) addAutoFlag = true - addModelFlag = "foo" - 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-model-override-1234", - "ANTHROPIC_MODEL": "from-env", - })) - defer restoreEnv() - - if _, _, err := runAddInner(t, "modeloverride"); err != nil { - t.Fatalf("runAdd --auto --model: %v", err) - } - loaded, err := h.store.LoadProfile("modeloverride") - if err != nil { - t.Fatalf("LoadProfile modeloverride: %v", err) - } - if loaded.Core.Model != "foo" { - t.Fatalf("Core.Model = %q, want explicit override foo", loaded.Core.Model) - } -} - -func TestAddAuto_MultipleNewNonTTYRefusesRedactedList(t *testing.T) { - 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", + "ANTHROPIC_AUTH_TOKEN": "sk-nontty-auto-1234", })) defer restoreEnv() restoreTTY := SetIsTerminalForTest(func(*os.File) bool { return false }) defer restoreTTY() - stdout, _, err := runAddInner(t, "multi") + stdout, _, err := runAddInner(t) if err == nil { - t.Fatalf("runAdd --auto multi non-tty unexpectedly succeeded\nstdout=%s", stdout) + t.Fatalf("runAdd --auto non-tty without --yes 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(err.Error(), "pass --yes") { + t.Fatalf("error missing --yes guidance: %v", err) } - if !strings.Contains(stdout, "multiple new credentials discovered:") { - t.Fatalf("stdout missing redacted list:\n%s", stdout) + if !strings.Contains(stdout, "profiles to create:") || !strings.Contains(stdout, "api-anthropic-com") { + t.Fatalf("stdout missing preview list:\n%s", 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 exists, err := h.store.ProfileExists("api-anthropic-com"); err != nil { + t.Fatalf("ProfileExists: %v", err) + } else if exists { + t.Fatalf("non-tty refusal wrote profile") } } -func TestAddAuto_MultipleNewNonTTYJSONOutputsRedactedCandidates(t *testing.T) { - newAddHarness(t) +func TestAddAuto_YesCreatesAllAndDryRunWritesNothing(t *testing.T) { + h := newAddHarness(t) addAutoFlag = 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() + addDryRunFlag = true + 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://dry.example/v1", + "ANTHROPIC_AUTH_TOKEN": "sk-dry-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) - } - var out jsonAddAutoDisambiguation - 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) - } - 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) - } + stdout, _, err := runAddInner(t) + if err != nil { + t.Fatalf("runAdd --auto --dry-run: %v\nstdout=%s", err, stdout) } - 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 !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 !strings.Contains(stdout, "sk-c***1234") || !strings.Contains(stdout, "sk-e***1234") { - t.Fatalf("stdout missing redacted keys:\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") } -} -func TestAddAuto_MultipleNewTTYSelectsOne(t *testing.T) { - newAddHarness(t) + resetAddFlags() 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() - restoreEnv := envextract.SetLookupForTest(addEnvUniverse(map[string]string{ - "OPENAI_BASE_URL": "https://env-select.example/v1", - "OPENAI_API_KEY": "sk-env-select-1234", + addYesFlag = true + restoreEnv2 := envextract.SetLookupForTest(addEnvUniverse(map[string]string{ + "ANTHROPIC_BASE_URL": "https://dry.example/v1", + "ANTHROPIC_AUTH_TOKEN": "sk-dry-auto-1234", })) - defer restoreEnv() - restoreTTY := SetIsTerminalForTest(func(*os.File) bool { return true }) - defer restoreTTY() - restoreStdin := withOSStdin(t, "2\n") - defer restoreStdin() - - stdout, _, err := runAddInner(t, "picked") + defer restoreEnv2() + stdout, _, err = runAddInner(t) if err != nil { - t.Fatalf("runAdd --auto TTY pick: %v\nstdout=%s", err, stdout) + t.Fatalf("runAdd --auto --yes: %v\nstdout=%s", err, stdout) } - if !strings.Contains(stdout, "Select credential [1-2]:") { - t.Fatalf("stdout missing selection prompt:\n%s", stdout) + if !strings.Contains(stdout, "created profiles:") || !strings.Contains(stdout, "dry-example") { + t.Fatalf("stdout missing created dry-example:\n%s", stdout) } - 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 strings.Contains(stdout, "sk-env-select-1234") || strings.Contains(stdout, "sk-clip-select-1234") { - t.Fatalf("stdout leaked selected secret:\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_DedupsSameCredentialWithinSweep(t *testing.T) { - newAddHarness(t) +func TestAddAuto_NameCollisionGetsNumericSuffix(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() - restoreEnv := envextract.SetLookupForTest(addEnvUniverse(map[string]string{ - "ANTHROPIC_BASE_URL": "https://API.Anthropic.com", - "ANTHROPIC_AUTH_TOKEN": "sk-same-auto-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, "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 codex suffix: %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, "codex-2") { + t.Fatalf("stdout missing suffixed name:\n%s", stdout) } + assertProfileNames(t, h, []string{"codex", "codex-2"}) } -func TestAddAuto_NoClipboardToolDoesNotAbortEnv(t *testing.T) { +func TestAddAuto_JSONDryRunListsAllProfilesRedacted(t *testing.T) { newAddHarness(t) addAutoFlag = true addDryRunFlag = true - restoreClipboard := setAddAutoClipboardForTest(func() (string, bool, error) { - return "", false, os.ErrNotExist - }) - defer restoreClipboard() + addOutputFlag = "json" + 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://json.example/v1", + "ANTHROPIC_AUTH_TOKEN": "sk-json-auto-1234", })) defer restoreEnv() - stdout, _, err := runAddInner(t, "clipmissing") + stdout, _, err := runAddInner(t) if err != nil { - t.Fatalf("runAdd --auto no clipboard tool: %v\nstdout=%s", err, stdout) + t.Fatalf("runAdd --auto json dry-run: %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) + var out jsonAddAutoProfiles + if err := json.Unmarshal([]byte(stdout), &out); err != nil { + t.Fatalf("json.Unmarshal(%q): %v", stdout, err) } - if !strings.Contains(stdout, "environment: NEW") { - t.Fatalf("stdout missing env candidate after clipboard skip:\n%s", stdout) + if out.Action != "dry-run" || len(out.Profiles) != 1 { + t.Fatalf("unexpected json auto dry-run: %+v", out) + } + if out.Profiles[0].Name != "json-example" { + t.Fatalf("profile name = %q, want json-example", out.Profiles[0].Name) + } + if strings.Contains(stdout, "sk-json-auto-1234") { + t.Fatalf("stdout leaked key:\n%s", stdout) } } -func TestAddAuto_MutuallyExclusiveWithOtherInputSources(t *testing.T) { - newAddHarness(t) - addAutoFlag = true - addFromEnvFlag = true +func stubAddAutoNoClipboard(t *testing.T) { + t.Helper() + restoreClipboard := setAddAutoClipboardForTest(func() (string, bool, error) { + return "", false, os.ErrNotExist + }) + t.Cleanup(restoreClipboard) +} - _, _, err := runAddInner(t, "exclusive") - if err == nil { - t.Fatalf("runAdd --auto --from-env unexpectedly succeeded") - } - if !strings.Contains(err.Error(), "choose only one add input source") { - t.Fatalf("error missing mutual exclusion style: %v", err) - } - if !strings.Contains(err.Error(), "--auto") { - t.Fatalf("error missing --auto in source list: %v", err) - } +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 +337,33 @@ 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() - old := os.Stdin - r, w, err := os.Pipe() + 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 assertProfileNames(t *testing.T, h *addHarness, want []string) { + t.Helper() + gotProfiles, err := h.store.LoadAllProfiles() if err != nil { - t.Fatalf("os.Pipe: %v", err) + t.Fatalf("LoadAllProfiles: %v", err) } - if _, err := w.WriteString(input); err != nil { - t.Fatalf("write stdin pipe: %v", err) + got := make(map[string]struct{}, len(gotProfiles)) + for _, profile := range gotProfiles { + got[profile.Name] = struct{}{} } - if err := w.Close(); err != nil { - t.Fatalf("close stdin writer: %v", err) + if len(got) != len(want) { + t.Fatalf("profile count = %d, want %d; got=%v", len(got), len(want), got) } - os.Stdin = r - return func() { - os.Stdin = old - _ = r.Close() + 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/quickstart.md b/docs/quickstart.md index 78b1ce8..205c664 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 `(base_url, api_key)` credentials, skips anything already recorded, and creates one auto-named 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 ask for confirmation before writing; 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. From 6f610f69c6e1fa785a3398359e825118893d7b5e Mon Sep 17 00:00:00 2001 From: imneov Date: Wed, 8 Jul 2026 08:08:33 +0000 Subject: [PATCH 3/3] fix add auto review findings --- README.md | 4 +- cmd/add.go | 4 +- cmd/add_auto.go | 425 +++++++++++++++++++++++--- cmd/add_auto_test.go | 295 +++++++++++++++++- docs/plan/stories/E13-S7.md | 22 +- docs/quickstart.md | 4 +- internal/adapter/claudecode/import.go | 7 + internal/adapter/codex/readers.go | 7 + 8 files changed, 717 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 8a6a6e2..53625d3 100644 --- a/README.md +++ b/README.md @@ -152,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` 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 `(base_url, api_key)` credentials, skips credentials already recorded, and creates one auto-named profile per remaining credential. +- `--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, 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 ask for confirmation before writing, 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. +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 f8539f0..8879c07 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -191,6 +191,8 @@ EXAMPLES cat provider.txt | claudecm add work --from-text - # 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 @@ -242,7 +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 confirmation for --auto") + 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)") diff --git a/cmd/add_auto.go b/cmd/add_auto.go index 1c23440..a4d6ea5 100644 --- a/cmd/add_auto.go +++ b/cmd/add_auto.go @@ -53,10 +53,16 @@ type addAutoSourceResult struct { type addAutoClipboardReader func() (string, bool, error) +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, ) error { results := sweepAddAutoSources(context.Background(), resv, readClipboardText) @@ -78,6 +84,8 @@ func runAddAuto( 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 nil } @@ -87,21 +95,28 @@ func runAddAuto( return err } assignAddAutoProfileNames(newCandidates, existingNames) - profiles := buildAddAutoProfiles(newCandidates, nowFn().UTC()) if addDryRunFlag { - return renderAddAutoDryRun(cmd.OutOrStdout(), format, profiles) + 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 := renderAddAutoProfilesJSON(cmd.OutOrStdout(), "confirm-required", profiles); err != nil { + } 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 + } + } + profiles := buildAddAutoProfiles(newCandidates, nowFn().UTC()) + if !addYesFlag { if format == addOutputText { fmt.Fprintln(cmd.OutOrStdout(), "profiles to create:") renderAddAutoProfileTextList(cmd.OutOrStdout(), profiles) @@ -115,6 +130,7 @@ func runAddAuto( } } + 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) @@ -125,10 +141,11 @@ func runAddAuto( return fmt.Errorf("derived profile name %q already exists", profile.Name) } if err := store.SaveProfile(profile); err != nil { - return fmt.Errorf("failed to save profile %q: %w", profile.Name, err) + return addAutoPartialCreateError(created, profile.Name, err) } + created = append(created, profile) } - return renderAddAutoCreated(cmd.OutOrStdout(), format, profiles) + return renderAddAutoCreated(cmd.OutOrStdout(), format, results, created) } func sweepAddAutoSources(ctx context.Context, resv *storage.Resolver, clipboard addAutoClipboardReader) []addAutoSourceResult { @@ -330,6 +347,12 @@ func lenientReadClaudeCode(ctx context.Context, resv *storage.Resolver) (config. 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) { @@ -395,7 +418,7 @@ func lenientReadCodex(ctx context.Context, resv *storage.Resolver) (config.CoreC var overlay config.ToolOverlay authPath := codexadapter.AuthPath(resv) - authRoot, authNote := lenientReadJSONMap(authPath, "auth.json") + authRoot, authNote := lenientReadJSONMap(authPath, "auth.json", resv, codexadapter.VerifyReadTargetInHome) if authNote != "" { notes = append(notes, authNote) } @@ -417,7 +440,7 @@ func lenientReadCodex(ctx context.Context, resv *storage.Resolver) (config.CoreC } configPath := codexadapter.ConfigPath(resv) - doc, configNote := lenientReadCodexConfig(configPath) + doc, configNote := lenientReadCodexConfig(configPath, resv) if configNote != "" { notes = append(notes, configNote) } @@ -434,7 +457,15 @@ func lenientReadCodex(ctx context.Context, resv *storage.Resolver) (config.CoreC return core, overlay, strings.Join(notes, "; ") } -func lenientReadJSONMap(path, label string) (map[string]any, string) { +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) { @@ -455,7 +486,13 @@ func lenientReadJSONMap(path, label string) (map[string]any, string) { return root, "" } -func lenientReadCodexConfig(path string) (*codextoml.Doc, string) { +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) { @@ -468,11 +505,127 @@ func lenientReadCodexConfig(path string) (*codextoml.Doc, string) { } doc, err := codextoml.Load(data) if err != nil { - return nil, "config.toml skipped: parse failed: " + err.Error() + 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 { @@ -488,7 +641,7 @@ func putOverlayRaw(overlay *config.ToolOverlay, key string, value any) { overlay.Raw[key] = value } -func markExistingAddAutoCandidates(results []addAutoSourceResult, store *storage.FileStorage) error { +func markExistingAddAutoCandidates(results []addAutoSourceResult, store addAutoProfileStore) error { if store == nil { return nil } @@ -496,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] } } } @@ -521,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 + } } } } @@ -615,6 +805,13 @@ type jsonAddAutoCandidate struct { 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 { @@ -628,19 +825,33 @@ func addAutoCandidateStatus(candidate addAutoCandidate) string { } func newAddAutoCandidates(results []addAutoSourceResult) ([]addAutoCandidate, error) { - byKey := map[string]int{} + 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 } - key := addAutoDedupKey(candidate.Core.BaseURL, candidate.Core.APIKey) - if key == "" { + apiKey := strings.TrimSpace(candidate.Core.APIKey) + if apiKey == "" { continue } - if idx, ok := byKey[key]; ok { - out[idx] = mergeAddAutoCandidate(out[idx], candidate) + 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) @@ -648,13 +859,33 @@ func newAddAutoCandidates(results []addAutoSourceResult) ([]addAutoCandidate, er if candidate.NameBase == "" { candidate.NameBase = addAutoBaseNameFor(candidate.Source, candidate.Core.BaseURL) } - byKey[key] = len(out) + indexAddAutoCandidate(candidate, len(out), exactByKey, anyByAPIKey, emptyByAPIKey) out = append(out, candidate) } } 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 + } + return + } + if _, exists := exactByKey[base+"\x00"+apiKey]; !exists { + exactByKey[base+"\x00"+apiKey] = idx + } +} + func mergeAddAutoCandidate(base, next addAutoCandidate) addAutoCandidate { if strings.TrimSpace(base.Core.BaseURL) == "" { base.Core.BaseURL = next.Core.BaseURL @@ -726,7 +957,7 @@ func addAutoNamePriority(name string) int { } } -func loadAddAutoProfileNames(store *storage.FileStorage) (map[string]struct{}, error) { +func loadAddAutoProfileNames(store addAutoProfileStore) (map[string]struct{}, error) { names := map[string]struct{}{} if store == nil { return names, nil @@ -765,6 +996,42 @@ func assignAddAutoProfileNames(candidates []addAutoCandidate, used map[string]st } } +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 { @@ -875,9 +1142,9 @@ func buildAddAutoProfiles(candidates []addAutoCandidate, now time.Time) []*confi return profiles } -func renderAddAutoDryRun(w io.Writer, format addOutputFormat, profiles []*config.Profile) error { +func renderAddAutoDryRun(w io.Writer, format addOutputFormat, results []addAutoSourceResult, profiles []*config.Profile) error { if format == addOutputJSON { - return renderAddAutoProfilesJSON(w, "dry-run", profiles) + return renderAddAutoResultJSON(w, "dry-run", results, profiles, nil) } fmt.Fprintln(w, "--- dry-run: auto profiles (not written) ---") for _, profile := range profiles { @@ -888,9 +1155,9 @@ func renderAddAutoDryRun(w io.Writer, format addOutputFormat, profiles []*config return nil } -func renderAddAutoCreated(w io.Writer, format addOutputFormat, profiles []*config.Profile) error { +func renderAddAutoCreated(w io.Writer, format addOutputFormat, results []addAutoSourceResult, profiles []*config.Profile) error { if format == addOutputJSON { - return renderAddAutoProfilesJSON(w, "created", profiles) + return renderAddAutoResultJSON(w, "created", results, profiles, nil) } fmt.Fprintln(w, "created profiles:") for _, profile := range profiles { @@ -914,8 +1181,11 @@ func renderAddAutoProfileTextList(w io.Writer, profiles []*config.Profile) { } type jsonAddAutoProfiles struct { - Action string `json:"action"` - Profiles []jsonAddProfile `json:"profiles"` + 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 { @@ -929,6 +1199,89 @@ func renderAddAutoProfilesJSON(w io.Writer, action string, profiles []*config.Pr return writeAddJSON(w, out) } +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 != "" { + out.Reason = "already-recorded" + } else if candidate.DuplicateOf != "" { + out.Reason = "duplicate" + } + 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 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 { apiKey = strings.TrimSpace(apiKey) if apiKey == "" { diff --git a/cmd/add_auto_test.go b/cmd/add_auto_test.go index 280f8f8..641a180 100644 --- a/cmd/add_auto_test.go +++ b/cmd/add_auto_test.go @@ -4,13 +4,17 @@ 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_CodexAuthJSONSurvivesProjectsConfig(t *testing.T) { @@ -92,12 +96,141 @@ base_url = "https://api.openai.com/v1" if err != nil { t.Fatalf("runAdd --auto two profiles: %v\nstdout=%s", err, stdout) } - if !strings.Contains(stdout, "created profiles:") || !strings.Contains(stdout, "bray-neov-im") || !strings.Contains(stdout, "codex") { - t.Fatalf("stdout missing created profile names:\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_PartialSaveFailureReportsCreatedAndFailed(t *testing.T) { + h := newAddHarness(t) + addAutoFlag = true + addYesFlag = true + stubAddAutoEmptySources(t) + restoreEnv := envextract.SetLookupForTest(addEnvUniverse(map[string]string{ + "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-codex", + Core: config.CoreConfig{ + Provider: "openai-compat", + BaseURL: "https://api.openai.com/v1", + APIKey: "sk-empty-base-dedup-1234", + }, + }); err != nil { + t.Fatalf("SaveProfile existing-codex: %v", err) + } + writeAddAutoCodexAuth(t, h.home, `{"OPENAI_API_KEY":"sk-empty-base-dedup-1234"}`) + + stdout, _, err := runAddInner(t) + if err != nil { + t.Fatalf("runAdd --auto empty base dedup: %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 out.Action != "already-recorded" || len(out.Created) != 0 { + t.Fatalf("unexpected json action/created: %+v", out) + } + 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_CodexMalformedConfigStillExtractsBaseURL(t *testing.T) { + h := newAddHarness(t) + addAutoFlag = true + 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 @@ -292,6 +425,9 @@ func TestAddAuto_JSONDryRunListsAllProfilesRedacted(t *testing.T) { if out.Action != "dry-run" || len(out.Profiles) != 1 { t.Fatalf("unexpected json auto dry-run: %+v", out) } + if len(out.Discovery) == 0 || len(out.Created) != 1 || out.Created[0] != "json-example" { + t.Fatalf("json missing discovery/created: %+v", out) + } if out.Profiles[0].Name != "json-example" { t.Fatalf("profile name = %q, want json-example", out.Profiles[0].Name) } @@ -300,6 +436,129 @@ func TestAddAuto_JSONDryRunListsAllProfilesRedacted(t *testing.T) { } } +func TestAddAuto_JSONCreatedListsDiscoveryCreatedAndSkippedRedacted(t *testing.T) { + h := newAddHarness(t) + addAutoFlag = true + addYesFlag = true + addOutputFlag = "json" + stubAddAutoNoClipboard(t) + restoreEnv := envextract.SetLookupForTest(addEnvUniverse(map[string]string{ + "ANTHROPIC_BASE_URL": "https://json-create.example/v1", + "ANTHROPIC_AUTH_TOKEN": "sk-json-create-1234", + })) + defer restoreEnv() + 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) + if err != nil { + 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 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 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-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_InteractiveNamingAcceptsDefault(t *testing.T) { + h := newAddHarness(t) + addAutoFlag = true + stubAddAutoNoClipboard(t) + restoreEnv := envextract.SetLookupForTest(addEnvUniverse(map[string]string{ + "ANTHROPIC_BASE_URL": "https://interactive-default.example/v1", + "ANTHROPIC_AUTH_TOKEN": "sk-interactive-default-1234", + })) + defer restoreEnv() + restoreTTY := SetIsTerminalForTest(func(*os.File) bool { return true }) + defer restoreTTY() + restoreStdin := pipeAddAutoStdin(t, "\ny\n") + defer restoreStdin() + + stdout, _, err := runAddInner(t) + if err != nil { + t.Fatalf("runAdd --auto interactive default: %v\nstdout=%s", err, 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_InteractiveNamingUsesCustomName(t *testing.T) { + h := newAddHarness(t) + addAutoFlag = true + stubAddAutoNoClipboard(t) + restoreEnv := envextract.SetLookupForTest(addEnvUniverse(map[string]string{ + "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) + if err != nil { + t.Fatalf("runAdd --auto interactive custom: %v\nstdout=%s", err, 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_YesUsesDerivedNameWithoutPrompt(t *testing.T) { + h := newAddHarness(t) + addAutoFlag = 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() + + stdout, _, err := runAddInner(t) + if err != nil { + t.Fatalf("runAdd --auto --yes derived: %v\nstdout=%s", err, stdout) + } + if strings.Contains(stdout, "Save profile for") { + t.Fatalf("--yes unexpectedly prompted:\n%s", stdout) + } + 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) { @@ -348,6 +607,38 @@ func writeAddAutoClaudeSettings(t *testing.T, home, body string) { } } +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() + 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 pipe writer: %v", err) + } + return func() { + os.Stdin = orig + _ = r.Close() + } +} + func assertProfileNames(t *testing.T, h *addHarness, want []string) { t.Helper() gotProfiles, err := h.store.LoadAllProfiles() diff --git a/docs/plan/stories/E13-S7.md b/docs/plan/stories/E13-S7.md index 8a2e550..8363b7e 100644 --- a/docs/plan/stories/E13-S7.md +++ b/docs/plan/stories/E13-S7.md @@ -26,16 +26,22 @@ everything, instead of forcing me to name a single profile or refusing because i - 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; each credential is then deduped against existing profiles - (`LoadAllProfiles`) and skipped if already recorded (secrets redacted in all output). -- **Auto-naming:** each NEW credential gets a derived 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. Name collisions with existing profiles get a numeric suffix - (`-2`, `-3`). Derived names pass `ValidateProfileName`. + 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, list the proposed profiles (redacted) and - confirm before writing; non-interactively require `--yes` (mirrors `import`), else refuse. + 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. diff --git a/docs/quickstart.md b/docs/quickstart.md index 205c664..efa469b 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -91,14 +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` 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 `(base_url, api_key)` credentials, skips anything already recorded, and creates one auto-named 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. +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 --auto --dry-run claudecm add --auto --yes ``` -Expected: a redacted discovery list and a preview or creation report for every new profile. Interactive terminals ask for confirmation before writing; non-interactive runs require `--yes`. +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) {