Skip to content

Commit ebd4b98

Browse files
sacha-ichbiahclaude
andcommitted
cli: experiments update replace-semantics help, plus auth/env, evals, blocks and spec fixes
- experiments update: the --capture/--captures-file/--documents-file flags REPLACE the document set, but the example advertised "Add more documents from production"; following it dropped every document not listed - carry the surrounding sweep fixes for auth/env resolution, evals, blocks, spec and the Go client's error detail Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 59ef676 commit ebd4b98

16 files changed

Lines changed: 1101 additions & 62 deletions

‎cli/cmd/auth.go‎

Lines changed: 93 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,15 @@ override and takes precedence over anything written to disk.`,
8787
return runAccessTokenLogin(accessToken, baseURL)
8888
}
8989

90+
loginSlug, err := loginEnvironmentSlug(cmd)
91+
if err != nil {
92+
return err
93+
}
94+
9095
// Explicit API-key path. Direct, no browser. Backward compatible
9196
// with the original login UX.
9297
if apiKey != "" || (!useBrowser && os.Getenv("RETAB_API_KEY") != "") {
93-
return runAPIKeyLogin(apiKey, baseURL)
98+
return runAPIKeyLogin(apiKey, baseURL, loginSlug)
9499
}
95100

96101
// If `--no-browser` was requested and no key was given, prompt.
@@ -100,7 +105,7 @@ override and takes precedence over anything written to disk.`,
100105
return err
101106
}
102107
if strings.TrimSpace(prompted) != "" {
103-
return runAPIKeyLogin(prompted, baseURL)
108+
return runAPIKeyLogin(prompted, baseURL, loginSlug)
104109
}
105110
// fall through to browser flow
106111
}
@@ -228,7 +233,15 @@ func chooseLoginEnvironment(currentEnvironmentID string, list *cliPaginatedList[
228233

229234
// runAPIKeyLogin persists an organization API key. Access tokens must use
230235
// runAccessTokenLogin so they are stored separately and sent as Bearer.
231-
func runAPIKeyLogin(apiKey, baseURL string) error {
236+
// runAPIKeyLogin stores an API key. When slug is empty the key becomes the
237+
// single top-level credential (the original, pre-environments behavior). When
238+
// slug is set — `auth login --env <slug>` or its `--live` alias — the key is
239+
// stored as that named environment profile instead, which is the only thing
240+
// `--env <slug>`/`--live` ever read at request time. Without this branch the
241+
// profile map had six readers and no writer, so those two selector flags could
242+
// not be satisfied by any command: their own remediation text sent users here,
243+
// this function wrote `api_key`, and the next `--live` call failed identically.
244+
func runAPIKeyLogin(apiKey, baseURL, slug string) error {
232245
if apiKey == "" {
233246
apiKey = os.Getenv("RETAB_API_KEY")
234247
}
@@ -246,11 +259,42 @@ func runAPIKeyLogin(apiKey, baseURL string) error {
246259
return fmt.Errorf("access tokens must be passed with --access-token, not --api-key")
247260
}
248261
cfg, _ := loadConfig()
262+
resolvedBaseURL := stripLegacyV1Suffix(configuredLoginBaseURL(baseURL))
263+
264+
if slug != "" {
265+
normalized, err := validateSlug(slug)
266+
if err != nil {
267+
return err
268+
}
269+
if cfg.Environments == nil {
270+
cfg.Environments = map[string]*environmentProfile{}
271+
}
272+
profile := cfg.Environments[normalized]
273+
if profile == nil {
274+
profile = &environmentProfile{CreatedAt: time.Now().UTC().Format(time.RFC3339)}
275+
cfg.Environments[normalized] = profile
276+
}
277+
profile.APIKey = apiKey
278+
profile.APIKeyPreview = previewAPIKey(apiKey)
279+
profile.BaseURL = resolvedBaseURL
280+
// A first named profile also becomes the default, so plain commands
281+
// keep working after an environment-scoped login.
282+
if cfg.DefaultEnvironment == "" {
283+
cfg.DefaultEnvironment = normalized
284+
}
285+
if err := saveConfig(cfg); err != nil {
286+
return err
287+
}
288+
path, _ := configPath()
289+
fmt.Fprintf(os.Stderr, "Saved API key for environment %q to %s\n", normalized, path)
290+
return nil
291+
}
292+
249293
cfg.APIKey = apiKey
250294
// Wipe stale bearer state — explicit API-key login is the user's intent.
251295
cfg.OAuth = nil
252296
cfg.AccessToken = ""
253-
cfg.BaseURL = stripLegacyV1Suffix(configuredLoginBaseURL(baseURL))
297+
cfg.BaseURL = resolvedBaseURL
254298
if err := saveConfig(cfg); err != nil {
255299
return err
256300
}
@@ -259,6 +303,51 @@ func runAPIKeyLogin(apiKey, baseURL string) error {
259303
return nil
260304
}
261305

306+
// previewAPIKey renders the redacted form kept alongside a stored profile so
307+
// list views never have to read the raw key: prefix through the second
308+
// underscore, then the last four characters ("rt_test_...9f2c").
309+
func previewAPIKey(apiKey string) string {
310+
if len(apiKey) < 4 {
311+
return "..."
312+
}
313+
tail := apiKey[len(apiKey)-4:]
314+
prefixEnd := 0
315+
for seen := 0; prefixEnd < len(apiKey) && seen < 2; prefixEnd++ {
316+
if apiKey[prefixEnd] == '_' {
317+
seen++
318+
}
319+
}
320+
if prefixEnd == 0 || prefixEnd >= len(apiKey)-4 {
321+
return "..." + tail
322+
}
323+
return apiKey[:prefixEnd] + "..." + tail
324+
}
325+
326+
// loginEnvironmentSlug resolves the environment profile an API-key login should
327+
// write to from the --env/--live selectors, mirroring how resolveCredential
328+
// reads them ("live" folds onto "production"). Empty means an unscoped login.
329+
func loginEnvironmentSlug(cmd *cobra.Command) (string, error) {
330+
envFlag, _ := cmd.Flags().GetString("env")
331+
live, _ := cmd.Flags().GetBool("live")
332+
if envFlag != "" && live {
333+
normalized, err := validateSlug(envFlag)
334+
if err != nil {
335+
return "", err
336+
}
337+
if normalized != slugProduction {
338+
return "", fmt.Errorf("--live conflicts with --env %s; --live is an alias for --env production", envFlag)
339+
}
340+
return slugProduction, nil
341+
}
342+
if live {
343+
return slugProduction, nil
344+
}
345+
if envFlag != "" {
346+
return validateSlug(envFlag)
347+
}
348+
return "", nil
349+
}
350+
262351
func runAccessTokenLogin(accessToken, baseURL string) error {
263352
accessToken = strings.TrimSpace(accessToken)
264353
if accessToken == "" {

‎cli/cmd/auth_test.go‎

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -529,7 +529,7 @@ func TestAPIKeyLoginDefaultsToProductionInsteadOfStoredLocalhost(t *testing.T) {
529529
t.Fatalf("saveConfig: %v", err)
530530
}
531531

532-
if err := runAPIKeyLogin("sk_live_test", ""); err != nil {
532+
if err := runAPIKeyLogin("sk_live_test", "", ""); err != nil {
533533
t.Fatalf("runAPIKeyLogin: %v", err)
534534
}
535535
cfg, err := loadConfig()
@@ -574,7 +574,7 @@ func TestAPIKeyLoginRejectsAccessTokenPrefix(t *testing.T) {
574574
t.Setenv("HOME", t.TempDir())
575575
t.Setenv("RETAB_API_KEY", "")
576576

577-
err := runAPIKeyLogin("acctk_production_secret", "")
577+
err := runAPIKeyLogin("acctk_production_secret", "", "")
578578
if err == nil || !strings.Contains(err.Error(), "--access-token") {
579579
t.Fatalf("expected --access-token guidance, got %v", err)
580580
}
@@ -1130,3 +1130,79 @@ func TestPromptSecretEOFHandling(t *testing.T) {
11301130
t.Errorf("empty stdin: got (%q, nil), want error", got)
11311131
}
11321132
}
1133+
1134+
// TestAPIKeyLoginWritesEnvironmentProfile pins the writer for the named
1135+
// credential profiles. `--env <slug>` and `--live` read
1136+
// cfg.Environments[slug].APIKey at request time; before this, six call sites
1137+
// read that map and nothing ever wrote it, so both selectors were unreachable
1138+
// and their own remediation text ("retab env add <slug> --api-key <key>", a
1139+
// command that does not exist) could not fix it.
1140+
func TestAPIKeyLoginWritesEnvironmentProfile(t *testing.T) {
1141+
t.Setenv("HOME", t.TempDir())
1142+
t.Setenv("RETAB_API_KEY", "")
1143+
1144+
if err := runAPIKeyLogin("rt_test_abc1234", "", "staging"); err != nil {
1145+
t.Fatalf("runAPIKeyLogin: %v", err)
1146+
}
1147+
cfg, err := loadConfig()
1148+
if err != nil {
1149+
t.Fatalf("loadConfig: %v", err)
1150+
}
1151+
profile := cfg.Environments["staging"]
1152+
if profile == nil || profile.APIKey != "rt_test_abc1234" {
1153+
t.Fatalf("staging profile = %#v, want the stored key", profile)
1154+
}
1155+
if profile.APIKeyPreview != "rt_test_...1234" {
1156+
t.Fatalf("preview = %q, want a redacted preview", profile.APIKeyPreview)
1157+
}
1158+
if cfg.DefaultEnvironment != "staging" {
1159+
t.Fatalf("DefaultEnvironment = %q, want staging", cfg.DefaultEnvironment)
1160+
}
1161+
// An environment-scoped login must not also claim the unscoped slot;
1162+
// that is what made the old behavior look like it had worked.
1163+
if cfg.APIKey != "" {
1164+
t.Fatalf("APIKey = %q, want empty for a scoped login", cfg.APIKey)
1165+
}
1166+
}
1167+
1168+
// TestAPIKeyLoginLiveFoldsOntoProduction covers the --live alias writing the
1169+
// same profile --live later reads.
1170+
func TestAPIKeyLoginLiveFoldsOntoProduction(t *testing.T) {
1171+
t.Setenv("HOME", t.TempDir())
1172+
t.Setenv("RETAB_API_KEY", "")
1173+
1174+
if err := runAPIKeyLogin("rt_live_xyz9876", "", "live"); err != nil {
1175+
t.Fatalf("runAPIKeyLogin: %v", err)
1176+
}
1177+
cfg, err := loadConfig()
1178+
if err != nil {
1179+
t.Fatalf("loadConfig: %v", err)
1180+
}
1181+
if cfg.Environments["production"] == nil {
1182+
t.Fatalf("environments = %#v, want the live alias folded onto production", cfg.Environments)
1183+
}
1184+
if cfg.Environments["live"] != nil {
1185+
t.Fatalf("environments has a literal \"live\" profile, want it normalized")
1186+
}
1187+
}
1188+
1189+
// TestAPIKeyLoginUnscopedKeepsLegacyShape guards the pre-environments path:
1190+
// no --env/--live means the key still lands in the top-level slot.
1191+
func TestAPIKeyLoginUnscopedKeepsLegacyShape(t *testing.T) {
1192+
t.Setenv("HOME", t.TempDir())
1193+
t.Setenv("RETAB_API_KEY", "")
1194+
1195+
if err := runAPIKeyLogin("rt_live_plain999", "", ""); err != nil {
1196+
t.Fatalf("runAPIKeyLogin: %v", err)
1197+
}
1198+
cfg, err := loadConfig()
1199+
if err != nil {
1200+
t.Fatalf("loadConfig: %v", err)
1201+
}
1202+
if cfg.APIKey != "rt_live_plain999" {
1203+
t.Fatalf("APIKey = %q, want the unscoped key", cfg.APIKey)
1204+
}
1205+
if len(cfg.Environments) != 0 {
1206+
t.Fatalf("environments = %#v, want none for an unscoped login", cfg.Environments)
1207+
}
1208+
}

‎cli/cmd/common.go‎

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"path"
2222
"path/filepath"
2323
"regexp"
24+
"sort"
2425
"strconv"
2526
"strings"
2627
"sync"
@@ -229,6 +230,14 @@ func renderAPIErrorForCLI(cmd *cobra.Command, apiErr *retab.APIError) string {
229230
}
230231
return strings.Join(lines, "\n")
231232
}
233+
if issueLines, issueCount := formatAPIErrorIssueLines(apiErr.Details); len(issueLines) > 0 {
234+
lines := []string{fmt.Sprintf("%d — %s", apiErr.StatusCode, summarizeIssueCount(issueCount))}
235+
lines = append(lines, issueLines...)
236+
if apiErr.RequestID != "" {
237+
lines = append(lines, " Request-ID: "+apiErr.RequestID)
238+
}
239+
return strings.Join(lines, "\n")
240+
}
232241
message := apiErr.Message
233242
if detail := usefulAPIErrorDetail(apiErr.Details); detail != "" && isGenericAPIErrorMessage(message) {
234243
message = detail
@@ -313,6 +322,122 @@ func usefulAPIErrorDetail(details map[string]any) string {
313322
return ""
314323
}
315324

325+
// formatAPIErrorIssueLines renders the full problem list an endpoint returned
326+
// instead of just the first one. The declarative spec compiler is the
327+
// motivating case: `workflows spec validate/plan/apply` exists precisely to
328+
// report everything wrong with a spec, but the error body's summary message
329+
// only names issue #1 and appends "(N issues)". Rendering just that summary
330+
// forced a fix-one-retry loop through N round trips and threw away the
331+
// per-issue yaml_path plus the details map, which frequently spells out the
332+
// fix (available_handles being the clearest example).
333+
//
334+
// Returns nil when there is no issue list, so callers fall back to the plain
335+
// message rendering. The second return is the number of issues rendered, which
336+
// is not len(lines): a single issue can also emit indented hint lines.
337+
func formatAPIErrorIssueLines(details map[string]any) ([]string, int) {
338+
raw, ok := details["errors"].([]any)
339+
if !ok || len(raw) == 0 {
340+
return nil, 0
341+
}
342+
lines := make([]string, 0, len(raw))
343+
issues := 0
344+
for _, entry := range raw {
345+
issue, ok := entry.(map[string]any)
346+
if !ok {
347+
continue
348+
}
349+
message := strings.TrimSpace(issueTextFromAny(issue["message"]))
350+
yamlPath := strings.TrimSpace(issueTextFromAny(issue["yaml_path"]))
351+
// The server already prefixes most messages with the yaml path; only
352+
// add it when it is genuinely missing so the line does not stutter.
353+
if yamlPath != "" && !strings.HasPrefix(message, yamlPath) {
354+
if message == "" {
355+
message = yamlPath
356+
} else {
357+
message = yamlPath + ": " + message
358+
}
359+
}
360+
if message == "" {
361+
continue
362+
}
363+
if severity := strings.TrimSpace(issueTextFromAny(issue["severity"])); severity != "" && severity != "error" {
364+
message = strings.ToUpper(severity) + ": " + message
365+
}
366+
if code := strings.TrimSpace(issueTextFromAny(issue["code"])); code != "" {
367+
message += " [" + code + "]"
368+
}
369+
lines = append(lines, " - "+message)
370+
lines = append(lines, formatIssueDetailHints(issue["details"])...)
371+
issues++
372+
}
373+
return lines, issues
374+
}
375+
376+
// formatIssueDetailHints surfaces the list-valued entries of an issue's details
377+
// map ("available_handles": [...] and friends). Those enumerate the legal
378+
// values, so they turn "handle X is not valid" into an actionable message.
379+
// Scalar detail keys are skipped: they are already quoted in the message.
380+
func formatIssueDetailHints(raw any) []string {
381+
details, ok := raw.(map[string]any)
382+
if !ok || len(details) == 0 {
383+
return nil
384+
}
385+
keys := make([]string, 0, len(details))
386+
for key := range details {
387+
keys = append(keys, key)
388+
}
389+
sort.Strings(keys)
390+
391+
hints := make([]string, 0, len(keys))
392+
for _, key := range keys {
393+
values, ok := details[key].([]any)
394+
if !ok || len(values) == 0 {
395+
continue
396+
}
397+
rendered := make([]string, 0, len(values))
398+
for _, value := range values {
399+
if text := strings.TrimSpace(issueTextFromAny(value)); text != "" {
400+
rendered = append(rendered, text)
401+
}
402+
}
403+
if len(rendered) == 0 {
404+
continue
405+
}
406+
label := strings.ReplaceAll(key, "_", " ")
407+
hints = append(hints, fmt.Sprintf(" %s: %s", label, strings.Join(rendered, ", ")))
408+
}
409+
return hints
410+
}
411+
412+
// summarizeIssueCount builds the header line above an expanded issue list.
413+
// The server's summary message is always a copy of issue #1 (with "(N issues)"
414+
// glued on when there are several), and issue #1 is about to be printed as the
415+
// first bullet, so echoing the message here would print it twice. A bare count
416+
// is the one thing the bullets do not already say.
417+
func summarizeIssueCount(count int) string {
418+
if count == 1 {
419+
return "1 issue found:"
420+
}
421+
return fmt.Sprintf("%d issues found:", count)
422+
}
423+
424+
// issueTextFromAny is the tolerant sibling of stringFromAny: issue detail
425+
// values arrive as free-form JSON, so numbers and bools must render too.
426+
func issueTextFromAny(value any) string {
427+
switch typed := value.(type) {
428+
case string:
429+
return typed
430+
case nil:
431+
return ""
432+
case float64:
433+
return strconv.FormatFloat(typed, 'f', -1, 64)
434+
case bool:
435+
return strconv.FormatBool(typed)
436+
default:
437+
return fmt.Sprint(typed)
438+
}
439+
}
440+
316441
type validationErrorLine struct {
317442
Location []any `json:"loc"`
318443
Message string `json:"msg"`

0 commit comments

Comments
 (0)