Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions docs/decisions/0003-smart-add-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# ADR-0003: Smart `add` Onboarding Scope (env / file / paste, optional AI-assisted parse)

- **Status:** Proposed (awaiting CEO/LF sign-off)
- **Date:** 2026-07-08
- **Owner:** CEO (LF)
- **Authority:** This memo governs the "smart api-key onboarding" scope (epic E13) only. Where it conflicts with `docs/decisions/0001-direction-lock.md` or `docs/decisions/0002-v1_1-scope.md`, this memo wins **for E13 only**. ADR-0001 and ADR-0002 remain the authority everywhere else.

## Context

`claudecm add` today is a declarative, flag-driven profile creator (E6-S3): every field
(`--base-url`, `--api-key`, `--model`, …) must be typed explicitly. That is script-perfect but
high-friction for the most common human task — "I just got a provider's onboarding blurb / an
`export` snippet / a config file, make me a profile from it." LF asked for three smarter entry
points:

1. **From environment variables** — read the already-exported `ANTHROPIC_*` (and Codex) env vars
and materialize a profile. Zero network.
2. **From a config file** — point at a `.env`, shell `export` snippet, JSON/YAML/TOML config, or
another tool's config file and parse it into a profile draft. Zero network.
3. **From pasted free text** — paste an arbitrary blob (a provider's docs snippet, a chat log, a
half-formatted `export`), and have claudecm figure out `base_url` / `api_key` / `model`.

Item 3 has two admissible strengths. A **local heuristic** pass (regex/structure extraction) needs
no network and is fully within the local-first lock. When the blob is too messy for heuristics, LF
wants an **opt-in AI-assisted** escalation whose defining constraint is: **the secret is stripped
locally first and never leaves the machine.** Only the desensitized (secret-free) text is sent to an
LLM, which returns a structured profile; the captured secret is re-injected locally.

The AI escalation is the only part of E13 that makes an outbound network request, so it is the only
part that touches ADR-0001 Decision 8 ("no cloud") / the README's "never sees a request" language.
This ADR draws the line precisely.

## Decision Summary

Epic **E13 — Smart `add` Onboarding** adds three input adapters to `add`, layered so the
zero-network paths are the default and the network path is explicit opt-in:

1. **`add --from-env`** — build a profile draft from the Claude Code / Codex environment-variable
allowlist. Local only.
2. **`add --from-file <path>`** — auto-detect format (`.env` / shell `export` / JSON / YAML / TOML)
and parse into a profile draft. Local only.
3. **`add --from-text` / `--from-text -` (stdin)** — run the shared **local redaction + heuristic
extractor** over pasted text and produce a profile draft. Local only.
4. **`add --ai`** — escalation, **off by default**. Uses the shared extractor to strip and hold
secrets locally, sends only the desensitized text to an LLM, receives a structured profile,
re-injects the held secret locally, then routes through the normal `add` preview/validation.
This is the only E13 path that uses the network.

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
directly, and no path auto-activates the new profile (activation stays `switch`).

## Amendments to Prior Scope

1. **ADR-0001 Decision 8 ("no cloud") is narrowly amended for E13's `--ai` path only.** claudecm
MAY make a single outbound LLM request to parse desensitized text into a profile draft, strictly
when the user opts in via `--ai`. This does not admit cloud sync, telemetry, a proxy, a gateway,
remote credential validation, or any always-on network behavior. Every non-`--ai` path, and the
default of every path, remains zero-network.
2. **README "never sees a request" is refined, not revoked.** The tool still never sees, proxies, or
routes a *tool* request (Claude Code / Codex traffic). The `--ai` path issues its own,
user-initiated, secret-free parse request and nothing else. Docs MUST state this distinction
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.

## Locked Decisions

1. **Secrets never transit the network.** The `--ai` path MUST run the local redaction pass first,
MUST hold captured secrets in-process, and MUST send only text from which secret-shaped tokens
have been removed/placeholdered. Re-injection of the real secret happens locally after the LLM
responds. A verifiable redaction step (secret token → placeholder) is an acceptance gate, not a
nicety. If redaction cannot be established for a candidate secret, that token is stripped
entirely rather than risk transit (no fallback that leaks).
2. **AI is opt-in and off by default.** Absent `--ai`, `add --from-text` uses only the local
heuristic and never touches the network. `--ai` must be typed explicitly per invocation; there is
no persisted "always use AI" mode in E13.
3. **LLM credential source.** `--ai` reuses the currently-active claudecm profile's endpoint and
key by default (dogfooding; no new secret to configure). `--ai-profile <name>` overrides which
profile's credentials are used for the parse call. The parse call never persists or logs the
credential it borrows.
4. **No remote validation.** No E13 path probes a provider endpoint to "verify" a key or model.
Drafts are produced from local + (optionally) LLM-structured data only; the user still confirms
via `--dry-run` / prompt before `SaveProfile`.
5. **No write-path bypass.** Every profile produced by E13 is saved through the same
`storage.SaveProfile` used by `add`/`import`, and any later activation still routes through
`internal/commit` + `internal/writepath`. E13 creates no side write path.
6. **Refuse, don't guess.** Consistent with NFR-S1 / CLAUDE.md no-fallback-writes: when a source
cannot be parsed into a valid draft (unreadable file, unrecognized format, LLM returns
non-conforming output), the command refuses with a clear error and writes nothing. It does not
silently emit a half-populated profile.

