reply-cli: profile management, team context & raw api passthrough - #2
Conversation
…Y-51354) Profile-management completeness for the reply CLI: - profile rename <old> <new>: renames the config def and moves the stored credential to the new key (store is keyed by profile name); refuses if the target name or credential already exists; repoints current_profile. - profile delete <name> (alias rm): removes the def and stored credential; confirm-by-default, --yes skips, non-interactive without --yes refused; resets current to default. - profile show [name]: shows backend URLs (marks inherited), pinned team, and authorization in priority order (--api-key > REPLY_API_KEY > stored) with no secrets. Defaults to the current profile. - profile unset <name> <field>: clears authority | api_base | team-id (never name or credentials); idempotent; allowed on the built-in default. - add/set: validate --authority/--api-base as http(s) URLs. - Lock name-on-create behavior with a test. profile.ts stays config-only/sync/store-agnostic; credential-touching logic lives in commands/profile.ts as exported, offline-testable handlers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…EPLY-51325)
Identifies CLI traffic for telemetry (captured by Elastic APM/OTel as
user_agent.original) — including the API-key path, where there is no OAuth
client_id. Identification only; authorization stays scope-based.
- config.ts: add cli_version() (memoized package.json read, '0.0.0' fallback)
and user_agent() = `${APP_NAME}-cli/<version>`, derived from APP_NAME to keep
the single build identity.
- utils/client.ts: set User-Agent on the shared request() transport (covers
both OAuth and API-key credentials; every current and future API call).
- index.ts: use cli_version() for --version, dropping the duplicate local
read_version() and its now-unused fs/path imports.
Scope: API requests only. The OAuth /connect/token endpoint and the browser
authorize step are intentionally left unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… guidance (REPLY-51258, REPLY-51259)
Two intertwined pieces landing together (api is the workload vehicle for the
team-conflict guidance; both share request_raw and teams.ts):
REPLY-51258 — team discovery + conflict guidance:
- src/teams.ts: resolve_my_teams() (resilient — /whoami/team-users on 200, or the
same teams[] from a TEAM_REQUIRED/TEAM_NOT_ACCESSIBLE 403), parse_teams()
(dedupe, case-insensitive), team_error_guidance() (TEAM_REQUIRED/
TEAM_NOT_ACCESSIBLE/USER_REQUIRED/USER_NOT_FOUND → tailored fix + team list).
- src/commands/team.ts: `team list` / `current` (pinned + effective-from-whoami,
graceful) / `use <id>` (verified against your teams) / `clear`. Reads & writes
the current profile's team_id — no new state.
- Guidance is surfaced ONLY on the workload `api` command; team/auth commands
consume the team list silently.
REPLY-51259 — reply api raw passthrough:
- src/commands/api.ts: `reply api <path> [--method] [--body]` → prints {code,data}
for any status; GET by default, POST when --body; --body takes inline JSON,
@file, or - (stdin). Query lives in the path. Exits 1 on HTTP >= 400 and, on a
team/user-resolution conflict, prints the tailored guidance to stderr.
Shared plumbing:
- src/utils/client.ts: request_raw() returns {status, data} for any HTTP status
(never throws on status; still retries transient, throws only on network).
- src/commands/authed.ts: shared credential + team/acting-user header resolution.
Docs: index.ts help + README gain team and api sections. Tests fully offline
(mock fetch, temp config/store).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-only team fallback (REPLY-51257, REPLY-51258, REPLY-51259) REPLY-51259 — reply api examples + URL model: - Advertise the API reference (docs.reply.io/api-reference/introduction) and use only route-verified endpoints (/v3/whoami, /v3/sequences, /v3/contacts, …) in help + README. - The request URL is now literally api_base + path — no /v3 special-casing. Removed ensure_v3 (path) and strip_api_version (base). The caller types the docs path (/v3/…); the CLI just joins. REPLY-51257 — profile base convention: - api_base is the HOST only (embedded default https://api.reply.io); the /v3 version prefix lives in the request path, not the profile. Internal calls use literal /v3/whoami and /v3/whoami/team-users against the host base. REPLY-51258 — team discovery on non-org accounts: - /whoami/team-users is organization-only; on 403 workspace.organizationRequired fall back to the single team from whoami instead of erroring. client: slash-safe base+path join (collapses //, inserts a missing /) shared by request and request_raw. Verified against dev: api /v3/whoami and api v3/whoami both 200; team list/current/ use/clear work for a personal account. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…Y-51259)
--verbose (global flag, honored by the workload `api` command):
- Prints a curl -v-style request/response trace to stderr while {code,data}
stays on stdout (pipe-safe). Authorization is redacted at the source
(request_raw returns a pre-redacted request view; the raw token never leaves),
and cookie response headers are redacted on print.
- request_raw now returns {status, data, response_headers, request}.
Help polish:
- Added worked Examples blocks to the non-trivial commands that lacked them
(auth status; profile add/set/use/show/rename/delete/unset; team list/current/
use), plus clarity notes (auth status runs offline; team list/current call the
API; profile delete confirm-vs--yes). Trivial argument-less commands
(auth logout, profile list/current, team clear) intentionally left without.
Verified on dev: `api /v3/whoami --verbose` shows the redacted trace + 200;
`... --verbose 2>/dev/null | jq` yields clean JSON.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n (REPLY-51257, REPLY-51259) - Environment-variables table was missing REPLY_TEAM_ID (referenced in Teams, supported by the CLI) — added the row. - Raw API section documents --body/--pretty/exit codes but not the new --verbose; added an example line and a one-sentence note (stderr trace, credentials redacted, stdout stays JSON). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…REPLY-51259) The intro implied only auth/identity works today with resource commands "on the way", underselling that `reply api` already reaches the entire v3 API. Add that to the opening pitch (one sentence, no restructuring); the Raw API section already carries real runnable examples (/v3/whoami, /v3/sequences, /v3/contacts). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…olor-stable (REPLY-51258)
CI failed on `team list` — the `*` marker is pc.green('*'), and under FORCE_COLOR
(set in CI) the reset code lands between `*` and the id, so /\*\s*1045/ no longer
matched. Passed locally only because color was off. Strip ANSI in the test's
capture helper; verified the full suite green with FORCE_COLOR=1.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ArtemKosolap
left a comment
There was a problem hiding this comment.
Code Review — profile management, team context & raw api passthrough
Overview
Substantial, well-structured feature PR (+1886/−44, 18 files) extending the reply CLI with:
- Profile management:
profile rename/delete/show/unsetwith URL validation. teamcommand:list/current/use/clear, editing the current profile'steam_id, with resilient team discovery (/v3/whoami/team-users→TEAM_REQUIREDbody → single-whoami fallback).reply apiraw passthrough: literalapi_base + path, method inference,@file/-/inline body,--verbosetrace with credential redaction,{code,data}output.- Client identification:
User-Agent: reply-cli/<version>on all requests. - A repositioning of
api_baseto be host-only (the/v3prefix moves into request paths).
Verification: tsc --noEmit clean; vitest run → 231 passed / 3 skipped. New code is well-covered by focused unit tests with injected deps (stdin, confirm prompt, credential store).
Strengths
- Clean separation of concerns: config-only mutations in
profile.ts(rename/delete/unset_profile_def) vs. credential-store orchestration in the command layer. Thehandle_renameordering (guard target → read → rename def → move credential, "orphan not destroy" on partial failure) is exactly the right instinct for a two-store operation without a transaction. - Secret hygiene is careful and tested:
request_rawpre-redactsAuthorizationin itsrequestview so the raw token never leaves the transport;--verboseadditionally redacts cookies;profile showreusesdescribe_status(no refresh, no write) and has explicit "never leaks token/key" tests. - Consistent conventions: matches the codebase's
snake_caselocals,UsageError/RuntimeErrorwithcode/hint, stdout-data/stderr-status split, and injectable-deps testing style. /v3path migration is applied consistently acrossauth,teams,team, andapi(no leftover un-prefixed endpoints).
Issues & risks (by severity)
1. api_base semantics change is a breaking change for existing custom profiles (Medium).
EMBEDDED.api_base moved from https://api.reply.io/v3 → https://api.reply.io, and all paths now carry /v3. Any user who followed the previous docs and stored api_base: "https://api.dev.reply.io/v3" will now hit …/v3/v3/whoami → 404, since join_url just concatenates. There is no migration or normalization, and the prod default is safe, but the PR body's "No breaking changes to existing auth commands" doesn't hold for hand-authored dev/staging profiles (the old help text and tests confirm /v3-in-api_base was the documented convention).
- Suggestion: strip a trailing
/v3(or/v[0-9]+) fromapi_baseinresolve_profile, or detect/v3/v3injoin_url, or at minimum add a CHANGELOG/upgrade note.profile showmarking inherited-vs-explicit helps users self-diagnose, but silent 404s are a rough landing.
2. request_raw retries non-idempotent methods on transient statuses (Medium-Low).
request_raw retries [429,500,502,503,504] up to 3× regardless of method. reply api … --body @c.json is a POST, and the docs actively promote it. A POST that created a resource but returned 500 would be retried and can double-create. (429/502/503/504 are generally safe to retry; 500 after a mutation is the dangerous case.)
- Suggestion: restrict body-bearing/
POST/PATCHretries to clearly-safe statuses (e.g. 429/503), or only retry idempotent methods, or document that mutations may be retried.requesthad the same behavior but was only ever used forGET /whoami, sorequest_rawis where this first becomes a real exposure.
3. handle_team_current collapses all errors into "failed to retrieve" (Low).
The catch treats a 401/invalid-credential the same as an offline network blip. A logged-out user sees failed to retrieve (…) rather than a clear "not logged in." Acceptable for a best-effort read, but a 401 could be special-cased for a better hint.
4. read_all_stdin via fs.readFileSync(0) (Low).
Correctly wrapped in try/catch (→ '' → clean UsageError). One rough edge: reply api /x --body - run interactively (no pipe) on a TTY will block on the read rather than erroring fast. Minor; worth a note that - expects piped input.
Style / maintainability (nits)
- Boilerplate duplication:
Global_opts,read_globals,wants_json,print_optsare now copy-pasted acrossapi.ts,team.ts, andprofile.ts(with slightly different field sets). Consider a shared helper to prevent drift as more commands land. - Double validation of clearable fields:
CLEARABLE_INPUT/map_clearablein the command layer andCLEARABLE_FIELDSinunset_profile_field. Fine as defense-in-depth, but the command-layer map already guarantees a validClearable_field, so the core check is effectively unreachable — worth a comment saying so. - Consistency:
handle_team_currentusescreate_client().get('/v3/whoami')while the rest of the new code usesrequest_raw. Harmless, but two ways to reach/whoami.
Test coverage
Strong and idiomatic: slash-safe join, request_raw (2xx/4xx/retry/network), redaction, method inference, body parsing (@file/-/inline/invalid), rename/delete/unset edge cases (default-slot refusal, current-repointing, idempotent no-op), team resolution incl. the org-only fallback, and team_error_guidance per code. Gaps worth adding: a test asserting request_raw retry behavior for a POST (documents the intended idempotency decision from #2), and an integration-style test that an existing /v3 api_base config doesn't double up (would pin down #1).
Security
No concerns beyond the above. Credential redaction is enforced at the transport boundary and unit-tested against a literal secret; --user-id/--user-email remain flag-only; URL validation rejects non-http(s) schemes (file://, ftp:// tested). --verbose prints request headers + response headers only (not the response body) to stderr, keeping stdout pipe-clean.
Verdict: High-quality, ships clean (types + tests green). Recommend treating #1 as clarify-before-merge (add normalization/migration or correct the "no breaking changes" claim) and considering #2 given the raw-api POST guidance; the rest are good follow-ups.
🤖 Generated with Claude Code
Summary
Builds out the
replyCLI beyond bare auth: full profile management, team context + ateamcommand, and a rawapipassthrough that already reaches the entire v3 API. Covers REPLY-51354, REPLY-51325, REPLY-51257, REPLY-51258, REPLY-51259.What's included
REPLY-51354 — profile management
profile rename <old> <new>(moves the stored credential with the def),profile delete <name>(rm; confirm-by-default,--yesto skip),profile show [name](redacted; authorization shown in precedence order),profile unset <name> <field>(clearauthority/api_base/team-id).--authority/--api-base(http/https only).REPLY-51325 — client identification
User-Agent: reply-cli/<version>on API requests (captured by APM asuser_agent.original), so API-key traffic is identifiable even without an OAuthclient_id.REPLY-51257 — team/acting-user context
api_baseis the host (no/v3); the version prefix lives in the request path.X-TEAM-IDprecedence--team-id > REPLY_TEAM_ID > profile team_id; org-key acting user via--user-id/--user-email.REPLY-51258 —
teamcommand + conflict guidanceteam list/current(pinned + effective-from-whoami) /use <id>(verified) /clear, editing the current profile'steam_id(no separate team state)./v3/whoami/team-users, resilient to theTEAM_REQUIRED403 (sameteams[]); falls back to the single whoami team when the endpoint is organization-only.REPLY-51259 —
reply apiraw passthroughreply api <path> [--method] [--body]→{code, data}; GET by default, POST when--body(inline /@file/-stdin). Request URL is literallyapi_base + path(slash-safe join) — no version-specific code.--verboseprints a curl-style request/response trace to stderr withAuthorizationredacted; stdout stays clean JSON (pipe-safe).Docs & help
Examples:blocks + clarity notes on every non-trivial command; README updated (intro surfacesreply api, Teams/Profiles/Raw-API sections, env-var table).Testing
npm test); fully offline (mockedfetch, temp config/credential store).api /v3/whoami(and--verbose, redacted) → 200;team list/current/use/clearfor a personal account; profile rename/delete/show/unset.Notes
TEAM_*/USER_*problem+json contract ships in reply-team/replyapp#1704.🤖 Generated with Claude Code