diff --git a/README.md b/README.md index 2303f44..07ae941 100644 --- a/README.md +++ b/README.md @@ -71,6 +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 + # Optional AI parse is opt-in per invocation and requires an interactive TTY. # claudecm strips secret-shaped tokens locally, shows the desensitized payload # for confirmation, then sends one Anthropic-compatible messages request. @@ -147,8 +151,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. -These paths are local-first. 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 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. ## Deeper reading diff --git a/cmd/add.go b/cmd/add.go index a6289cc..2ec774e 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -93,6 +93,7 @@ var ( addFromEnvFlag bool addFromFileFlag string addFromTextFlag string + addAutoFlag bool addAIFlag bool addAIProfileFlag string addListPresetsFlag bool @@ -188,6 +189,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 + # 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 @@ -222,6 +226,7 @@ func init() { addCmd.Flags().BoolVar(&addFromEnvFlag, "from-env", false, "Build the profile draft from Claude Code / Codex environment variables") addCmd.Flags().StringVar(&addFromFileFlag, "from-file", "", "Build the profile draft from a dotenv, shell, JSON, YAML, or TOML file") addCmd.Flags().StringVar(&addFromTextFlag, "from-text", "", "Build the profile draft from pasted text locally; use '-' to read stdin") + addCmd.Flags().BoolVarP(&addAutoFlag, "auto", "a", false, "Sweep clipboard, environment, and known tool configs; skip already-recorded credentials") addCmd.Flags().BoolVar(&addAIFlag, "ai", false, "With --from-text, opt in to one interactive, reviewed, secret-free Anthropic-compatible LLM parse") addCmd.Flags().StringVar(&addAIProfileFlag, "ai-profile", "", "Profile whose Anthropic-compatible credentials are borrowed for --ai parsing") addCmd.Flags().BoolVar(&addListPresetsFlag, "list-presets", false, "List built-in provider presets and exit") @@ -268,10 +273,10 @@ func runAdd(cmd *cobra.Command, args []string) error { if err != nil { return err } - if err := validateAddInputSources(hasPreset); err != nil { + if err := validateAddInputSources(hasPreset, baseURLFlagSet, apiKeyFlagSet); err != nil { return err } - fromInputSource := addFromEnvFlag || strings.TrimSpace(addFromFileFlag) != "" || strings.TrimSpace(addFromTextFlag) != "" + fromInputSource := addAutoFlag || addFromEnvFlag || strings.TrimSpace(addFromFileFlag) != "" || strings.TrimSpace(addFromTextFlag) != "" if fromInputSource { providerFlagSet = flagWasExplicit(cmd, "provider", false) baseURLFlagSet = flagWasExplicit(cmd, "base-url", false) @@ -305,6 +310,23 @@ 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 { @@ -445,7 +467,7 @@ func resolveAddPreset(raw string) (presets.Preset, bool, error) { return p, true, nil } -func validateAddInputSources(hasPreset bool) error { +func validateAddInputSources(hasPreset, baseURLFlagSet, apiKeyFlagSet bool) error { fromFileSet := strings.TrimSpace(addFromFileFlag) != "" fromTextSet := strings.TrimSpace(addFromTextFlag) != "" count := 0 @@ -461,8 +483,14 @@ func validateAddInputSources(hasPreset bool) error { if fromTextSet { count++ } + if addAutoFlag { + count++ + } if count > 1 { - return fmt.Errorf("choose only one add input source: --preset, --from-env, --from-file, or --from-text") + return fmt.Errorf("choose only one add input source: --preset, --from-env, --from-file, --from-text, or --auto") + } + if addAutoFlag && (baseURLFlagSet || apiKeyFlagSet) { + return fmt.Errorf("choose only one add identity source: --auto, --base-url, or --api-key") } if addAIFlag && !fromTextSet { return fmt.Errorf("--ai requires --from-text") diff --git a/cmd/add_auto.go b/cmd/add_auto.go new file mode 100644 index 0000000..511c560 --- /dev/null +++ b/cmd/add_auto.go @@ -0,0 +1,444 @@ +package cmd + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "net" + "net/url" + "os" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/a2d2-dev/claudecm/internal/adapter" + "github.com/a2d2-dev/claudecm/internal/blobparse" + "github.com/a2d2-dev/claudecm/internal/config" + "github.com/a2d2-dev/claudecm/internal/storage" +) + +type addAutoSource string + +const ( + addAutoSourceClipboard addAutoSource = "clipboard" + addAutoSourceEnv addAutoSource = "environment" + addAutoSourceClaudeCode addAutoSource = "~/.claude/settings.json" + addAutoSourceCodex addAutoSource = "~/.codex/auth.json + config.toml" +) + +type addAutoCandidate struct { + Source string + Core config.CoreConfig + Tools map[config.ToolID]config.ToolOverlay + + AlreadyProfile string + DuplicateOf string +} + +type addAutoSourceResult struct { + Source string + Candidates []addAutoCandidate + Note string +} + +type addAutoClipboardReader func() (string, bool, error) + +func profileDraftFromAuto( + cmd *cobra.Command, + resv *storage.Resolver, + store *storage.FileStorage, + format addOutputFormat, +) (config.CoreConfig, map[config.ToolID]config.ToolOverlay, bool, error) { + results := sweepAddAutoSources(context.Background(), resv, readClipboardText) + if err := markExistingAddAutoCandidates(results, store); err != nil { + return config.CoreConfig{}, nil, false, err + } + markDuplicateAddAutoCandidates(results) + if err := renderAddAutoDiscovery(cmd.OutOrStdout(), format, results); err != nil { + return config.CoreConfig{}, nil, false, err + } + + var newCandidates []addAutoCandidate + for _, result := range results { + for _, candidate := range result.Candidates { + if candidate.AlreadyProfile == "" && candidate.DuplicateOf == "" { + newCandidates = append(newCandidates, candidate) + } + } + } + + 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)) + 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 chosen.Core, chosen.Tools, false, nil + } +} + +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)) + return results +} + +func addAutoSourceOrder() []addAutoSource { + return []addAutoSource{ + addAutoSourceClipboard, + addAutoSourceEnv, + addAutoSourceClaudeCode, + addAutoSourceCodex, + } +} + +func scanAddAutoClipboard(reader addAutoClipboardReader) addAutoSourceResult { + result := addAutoSourceResult{Source: string(addAutoSourceClipboard)} + text, ok, err := reader() + if err != nil { + result.Note = "skipped: " + err.Error() + return result + } + if !ok || strings.TrimSpace(text) == "" { + result.Note = "skipped: no clipboard text available" + return result + } + parsed := normalizeParsedProvider(blobparse.Parse(text).Core) + if strings.TrimSpace(parsed.APIKey) == "" { + result.Note = "no API key found" + return result + } + result.Candidates = []addAutoCandidate{{ + Source: 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) == "" { + result.Note = "no API key found" + return result + } + result.Candidates = []addAutoCandidate{{ + Source: result.Source, + Core: core, + Tools: tools, + }} + return result +} + +func coreFromAddAutoAdapterOverlay(core config.CoreConfig, overlay config.ToolOverlay) config.CoreConfig { + providerRaw := firstStringRawValue(overlay.Raw, "model_provider") + if strings.TrimSpace(core.BaseURL) == "" { + if providerRaw != "" { + core.BaseURL = firstStringRawValue(overlay.Raw, "model_providers."+providerRaw+".base_url") + } + if strings.TrimSpace(core.BaseURL) == "" { + core.BaseURL = firstStringRawValue(overlay.Raw, "model_providers.openai.base_url") + } + } + if strings.TrimSpace(core.Model) == "" { + core.Model = firstStringRawValue(overlay.Raw, "model") + } + if strings.TrimSpace(core.Provider) == "" { + core.Provider = normalizeParsedProvider(config.CoreConfig{ + Provider: providerRaw, + }).Provider + } + return core +} + +func firstStringRawValue(raw map[string]any, key string) string { + if len(raw) == 0 { + return "" + } + if v, ok := raw[key]; ok { + return strings.TrimSpace(fmt.Sprint(v)) + } + 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 + } + core, overlay, err := a.Import(ctx, resv) + if err != nil { + result.Note = "skipped: " + err.Error() + return result + } + core = coreFromAddAutoAdapterOverlay(core, overlay) + if strings.TrimSpace(core.APIKey) == "" { + result.Note = "no API key found" + return result + } + tools := map[config.ToolID]config.ToolOverlay{} + if !isEmptyOverlay(overlay) { + tools[tool] = overlay + } + result.Candidates = []addAutoCandidate{{ + Source: result.Source, + Core: normalizeParsedProvider(core), + Tools: tools, + }} + return result +} + +func markExistingAddAutoCandidates(results []addAutoSourceResult, store *storage.FileStorage) error { + if store == nil { + return nil + } + profiles, err := store.LoadAllProfiles() + if err != nil { + return fmt.Errorf("load existing profiles for auto dedup: %w", err) + } + index := 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 == "" { + continue + } + if _, exists := index[key]; !exists { + index[key] = 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 != "" { + candidate.AlreadyProfile = name + } + } + } + return nil +} + +func markDuplicateAddAutoCandidates(results []addAutoSourceResult) { + seen := 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 == "" { + continue + } + if firstSource := seen[key]; firstSource != "" { + candidate.DuplicateOf = firstSource + continue + } + seen[key] = candidate.Source + } + } +} + +func renderAddAutoDiscovery(w io.Writer, format addOutputFormat, results []addAutoSourceResult) error { + if format == addOutputJSON { + return nil + } + fmt.Fprintln(w, "auto-discovery results:") + for _, result := range results { + if len(result.Candidates) == 0 { + note := result.Note + if note == "" { + note = "no API key found" + } + fmt.Fprintf(w, " - %s: %s\n", result.Source, note) + continue + } + for _, candidate := range result.Candidates { + status := "NEW" + if candidate.AlreadyProfile != "" { + status = fmt.Sprintf("already recorded as %s", candidate.AlreadyProfile) + } else if candidate.DuplicateOf != "" { + status = fmt.Sprintf("duplicate of %s", candidate.DuplicateOf) + } + fmt.Fprintf(w, " - %s: %s base_url=%s api_key=%s", + candidate.Source, + status, + displayAddAutoValue(candidate.Core.BaseURL), + redactedValueDisplay("api_key", candidate.Core.APIKey), + ) + if strings.TrimSpace(candidate.Core.Model) != "" { + fmt.Fprintf(w, " model=%s", candidate.Core.Model) + } + 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 + } + 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 +} + +func countAddAutoCandidatesWithKey(results []addAutoSourceResult) int { + count := 0 + for _, result := range results { + for _, candidate := range result.Candidates { + if strings.TrimSpace(candidate.Core.APIKey) != "" { + count++ + } + } + } + return count +} + +func addAutoSourceList(results []addAutoSourceResult) string { + names := make([]string, 0, len(results)) + for _, result := range results { + names = append(names, result.Source) + } + return strings.Join(names, ", ") +} + +func displayAddAutoValue(value string) string { + if strings.TrimSpace(value) == "" { + return "" + } + 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, " - ") + } + 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) + } + fmt.Fprintln(w) + } +} + +type jsonAddAutoDisambiguation struct { + Action string `json:"action"` + Candidates []jsonAddAutoCandidate `json:"candidates"` +} + +type jsonAddAutoCandidate struct { + Source string `json:"source"` + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` + Status string `json:"status"` +} + +func renderAddAutoDisambiguationJSON(w io.Writer, candidates []addAutoCandidate) error { + out := jsonAddAutoDisambiguation{ + Action: "auto-disambiguation-required", + Candidates: make([]jsonAddAutoCandidate, 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), + }) + } + return writeAddJSON(w, out) +} + +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 addAutoDedupKey(baseURL, apiKey string) string { + apiKey = strings.TrimSpace(apiKey) + if apiKey == "" { + return "" + } + return normalizeAddAutoBaseURL(baseURL) + "\x00" + apiKey +} + +func normalizeAddAutoBaseURL(raw string) string { + value := strings.TrimSpace(raw) + value = strings.TrimRight(value, "/") + if value == "" { + return "" + } + u, err := url.Parse(value) + if err != nil || u.Host == "" { + return value + } + u.Scheme = strings.ToLower(u.Scheme) + host := strings.ToLower(u.Hostname()) + if port := u.Port(); port != "" { + if (u.Scheme != "https" || port != "443") && (u.Scheme != "http" || port != "80") { + host = net.JoinHostPort(host, port) + } + } + u.Host = host + return strings.TrimRight(u.String(), "/") +} diff --git a/cmd/add_auto_clipboard.go b/cmd/add_auto_clipboard.go new file mode 100644 index 0000000..1149992 --- /dev/null +++ b/cmd/add_auto_clipboard.go @@ -0,0 +1,7 @@ +//go:build !test + +package cmd + +func readClipboardText() (string, bool, error) { + return readClipboardTextWithOSCommands() +} diff --git a/cmd/add_auto_clipboard_exec.go b/cmd/add_auto_clipboard_exec.go new file mode 100644 index 0000000..ef48a30 --- /dev/null +++ b/cmd/add_auto_clipboard_exec.go @@ -0,0 +1,40 @@ +package cmd + +import ( + "fmt" + "os/exec" + "sort" + "strings" +) + +func readClipboardTextWithOSCommands() (string, bool, error) { + tools := []struct { + name string + args []string + }{ + {name: "pbpaste"}, + {name: "wl-paste"}, + {name: "xclip", args: []string{"-o", "-selection", "clipboard"}}, + {name: "xsel", args: []string{"-b"}}, + {name: "powershell", args: []string{"-NoProfile", "-Command", "Get-Clipboard"}}, + {name: "powershell.exe", args: []string{"-NoProfile", "-Command", "Get-Clipboard"}}, + } + var missing []string + for _, tool := range tools { + path, err := exec.LookPath(tool.name) + if err != nil { + missing = append(missing, tool.name) + continue + } + out, err := exec.Command(path, tool.args...).Output() + if err != nil { + return "", false, fmt.Errorf("%s failed: %w", tool.name, err) + } + if strings.TrimSpace(string(out)) == "" { + return "", false, nil + } + return string(out), true, nil + } + sort.Strings(missing) + return "", false, fmt.Errorf("no clipboard tool found (%s)", strings.Join(missing, ", ")) +} diff --git a/cmd/add_auto_clipboard_testhook.go b/cmd/add_auto_clipboard_testhook.go new file mode 100644 index 0000000..1cfe9b6 --- /dev/null +++ b/cmd/add_auto_clipboard_testhook.go @@ -0,0 +1,11 @@ +//go:build test + +package cmd + +var readClipboardText addAutoClipboardReader = readClipboardTextWithOSCommands + +func setAddAutoClipboardForTest(fn addAutoClipboardReader) func() { + prev := readClipboardText + readClipboardText = fn + return func() { readClipboardText = prev } +} diff --git a/cmd/add_auto_test.go b/cmd/add_auto_test.go new file mode 100644 index 0000000..9b1fe09 --- /dev/null +++ b/cmd/add_auto_test.go @@ -0,0 +1,495 @@ +//go:build test + +package cmd + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/a2d2-dev/claudecm/internal/config" + "github.com/a2d2-dev/claudecm/internal/envextract" +) + +func TestAddAuto_EnvNewCandidateDryRun(t *testing.T) { + 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{ + "ANTHROPIC_BASE_URL": "https://api.anthropic.com", + "ANTHROPIC_AUTH_TOKEN": "sk-env-auto-123456", + "ANTHROPIC_MODEL": "claude-opus-4-5", + })) + defer restoreEnv() + + stdout, _, err := runAddInner(t, "autoenv") + 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) + } + if !strings.Contains(stdout, "sk-e***3456") { + t.Fatalf("stdout missing redacted key:\n%s", stdout) + } +} + +func TestAddAuto_CodexAuthJSONCandidate(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{})) + defer restoreEnv() + writeAddAutoCodexConfig(t, h.home, `model = "gpt-5" +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"}`) + + stdout, _, err := runAddInner(t, "autocodex") + if err != nil { + t.Fatalf("runAdd --auto codex: %v\nstdout=%s", err, stdout) + } + if !strings.Contains(stdout, "~/.codex/auth.json + config.toml: NEW") { + t.Fatalf("stdout missing codex NEW marker:\n%s", stdout) + } + if strings.Contains(stdout, "sk-codex-auto-1234") { + t.Fatalf("stdout leaked codex key:\n%s", stdout) + } +} + +func TestAddAuto_DedupAlreadyRecorded(t *testing.T) { + h := 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://API.Anthropic.com/", + "ANTHROPIC_AUTH_TOKEN": "sk-existing-auto-1234", + })) + defer restoreEnv() + if err := h.store.SaveProfile(&config.Profile{ + SchemaVersion: config.CurrentProfileSchemaVersion, + Name: "existing", + Core: config.CoreConfig{ + Provider: "anthropic", + BaseURL: "https://api.anthropic.com", + APIKey: "sk-existing-auto-1234", + }, + }); err != nil { + t.Fatalf("SaveProfile existing: %v", err) + } + + stdout, _, err := runAddInner(t, "dup") + if err != nil { + t.Fatalf("runAdd --auto dedup should exit 0: %v\nstdout=%s", err, stdout) + } + if !strings.Contains(stdout, "already recorded as existing") { + t.Fatalf("stdout missing already-recorded marker:\n%s", stdout) + } + if !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") + } +} + +func TestAddAuto_DedupAlreadyRecordedDefaultHTTPSPort(t *testing.T) { + h := 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) + } + 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") + } +} + +func TestAddAuto_NoKeyRefusesWithSweptSources(t *testing.T) { + newAddHarness(t) + addAutoFlag = true + restoreClipboard := setAddAutoClipboardForTest(func() (string, bool, error) { + return "", false, os.ErrNotExist + }) + defer restoreClipboard() + restoreEnv := envextract.SetLookupForTest(addEnvUniverse(map[string]string{})) + defer restoreEnv() + + stdout, _, err := runAddInner(t, "nokey") + 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"} { + 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) { + h := newAddHarness(t) + addAutoFlag = true + addModelFlag = "foo" + restoreClipboard := setAddAutoClipboardForTest(func() (string, bool, error) { + return "", false, os.ErrNotExist + }) + defer restoreClipboard() + 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", + })) + defer restoreEnv() + restoreTTY := SetIsTerminalForTest(func(*os.File) bool { return false }) + defer restoreTTY() + + stdout, _, err := runAddInner(t, "multi") + if err == nil { + t.Fatalf("runAdd --auto multi non-tty unexpectedly succeeded\nstdout=%s", stdout) + } + if !strings.Contains(err.Error(), "multiple new credentials discovered") { + t.Fatalf("error missing multi-candidate refusal: %v", err) + } + if !strings.Contains(stdout, "multiple new credentials discovered:") { + t.Fatalf("stdout missing redacted list:\n%s", stdout) + } + 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) + } + } +} + +func TestAddAuto_MultipleNewNonTTYJSONOutputsRedactedCandidates(t *testing.T) { + 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() + restoreEnv := envextract.SetLookupForTest(addEnvUniverse(map[string]string{ + "OPENAI_BASE_URL": "https://env-json.example/v1", + "OPENAI_API_KEY": "sk-env-json-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) + } + } + 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, "sk-c***1234") || !strings.Contains(stdout, "sk-e***1234") { + t.Fatalf("stdout missing redacted keys:\n%s", stdout) + } +} + +func TestAddAuto_MultipleNewTTYSelectsOne(t *testing.T) { + newAddHarness(t) + addAutoFlag = true + addDryRunFlag = true + restoreClipboard := setAddAutoClipboardForTest(func() (string, bool, error) { + return "ANTHROPIC_BASE_URL=https://clip.example ANTHROPIC_AUTH_TOKEN=sk-clip-select-1234", true, nil + }) + defer restoreClipboard() + restoreEnv := envextract.SetLookupForTest(addEnvUniverse(map[string]string{ + "OPENAI_BASE_URL": "https://env-select.example/v1", + "OPENAI_API_KEY": "sk-env-select-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") + if err != nil { + t.Fatalf("runAdd --auto TTY pick: %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, "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) + } +} + +func TestAddAuto_DedupsSameCredentialWithinSweep(t *testing.T) { + 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() + restoreTTY := SetIsTerminalForTest(func(*os.File) bool { return false }) + defer restoreTTY() + + stdout, _, err := runAddInner(t, "same") + if err != nil { + t.Fatalf("runAdd --auto same credential should not be ambiguous: %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) + } +} + +func TestAddAuto_NoClipboardToolDoesNotAbortEnv(t *testing.T) { + 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{ + "ANTHROPIC_BASE_URL": "https://api.anthropic.com", + "ANTHROPIC_AUTH_TOKEN": "sk-env-after-clip-1234", + })) + defer restoreEnv() + + stdout, _, err := runAddInner(t, "clipmissing") + if err != nil { + t.Fatalf("runAdd --auto no clipboard tool: %v\nstdout=%s", err, stdout) + } + if !strings.Contains(stdout, "clipboard: skipped: file does not exist") { + t.Fatalf("stdout missing clipboard skip reason:\n%s", stdout) + } + if !strings.Contains(stdout, "environment: NEW") { + t.Fatalf("stdout missing env candidate after clipboard skip:\n%s", stdout) + } +} + +func TestAddAuto_MutuallyExclusiveWithOtherInputSources(t *testing.T) { + newAddHarness(t) + addAutoFlag = true + addFromEnvFlag = true + + _, _, 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 writeAddAutoCodexConfig(t *testing.T, home, body string) { + t.Helper() + dir := filepath.Join(home, ".codex") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatalf("mkdir .codex: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "config.toml"), []byte(body), 0o600); err != nil { + t.Fatalf("write config.toml: %v", err) + } +} + +func writeAddAutoCodexAuth(t *testing.T, home, body string) { + t.Helper() + dir := filepath.Join(home, ".codex") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatalf("mkdir .codex: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "auth.json"), []byte(body), 0o600); err != nil { + t.Fatalf("write auth.json: %v", err) + } +} + +func withOSStdin(t *testing.T, input string) func() { + t.Helper() + old := os.Stdin + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + if _, err := w.WriteString(input); err != nil { + t.Fatalf("write stdin pipe: %v", err) + } + if err := w.Close(); err != nil { + t.Fatalf("close stdin writer: %v", err) + } + os.Stdin = r + return func() { + os.Stdin = old + _ = r.Close() + } +} diff --git a/cmd/add_test.go b/cmd/add_test.go index 5c80840..c737488 100644 --- a/cmd/add_test.go +++ b/cmd/add_test.go @@ -34,6 +34,11 @@ import ( "github.com/a2d2-dev/claudecm/internal/storage" ) +var ( + addBaseURLFlagExplicit bool + addAPIKeyFlagExplicit bool +) + // resetAddFlags restores the package-level flag vars to their init() // defaults. func resetAddFlags() { @@ -41,6 +46,8 @@ func resetAddFlags() { addProviderFlag = addProviderDefault addBaseURLFlag = "" addAPIKeyFlag = "" + addBaseURLFlagExplicit = false + addAPIKeyFlagExplicit = false addModelFlag = "" addSmallFastModelFlag = "" addSetFlag = nil @@ -48,6 +55,7 @@ func resetAddFlags() { addFromEnvFlag = false addFromFileFlag = "" addFromTextFlag = "" + addAutoFlag = false addAIFlag = false addAIProfileFlag = "" addListPresetsFlag = false @@ -112,11 +120,11 @@ func bindSyntheticAddFlags(cmd *cobra.Command) { _ = cmd.Flags().Set("provider", addProviderFlag) } cmd.Flags().String("base-url", addBaseURLFlag, "") - if addBaseURLFlag != "" { + if addBaseURLFlag != "" || addBaseURLFlagExplicit { _ = cmd.Flags().Set("base-url", addBaseURLFlag) } cmd.Flags().String("api-key", addAPIKeyFlag, "") - if addAPIKeyFlag != "" { + if addAPIKeyFlag != "" || addAPIKeyFlagExplicit { _ = cmd.Flags().Set("api-key", addAPIKeyFlag) } cmd.Flags().String("model", addModelFlag, "") @@ -127,6 +135,10 @@ func bindSyntheticAddFlags(cmd *cobra.Command) { if addSmallFastModelFlag != "" { _ = cmd.Flags().Set("small-fast-model", addSmallFastModelFlag) } + cmd.Flags().Bool("auto", addAutoFlag, "") + if addAutoFlag { + _ = cmd.Flags().Set("auto", "true") + } } // --------------------------------------------------------------------------- diff --git a/docs/decisions/0003-smart-add-scope.md b/docs/decisions/0003-smart-add-scope.md index 6ec28df..ef01f8f 100644 --- a/docs/decisions/0003-smart-add-scope.md +++ b/docs/decisions/0003-smart-add-scope.md @@ -47,6 +47,12 @@ 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. + 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), overwrite guard, and `SaveProfile` invariants. No E13 path writes a Claude Code or Codex tool file @@ -66,6 +72,10 @@ directly, and no path auto-activates the new profile (activation stays `switch`) plainly. 3. **No new command.** E13 adds flags/inputs to the existing `add` command (ADR-0001 Decision 3). It does not introduce a new top-level command. +4. **Clipboard read is admitted for `--auto` only (reverses the E13 non-goal below).** claudecm MAY + read the OS clipboard read-only, best-effort, as one source of the `--auto` sweep, degrading + silently when no clipboard tool is present. This is a local read; it is not network access and + does not admit clipboard *writes* or any always-on clipboard watching. ## Locked Decisions @@ -97,7 +107,8 @@ directly, and no path auto-activates the new profile (activation stays `switch`) ## Explicit Non-Goals -- No clipboard integration (paste is via argument or stdin `-`); OS clipboard access is deferred. +- Clipboard: read-only, best-effort clipboard access is admitted **only** as an `--auto` sweep + source (Amendment 4). Clipboard *writes* and always-on clipboard watching remain out. - No batch/multi-profile import from one blob; one draft per invocation. - No provider auto-detection beyond field extraction (we do not map a base URL to an official provider identity or claim support). diff --git a/docs/plan/epics.md b/docs/plan/epics.md index 4dfea0f..1aec6ff 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. +**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). --- @@ -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: 5 stories -- **Total: 13 epics, 74 stories.** +- E13: 6 stories +- **Total: 13 epics, 75 stories.** diff --git a/docs/plan/stories/E13-S6.md b/docs/plan/stories/E13-S6.md new file mode 100644 index 0000000..64a5a5a --- /dev/null +++ b/docs/plan/stories/E13-S6.md @@ -0,0 +1,45 @@ +# E13-S6 — `add --auto`: one-flag source sweep with dedup + +**User story.** +As a user who finds `--from-env` / `--from-file` / `--from-text` too many to choose between, I want `claudecm add --auto` (`-a`) to sweep every local source at once (clipboard, environment, and known tool config files), tell me what credentials it found, and skip anything I already have — so onboarding is one boring-free command and I never create a duplicate profile. + +**Acceptance criteria.** +- `add --auto` / `-a` sweeps, best-effort, in this order — a failure or absence of one source never aborts the others, and the whole path is **zero network**: + 1. **Clipboard** — read via the first available OS tool (`pbpaste` / `wl-paste` / `xclip -o -selection clipboard` / `xsel -b` / PowerShell `Get-Clipboard`), then run `internal/blobparse` over the text. If no clipboard tool exists, skip with a noted reason (never fail). + 2. **Environment** — the Claude Code / Codex env-var allowlist via `internal/envextract`. + 3. **`~/.claude/settings.json`** — via the existing claude-code adapter `Import`. + 4. **`~/.codex/auth.json` + `~/.codex/config.toml`** — via the existing codex adapter `Import`. +- Each source yields a candidate `{source, base_url, api_key, model, …}`; candidates with no API key are dropped (nothing to record). +- **Dedup by (base_url, api_key):** every candidate is compared against all existing profiles (`FileStorage.LoadAllProfiles`). A match is reported as "already recorded as ``"; comparison never prints a plaintext secret (redacted display only). +- Decision, refuse-don't-guess: + - No candidate with a key found → refuse with a clear message naming the swept sources. + - Every candidate already recorded → report "all discovered credentials are already recorded" and exit 0 (informational, not an error); write nothing. + - Exactly one NEW candidate → build the draft as `` and run the normal `add` preview / confirm / `SaveProfile` path. + - Multiple NEW candidates → in an interactive TTY, prompt the user to pick one; non-interactively, print the redacted list and refuse asking the user to disambiguate. Never silently pick. +- `--auto` is mutually exclusive with `--from-env` / `--from-file` / `--from-text` / `--preset` (one input mode per invocation). Reuses the existing `--dry-run`, redaction (NFR-S8), name validation (NFR-S5), overwrite guard; never auto-activates. + +**PRD/architecture refs.** ADR-0003 Decision Summary item 5, Amendment 4, Explicit Non-Goal (clipboard) reversal; PRD FR-1, NFR-S1, NFR-S8; reuses E3/E4 adapter `Import`, E5 `envextract`, E13-S1 `blobparse`. + +**Test plan.** +- Happy: env holds a key not in any profile → discovered, marked NEW, draft built (dry-run redacts key). +- Happy: `~/.codex/auth.json` holds a key → discovered via codex adapter. +- Dedup: a candidate whose (base_url, api_key) matches an existing profile → reported "already recorded as ", not offered as new. +- Edge: all sources empty / no key anywhere → refuse naming swept sources. +- Edge: two distinct NEW candidates, non-TTY → refuse with redacted list; TTY → prompt to pick (test the pick via injected input). +- Edge: no clipboard tool available → clipboard source skipped with a reason, other sources still swept. +- Clipboard read seam is injectable for tests (no real OS clipboard dependency in unit tests). + +**No fallback writes.** No key found, or ambiguous multi-candidate non-interactively → refuse; never write a guessed/partial profile. + +**Complexity.** M. + +**Dependencies.** E13-S1 (blobparse), E13-S2/S3 (add source-mode plumbing), E3/E4 (adapter Import), E5 (envextract). + +**Ready-for-dev checklist.** +- [x] PRD/architecture refs correct. +- [x] AC testable. +- [x] Happy + edge + dedup test rows. +- [x] No-fallback reminder present. +- [x] ADR-0003 scope (clipboard reversal noted). +- [x] Deps satisfied. +- [x] Complexity set. diff --git a/docs/quickstart.md b/docs/quickstart.md index 14bc02e..78b1ce8 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -91,6 +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. + +```bash +claudecm add work --auto --dry-run +``` + +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. + 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. ```bash