## Explicit Non-Goals

- No clipboard integration (paste is via argument or stdin `-`); OS clipboard access is deferred.
- 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).
- No persisted AI configuration, no streaming UI, no model selection surface beyond the borrowed
profile's model.

## Rationale

The three entry points collapse the most common onboarding frictions into one command while keeping
claudecm's core promise intact: profiles stay explicit, local, inspectable YAML, and the user owns
the secret. Layering zero-network paths as the default with a clearly-fenced, secret-free, opt-in AI
escalation lets us say "smart" without becoming "a thing that phones home." The secret-never-transits
rule is what makes the AI path defensible against ADR-0001's spirit even while amending its letter.

## Risks

- **R1. Redaction miss → secret leak on the `--ai` path.** This is the load-bearing risk.
Mitigation: redaction is a tested gate with a conservative "strip on doubt" rule; `--ai` shows the
exact desensitized payload before sending when interactive; unit tests assert no secret-shaped
token survives into the outbound request.
- **R2. Heuristic false extraction** (wrong field grabbed). Mitigation: every path ends in
`--dry-run`/preview with redacted secrets; nothing is saved without confirmation.
- **R3. Scope creep toward "AI everywhere."** Mitigation: `--ai` is per-invocation, single-request,
parse-only, no persistence; this ADR forbids always-on modes.
- **R4. Borrowed-credential surprise** (user didn't expect their active key to be used for a parse
call). Mitigation: interactive confirmation names the profile whose credentials will be used
before the call; `--ai-profile` makes it explicit.

## Supersedes

For E13 only: ADR-0001 Decision 8's blanket no-network stance as applied to the single opt-in
`--ai` parse request, and the README's "never sees a request" phrasing as applied to that same
user-initiated, secret-free call. Everything else in ADR-0001 and ADR-0002 stands.
23 changes: 22 additions & 1 deletion docs/plan/epics.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,26 @@ No story silently expands v1 scope: no MCP, no cloud, no GUI, no Gemini CLI / Cu

---

## E13. Smart `add` Onboarding

> Authority: ADR-0003 (`docs/decisions/0003-smart-add-scope.md`) governs this epic. It narrowly amends ADR-0001 Decision 8 for the opt-in `--ai` parse path only; everything else stays zero-network and local-first.

**Goal.** Collapse the three highest-friction onboarding flows into `add`: build a profile draft from environment variables, from a config file, or from pasted free text. Pasted text is parsed by a local redaction+heuristic extractor by default; an explicit `--ai` escalation strips secrets locally, sends only desensitized text to an LLM (borrowing the active profile's credentials), and re-injects the secret locally. Every path ends in the existing `add` preview/validation/save pipeline and never auto-activates or writes a tool file directly.

**Acceptance criteria.**
- `add --from-env` materializes a draft from the Claude Code / Codex env-var allowlist; zero network; missing required fields (no key) refused with a clear message.
- `add --from-file <path>` auto-detects `.env` / shell `export` / JSON / YAML / TOML and parses into a draft; unreadable/unrecognized input is refused (NFR-S1), never half-populated; zero network.
- A local redaction+heuristic extractor library exists (`internal/blobparse` or equivalent): given arbitrary text it returns extracted core fields **plus** a desensitized copy in which no secret-shaped token survives; "strip on doubt" — a candidate secret that cannot be safely placeholdered is removed, never passed through.
- `add --from-text <text>` / `add --from-text -` (stdin) runs the local extractor and produces a draft; zero network.
- `add --ai` is off by default and requires the flag per invocation. It runs the extractor, holds secrets locally, sends only the desensitized text to the LLM (active profile by default, `--ai-profile <name>` override), maps the structured response into the profile schema, re-injects the held secret locally, and refuses if the LLM output does not conform. Interactive runs name the credential-lending profile and show the desensitized payload before the call.
- No E13 path probes a remote provider to validate a key/model; no secret is logged or persisted by the parse call; all drafts save through `storage.SaveProfile` and any activation still routes through `internal/commit` + `internal/writepath`.
- 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.

---

## Story count

- E1: 7 stories
Expand All @@ -217,4 +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
- **Total: 12 epics, 69 stories.**
- E13: 5 stories
- **Total: 13 epics, 74 stories.**
35 changes: 35 additions & 0 deletions docs/plan/stories/E13-S1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# E13-S1 — Local redaction + heuristic blob extractor

**User story.**
As a claudecm user pasting a messy blob (provider onboarding text, an `export` snippet, a chat log), I want claudecm to locally detect the base URL / API key / model and produce both the extracted fields and a secret-free desensitized copy of my text, so later stories can build a profile draft without any secret ever needing to leave my machine.

**Acceptance criteria.**
- A new package (`internal/blobparse`) exposes a pure function that takes arbitrary text and returns: extracted core fields (`base_url`, `api_key`, `model`, `small_fast_model`, `provider` where inferable), a map of captured secrets, and a **desensitized** copy of the input.
- Extraction is heuristic and local: recognizes `KEY=VALUE`, shell `export KEY=VALUE`, JSON/YAML `"key": "value"`, and common label forms (`API Key: ...`, `Base URL: ...`) for the Anthropic/Codex field vocabulary.
- Secret detection covers `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `OPENAI_API_KEY`, and generic `sk-`/token-shaped values; every detected secret is replaced by a stable placeholder in the desensitized copy.
- **Strip-on-doubt:** a token that looks secret-shaped but cannot be safely mapped to a placeholder is removed from the desensitized copy entirely rather than passed through. No secret-shaped token survives desensitization.
- Pure function, no I/O, no network, deterministic; provider inference (if any) never claims official support.

**PRD/architecture refs.** ADR-0003 Locked Decisions 1 & 6; PRD FR-1, NFR-S1, NFR-S8; Architecture §2.1.

**Test plan.**
- Happy: an `export ANTHROPIC_BASE_URL=... ANTHROPIC_AUTH_TOKEN=sk-...` blob → base_url + key extracted; desensitized copy contains a placeholder, not the key.
- Happy: a prose blob (`Base URL: https://... , API Key: sk-...`) → fields extracted.
- Edge: blob with a bare `sk-xxxx` and no label → captured as a secret; desensitized copy has no `sk-` token.
- Edge: blob with no recognizable fields → empty extraction, no panic, desensitized copy equals input (nothing secret-shaped to strip).
- Property: for a corpus of blobs, assert no secret-shaped token appears in any desensitized output.

**No fallback writes.** N/A — pure library; no disk writes.

**Complexity.** M.

**Dependencies.** E1-S1, E6-S3.

**Ready-for-dev checklist.**
- [x] PRD/architecture refs correct.
- [x] AC testable.
- [x] Happy + edge test rows.
- [x] No-fallback reminder present.
- [x] ADR-0003 scope only.
- [x] Deps satisfied.
- [x] Complexity set.
33 changes: 33 additions & 0 deletions docs/plan/stories/E13-S2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# E13-S2 — `add --from-env`

**User story.**
As a user who already has `ANTHROPIC_*` (or Codex) variables exported in my shell, I want `claudecm add <name> --from-env` to read them and build a profile draft, so I don't retype values that are already in my environment.

**Acceptance criteria.**
- `add <name> --from-env` reads the Claude Code / Codex environment-variable allowlist (`ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`/`ANTHROPIC_API_KEY`, `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, and the Codex equivalents) and populates the corresponding profile core fields.
- Env reads route through `internal/envextract` (not ad-hoc `os.Getenv` scattered in `cmd/`).
- Explicit flags win over env: e.g. `--model` overrides `ANTHROPIC_MODEL` from the environment.
- If no API key/token is present in the environment, the command refuses with a clear message rather than writing a keyless profile (NFR-S1, no fallback).
- Reuses the existing `add` pipeline: `--dry-run`, redaction, name validation, overwrite guard, `SaveProfile`; does not auto-activate. Zero network.

**PRD/architecture refs.** ADR-0003 Decision Summary item 1; ADR-0001 Decision 4 (secondary env flow); PRD FR-1, NFR-E1, NFR-S1; Architecture §2.1.

**Test plan.**
- Happy: env has base_url + auth token + model → draft populated; `--dry-run` shows redacted key.
- Edge: env has base_url + model but no key → refused with "no API key found in environment".
- Edge: `--from-env` plus explicit `--model foo` → `foo` wins over env model.

**No fallback writes.** No keyless/partial profile is written; refuse when required fields are absent.

**Complexity.** S.

**Dependencies.** E6-S3, E5-S1 (envextract allowlist).

**Ready-for-dev checklist.**
- [x] PRD/architecture refs correct.
- [x] AC testable.
- [x] Happy + edge test rows.
- [x] No-fallback reminder present.
- [x] ADR-0003 scope only.
- [x] Deps satisfied.
- [x] Complexity set.
34 changes: 34 additions & 0 deletions docs/plan/stories/E13-S3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# E13-S3 — `add --from-file`

**User story.**
As a user with a provider's credentials sitting in a `.env`, a shell `export` snippet, or a JSON/YAML/TOML config, I want `claudecm add <name> --from-file <path>` to parse that file into a profile draft, so onboarding is one command instead of hand-copying fields.

**Acceptance criteria.**
- `add <name> --from-file <path>` auto-detects the format by content/extension: dotenv, shell `export` lines, JSON, YAML, TOML.
- Recognized field vocabulary maps to profile core fields (base_url, api_key/auth_token, model, small_fast_model, provider where present).
- Explicit flags override values parsed from the file.
- Unreadable file, or a file whose content matches no recognized format / yields no usable fields, is refused with a clear message; no partial profile is written (NFR-S1).
- File is read read-only; nothing outside `add`'s normal write to `~/.claudecm/profiles/<name>.yaml` is touched. Zero network. Reuses `add` `--dry-run`/redaction/validation/overwrite/save; no auto-activation.

**PRD/architecture refs.** ADR-0003 Decision Summary item 2, Locked Decision 6; PRD FR-1, NFR-S1, NFR-S8; Architecture §2.1.

**Test plan.**
- Happy: a `.env` with `ANTHROPIC_BASE_URL=` + `ANTHROPIC_AUTH_TOKEN=` → draft populated; dry-run redacts key.
- Happy: a JSON `{ "base_url": ..., "api_key": ..., "model": ... }` → draft populated.
- Edge: a shell `export ANTHROPIC_MODEL=...` snippet → model parsed.
- Edge: nonexistent path → refused "cannot read file"; a binary/garbage file → refused "unrecognized config format".

**No fallback writes.** Unrecognized/unreadable input refuses; never emit a half-populated profile.

**Complexity.** M.

**Dependencies.** E6-S3, E1-S1.

**Ready-for-dev checklist.**
- [x] PRD/architecture refs correct.
- [x] AC testable.
- [x] Happy + edge test rows.
- [x] No-fallback reminder present.
- [x] ADR-0003 scope only.
- [x] Deps satisfied.
- [x] Complexity set.
34 changes: 34 additions & 0 deletions docs/plan/stories/E13-S4.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# E13-S4 — `add --from-text` (local heuristic paste)

**User story.**
As a user who just copied a messy blob (provider docs, a chat message, a half-formatted snippet), I want `claudecm add <name> --from-text "<blob>"` (or `--from-text -` to read stdin) to locally extract the fields into a profile draft, so I can onboard without hand-parsing and without anything leaving my machine.

**Acceptance criteria.**
- `add <name> --from-text <text>` and `add <name> --from-text -` (stdin) run the `internal/blobparse` (E13-S1) local extractor and populate the profile draft from the extracted fields.
- Zero network on this path; it is the local-heuristic default of the paste flow.
- Explicit flags override extracted values.
- When the extractor finds no API key/token, the command refuses with a clear message (NFR-S1) unless a key is supplied by flag; no keyless profile is written.
- Reuses `add` `--dry-run`/redaction/validation/overwrite/save; secrets redacted in preview; no auto-activation.

**PRD/architecture refs.** ADR-0003 Decision Summary item 3, Locked Decisions 1, 2, 6; PRD FR-1, NFR-S1, NFR-S8.

**Test plan.**
- Happy: `--from-text "export ANTHROPIC_BASE_URL=... ANTHROPIC_AUTH_TOKEN=sk-..."` → draft populated; dry-run redacts key.
- Happy: `--from-text -` reading a prose blob from stdin → fields extracted.
- Edge: blob with base_url + model but no key → refused unless `--api-key` supplied.
- Edge: blob with nothing recognizable → refused "could not extract profile fields from text".

**No fallback writes.** No keyless/partial profile is written; refuse when the key is absent and unsupplied.

**Complexity.** M.

**Dependencies.** E13-S1, E6-S3.

**Ready-for-dev checklist.**
- [x] PRD/architecture refs correct.
- [x] AC testable.
- [x] Happy + edge test rows.
- [x] No-fallback reminder present.
- [x] ADR-0003 scope only.
- [x] Deps satisfied.
- [x] Complexity set.
Loading
Loading