diff --git a/CHANGELOG.md b/CHANGELOG.md index 79abad3..e843539 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Bug fixes from a review of 0.1.3, and live progress on a terminal. No new comman - **Live progress on a terminal.** A spinner with a label and elapsed time covers every wait over about 300 ms (`scrape`, `scrape -`, `batch submit`/`wait`, and any other API call), and `scrape`, `scrape -` and `batch wait` end with a `✓`/`✗` summary line (page, status, credits, duration) on stderr. API errors keep their `ERR::` code and hint in a short block. Everything is on stderr, only when stderr is a terminal, and off with `--json`, `-q`, `NO_COLOR`, `TERM=dumb` or `CI`: stdout and the plain output are unchanged. New dependency: `golang.org/x/term`, for the terminal width. - **Retries are safe.** A connection that drops after the request was sent now exits 12 ("may have run and been billed"), not 10 ("nothing was sent, safe to retry"). A gateway 5xx without an API problem document is retried only for GET/HEAD, and is no longer mistaken for the target's status under `--original-status`. A 301/302/303 on a POST is an error naming the Location instead of a silent GET. `Retry-After` waits are capped at 60 s and announced on stderr. - **Ctrl-C exits 130** with "interrupted", including while `scrape -` waits on stdin. A mistyped subcommand (`spicrawl batch wiat`) is a usage error with a suggestion, not help and exit 0. +- **JSON mode keeps stderr clean** (**behaviour change**). With `--json`, or whenever stdout is not a terminal, stderr is now the problem document of a failure and nothing else; it is empty on success. Warnings and status lines ("wrote N bytes", "more results: --cursor ...", the `--engine` deprecation notice, the `ws://` warning) used to print there as prose, sometimes before the problem document, so `cmd --json 2>err.json; jq . err.json` failed. They still print in human mode. The API's `warnings` field is still in the JSON on stdout. - **Keys:** a trailing CR/LF or space on the key (flag, env or file) is trimmed. The config file is written via a private temp file, short keys are fully masked, and commands that get the key and base URL from flags work without `$HOME`. - **scrape:** `--body null` is a usage error, not a panic (also `batch submit`). Big integers survive `--json` (**key order now follows the API**, as `--jsonl` already did). Stdout write errors stop the run instead of exiting 0. `-o` writes atomically and **a target error page no longer overwrites the file**. `-o -` means stdout. Screenshot labels never overwrite each other. `--body` merges deeply. A BOM on stdin is stripped. Human output no longer HTML-escapes `<>&`. The multi-URL summary counts correctly. `--impersonate` help says it is on by default. - **Empty results and `--extract` order** (**behaviour change**, human mode only). `batch list` ("no batch jobs") and `batch results` ("no finished items yet") now print that message on stderr, as `sessions list` and `logs` already did, so stdout is empty and `spicrawl batch list | wc -l` is 0; `-q` silences it. `scrape --extract` (and the file `-o` writes for it) lists the extracted fields in the API's order instead of sorted alphabetically. `--json` and `--jsonl` are unchanged. @@ -20,7 +21,7 @@ Bug fixes from a review of 0.1.3, and live progress on a terminal. No new comman **Set up from one prompt.** `spicrawl init --all --yes --api-key ` then `spicrawl status --json` is enough for an agent to set Spicrawl up with no questions asked; the README has the prompt to paste ("Set up with one prompt"). -- **init:** `--all` connects every detected client (conflicts with `--client`). `--api-key` is checked with the API and saved as `login` does, before any client file is written; a rejected key writes nothing. The key is then written into each client's MCP config as is, in the user-level files (`--global` is implied; `--global=false` picks the project files, which are added to the project's `.gitignore` when it has one; `--use-env=true` keeps the `${SPICRAWL_API_KEY}` reference), so the agents work with nothing exported. A key already saved in the config file (by `login` or an earlier `--api-key`) is embedded the same way when `init` runs without `--api-key`, with no new check; a key that is only in `$SPICRAWL_API_KEY` keeps today's `${SPICRAWL_API_KEY}` references. The key is never printed, only masked, and the error output masks it too. After the result list (stdout, unchanged) init prints a report on stderr: `connected:`, `skipped:` with the reason, `api key:` and the `next:` steps. `$SPICRAWL_API_KEY` is used as before and is not checked or saved. +- **init:** `--all` connects every detected client (conflicts with `--client`). `--api-key` is checked with the API and saved as `login` does, before any client file is written; a rejected key writes nothing. The key is then written into each client's MCP config as is, in the user-level files (`--global` is implied; `--global=false` picks the project files, which are added to the project's `.gitignore` when it has one; `--use-env=true` keeps the `${SPICRAWL_API_KEY}` reference), so the agents work with nothing exported. A key already saved in the config file (by `login` or an earlier `--api-key`) is embedded the same way when `init` runs without `--api-key`, with no new check; a key that is only in `$SPICRAWL_API_KEY` keeps today's `${SPICRAWL_API_KEY}` references. The key is never printed, only masked, and the error output masks it too. After the result list (stdout, unchanged) init prints a report on stderr in human mode (like every message, it is silent in JSON mode, where the list has the `action` and `reason` of each file): `connected:`, `skipped:` with the reason, `api key:` and the `next:` steps. `$SPICRAWL_API_KEY` is used as before and is not checked or saved. - **status** (**behaviour change**): it prints a repair command for each failed check (`reachable`, `ready`, `key`, `workers.`, `mcp`), each marked `unattended_safe: true|false`; `--json` has them in a `repairs` array of `{check, command, unattended_safe, why}`, plus an `mcp` object (`detected`, `connected`, `needs_env`). It checks that each agent client found in this directory or your home directory has a Spicrawl MCP server that can authenticate: an entry that reads `${SPICRAWL_API_KEY}` does not count while that variable is unset in the environment `status` runs in (`mcp.needs_env`, with a repair that rewrites the entry using the saved key), and a project-level entry shadows a user-level one. **It now exits 1 when any check fails** (the API reports not ready, a worker kind has none ready, an MCP server is missing), where a not-ready API used to exit 0. Key and network failures still exit 3 and 10. ## 0.1.3 — 2026-10-05 diff --git a/DESIGN.md b/DESIGN.md index 3d5445a..74b7fe4 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -16,6 +16,13 @@ never invent a field the API does not accept (the API rejects unknown fields). `TERM=dumb` or `CI`. Switched off, the output is the plain text it always was. It never prints an engine or browser name; it says what the request asked for ("rendering JavaScript"), not which tier the API picked. + In JSON mode stderr is stricter still: the problem document of a failure and + nothing else, or empty. `output.Printer.Warn` and `Info` (warnings, "wrote N + bytes", "more results: --cursor", deprecation notices) write nothing there, + so `cmd --json 2>err.json; jq . err.json` always parses. The API's + `warnings` field is still in the JSON on stdout. A command that must tell an + agent something puts it in its JSON result, not on stderr. + `internal/cmd/json_stderr_test.go` runs every golden case against this. An empty result is not data either: in human mode an empty list (`sessions list`, `logs`, `batch list`, `batch results`) prints nothing on stdout and its one-line message ("no sessions") on stderr, so @@ -53,8 +60,9 @@ never invent a field the API does not accept (the API rejects unknown fields). retried POST could be billed twice. Commands that report per-item errors use `cmd.ExitCode(err)` to stay in step with the process exit code. 5. **Errors are the API's problem document.** In JSON mode a failure writes - the RFC 7807 body to stderr unchanged; in human mode `error:`, `hint:`, - `target status:`, `retryable:` and `request id:` lines. + the RFC 7807 body to stderr unchanged, as the only thing on stderr; in + human mode `error:`, `hint:`, `target status:`, `retryable:` and + `request id:` lines. 6. **`-` reads stdin.** Commands that take URLs accept `-` for one per line. 7. **Streams are JSONL.** Anything that yields many records supports `--jsonl` (one compact JSON object per line) so output can be piped. @@ -63,15 +71,16 @@ never invent a field the API does not accept (the API rejects unknown fields). stdout, JSON output carries `target_error`, and stderr gets one line. 9. **Secrets are printed once.** `browser url` writes the URL (which carries the key) once on stdout; its connection hints on stderr use - `$SPICRAWL_BROWSER_URL` and are suppressed by `-q`. A `ws://` URL warns on - stderr, always, that the key travels unencrypted. + `$SPICRAWL_BROWSER_URL` and are suppressed by `-q`. In human mode a `ws://` + URL warns on stderr, always, that the key travels unencrypted; in JSON mode + stderr stays empty and the `url` field shows the scheme. 10. **Engine routing is internal.** A customer sees three rendering tiers, `fetch`, `js` and `browser`, and never an engine or browser name. The API already reports tiers. `sessions create` and `browser url` take a tier with `--engine`; the engine names older CLIs took (which the API still honours) - are sent as typed, with a deprecation notice on stderr that does not repeat - them. `scrape` and `batch submit` keep `--engine` and the display flag next - to it hidden: the API ignores both and says so (`ENGINE_IGNORED`). In human output any + are sent as typed, with a deprecation notice on stderr (human mode only) + that does not repeat them. `scrape` and `batch submit` keep `--engine` and + the display flag next to it hidden: the API ignores both and says so (`ENGINE_IGNORED`). In human output any engine name the API sends is shown as its tier (`output.Tier`, `output.PublicText`). JSON and `--jsonl` are the API's data and stay exactly as sent. `internal/cmd/engine_names_test.go` locks the help, the human @@ -106,7 +115,7 @@ cmd/spicrawl/main.go entry point (go install github.com/OfficialSpicrawl/cli/cm internal/api HTTP client, Problem, DoWithRetry internal/config key/base resolution, config file internal/exitcode exit codes -internal/output Printer (JSON/human/JSONL, stderr info) +internal/output Printer (JSON/human/JSONL, stderr info and warnings in human mode only) internal/ui stderr spinner, summary line, error block, colour (off unless a terminal) internal/cmd one file per command group; each registers in init() ``` diff --git a/README.md b/README.md index 50dfecc..ee627db 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ Set up Spicrawl for yourself. My API key is . Only if your shell r What the two commands do, with no questions asked: -- `init --all --yes --api-key ` connects every detected client (`--all`; it conflicts with `--client`) without confirming (`--yes`). The key is checked with the API and saved to the config file as `spicrawl login` does, before any client file is written; a rejected key exits 3 and writes nothing. Then the key is written into each client's MCP config as is, in your **user-level** files (`~/.claude.json`, `~/.cursor/mcp.json`, ... with mode 0600; `--global` is implied), so the agents authenticate right after a restart with nothing to export. A key already saved in the config file (by `login` or an earlier `--api-key`) is embedded the same way when you run `init` without `--api-key`; a key that is only in `$SPICRAWL_API_KEY` stays a reference. Pass `--use-env=true` to keep the `${SPICRAWL_API_KEY}` reference instead, or `--global=false` to write the project files: those get the same key, a warning, and a line in the project's `.gitignore` when it has one that does not ignore them yet. The key is never printed, only masked (`spicrawl_live_ab12…cd34`), and API errors are masked too. The result list stays on stdout (JSON when it is not a terminal); a short report follows on stderr: +- `init --all --yes --api-key ` connects every detected client (`--all`; it conflicts with `--client`) without confirming (`--yes`). The key is checked with the API and saved to the config file as `spicrawl login` does, before any client file is written; a rejected key exits 3 and writes nothing. Then the key is written into each client's MCP config as is, in your **user-level** files (`~/.claude.json`, `~/.cursor/mcp.json`, ... with mode 0600; `--global` is implied), so the agents authenticate right after a restart with nothing to export. A key already saved in the config file (by `login` or an earlier `--api-key`) is embedded the same way when you run `init` without `--api-key`; a key that is only in `$SPICRAWL_API_KEY` stays a reference. Pass `--use-env=true` to keep the `${SPICRAWL_API_KEY}` reference instead, or `--global=false` to write the project files: those get the same key, a warning, and a line in the project's `.gitignore` when it has one that does not ignore them yet. The key is never printed, only masked (`spicrawl_live_ab12…cd34`), and API errors are masked too. The result list is on stdout (JSON when it is not a terminal). In human mode (a terminal, or `NO_COLOR`/`CI`) a short report follows on stderr; in JSON mode, which is what an agent gets, stderr stays empty and `connected`/`skipped` are the `action` of each `mcp` entry in the list: ```text connected: Claude Code, Cursor diff --git a/internal/cmd/agents_golden_test.go b/internal/cmd/agents_golden_test.go index 58b7217..8b20ff7 100644 --- a/internal/cmd/agents_golden_test.go +++ b/internal/cmd/agents_golden_test.go @@ -2,6 +2,7 @@ package cmd import ( "net/http" + "strings" "testing" ) @@ -47,14 +48,15 @@ func init() { c11.stderr = true // One prompt, no questions: --all --yes --api-key against a fake API that - // accepts or rejects the key. The report on stderr is machine-readable here. + // accepts or rejects the key. In JSON mode stderr is empty (or the problem + // document), so the report a person reads is golden in the human variants. setupClients := func(t *testing.T, e *goldenEnv) { goldenWrite(t, e.Dir, ".claude/settings.json", `{}`) goldenWrite(t, e.Dir, ".cursor/rules/x.mdc", `x`) } oneShot := func(name string, key int, noKey bool, args ...string) goldenCase { return goldenCase{ - name: name, args: append([]string{"init", "--all", "--yes", "--dir", "{dir}"}, args...), noKey: noKey, stderr: true, + name: name, args: append([]string{"init", "--all", "--yes", "--dir", "{dir}"}, args...), noKey: noKey, env: env, setup: setupClients, server: goldenServe(map[string]http.HandlerFunc{ "GET /skill.md": func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(gAgentSkill)) }, @@ -89,7 +91,7 @@ func init() { } savedKey.files = []string{"~/.claude.json", "~/.cursor/mcp.json"} rejected := oneShot("agents/init_api_key_rejected_exit3", 401, true, "--api-key", goldenFlagKey) - rejected.files = nil + rejected.files, rejected.stderr = nil, true noKeyAtAll := oneShot("agents/init_all_yes_no_key", 200, true) noKeyAtAll.files = nil envKey := oneShot("agents/init_all_yes_env_key", 200, false) @@ -98,6 +100,13 @@ func init() { both.stderr = true goldenAdd(allKey, projectKey, useEnv, savedKey, rejected, noKeyAtAll, envKey, both) + // What a person reads: the table on stdout and the report (connected, + // skipped, key, next steps) on stderr. + for _, c := range []goldenCase{allKey, projectKey, useEnv, savedKey, rejected, noKeyAtAll, envKey} { + c.name = "agents_human/" + strings.TrimPrefix(c.name, "agents/") + c.human, c.stderr, c.files = true, true, nil + goldenAdd(c) + } goldenAdd(c, c2, c3, c4, c5, c6, c7, c8, c9, c10, c11, agent("agents_human/mcp_print_all", true, nil, nil, "mcp", "install", "--client", "all", "--dir", "{dir}", "--print"), agent("agents_human/mcp_install_all", true, nil, nil, "mcp", "install", "--client", "all", "--dir", "{dir}"), diff --git a/internal/cmd/agents_test.go b/internal/cmd/agents_test.go index 13cea75..bc72c0d 100644 --- a/internal/cmd/agents_test.go +++ b/internal/cmd/agents_test.go @@ -255,6 +255,14 @@ func TestMCPInstallCodexAppendsToGlobal(t *testing.T) { } func TestMCPInstallClaudeGlobalEmbedsKeyAndWarns(t *testing.T) { + // The warning is for a person; JSON mode keeps stderr empty and says + // `secret: true` in the result. + dirH, _ := agentTestEnv(t) + _, humanErr, code := runCLIHuman(t, agentTestBase, "mcp", "install", "--client", "claude", "--global", "--dir", dirH) + if code != 0 || !strings.Contains(humanErr, "API key in plain text") || strings.Contains(humanErr, ".gitignore") { + t.Errorf("human: exit %d, stderr %q", code, humanErr) + } + dir, home := agentTestEnv(t) path := filepath.Join(home, ".claude.json") agentWrite(t, path, `{"numStartups": 3, "projects": {"/x": {}}}`) @@ -262,11 +270,8 @@ func TestMCPInstallClaudeGlobalEmbedsKeyAndWarns(t *testing.T) { if code != 0 { t.Fatalf("exit %d: %s", code, errOut) } - if !rs[0].Secret || !strings.Contains(errOut, "API key in plain text") { - t.Errorf("no secret warning: %+v %s", rs, errOut) - } - if strings.Contains(errOut, ".gitignore") { - t.Errorf("gitignore advice for a home file: %s", errOut) + if !rs[0].Secret || errOut != "" { + t.Errorf("JSON mode: %+v, stderr %q", rs, errOut) } m := agentReadJSON(t, path) if m["numStartups"] != float64(3) { @@ -280,7 +285,7 @@ func TestMCPInstallClaudeGlobalEmbedsKeyAndWarns(t *testing.T) { func TestMCPInstallLiteralInProjectSuggestsGitignore(t *testing.T) { dir, _ := agentTestEnv(t) - _, errOut, code := agentRun(t, "mcp", "install", "--client", "cursor", "--use-env=false", "--dir", dir) + _, errOut, code := runCLIHuman(t, agentTestBase, "mcp", "install", "--client", "cursor", "--use-env=false", "--dir", dir) if code != 0 { t.Fatalf("exit %d: %s", code, errOut) } @@ -378,15 +383,23 @@ func TestSkillInstallUpdatesExistingCopyInPlace(t *testing.T) { } func TestSkillInstallFallsBackToEmbedded(t *testing.T) { - dir, _ := agentTestEnv(t) + dirH, _ := agentTestEnv(t) srv, _ := newRecordingServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(500) }) t.Setenv(agentEnvSkillURL, srv.URL) + // The notice is for a person; JSON mode keeps stderr empty. + _, humanErr, code := runCLIHuman(t, agentTestBase, "skill", "install", "--client", "codex", "--global", "--dir", dirH) + if code != 0 || !strings.Contains(humanErr, "built into this CLI") { + t.Errorf("human: exit %d, stderr %q", code, humanErr) + } + + dir, _ := agentTestEnv(t) + t.Setenv(agentEnvSkillURL, srv.URL) rs, errOut, code := agentRun(t, "skill", "install", "--client", "codex", "--global", "--dir", dir) if code != 0 { t.Fatalf("exit %d: %s", code, errOut) } - if !strings.Contains(errOut, "built into this CLI") { - t.Errorf("stderr = %s", errOut) + if errOut != "" { + t.Errorf("JSON mode wrote prose to stderr: %q", errOut) } b, _ := os.ReadFile(rs[0].File) if !strings.Contains(string(b), "name: spicrawl\n") || !strings.Contains(string(b), "http://127.0.0.1:1/v1/scrape") { @@ -838,7 +851,8 @@ func TestMCPInstallSecretTightensExistingFile(t *testing.T) { if err := os.Chmod(path, 0o644); err != nil { t.Fatal(err) } - _, errOut, code := agentRun(t, "mcp", "install", "--client", "claude", "--global", "--dir", dir) + // A person is told on stderr; JSON mode keeps it empty and says so in the result's reason. + _, errOut, code := runCLIHuman(t, agentTestBase, "mcp", "install", "--client", "claude", "--global", "--dir", dir) if code != 0 { t.Fatalf("exit %d: %s", code, errOut) } diff --git a/internal/cmd/batch_results.go b/internal/cmd/batch_results.go index f1581d9..e85dd59 100644 --- a/internal/cmd/batch_results.go +++ b/internal/cmd/batch_results.go @@ -207,7 +207,7 @@ var batchContentCmd = &cobra.Command{ Short: "Print one finished item's document (GET /v1/batch/{id}/tasks/{seq}/content)", Long: `Fetch a single item's payload by its seq (0-based submission order, "seq" on a result line) without paging the whole result set. The API caps it at 512 KiB; -a cut payload is reported on stderr. +a cut payload is reported on stderr in human mode. In JSON mode the payload is printed as the API returned it (a JSON string holding the document). In human mode, and always with -o, the document itself is written. diff --git a/internal/cmd/batch_test.go b/internal/cmd/batch_test.go index ee9507d..3aebe10 100644 --- a/internal/cmd/batch_test.go +++ b/internal/cmd/batch_test.go @@ -260,9 +260,12 @@ func TestBatchResultsToFileSinglePage(t *testing.T) { if !strings.HasSuffix(string(b), "}\n") { t.Errorf("file: %q", b) } - if len(*reqs) != 1 || !strings.Contains(errOut, "--cursor ff") { + if len(*reqs) != 1 || errOut != "" { // JSON mode: stderr is the problem document or empty t.Errorf("reqs=%d stderr=%s", len(*reqs), errOut) } + if _, errOut, code = runCLIHuman(t, srv.URL, "batch", "results", "J8", "-o", path); code != 0 || !strings.Contains(errOut, "--cursor ff") { + t.Errorf("human: exit %d stderr %q", code, errOut) + } } func TestBatchResultsExpiredProblem(t *testing.T) { @@ -486,9 +489,12 @@ func TestBatchContent(t *testing.T) { if err := json.Unmarshal([]byte(out), &doc); err != nil || doc != "

hi

" { t.Errorf("JSON stdout %q (%v)", out, err) } - if (*reqs)[0].Path != "/v1/batch/J1/tasks/3/content" || !strings.Contains(errOut, "512 KiB") { + if (*reqs)[0].Path != "/v1/batch/J1/tasks/3/content" || errOut != "" { // JSON mode: no prose on stderr t.Errorf("path %s stderr %s", (*reqs)[0].Path, errOut) } + if _, errOut, code = runCLIHuman(t, srv.URL, "batch", "content", "J1", "3"); code != 0 || !strings.Contains(errOut, "512 KiB") { + t.Errorf("human: exit %d stderr %q", code, errOut) + } path := filepath.Join(t.TempDir(), "page.html") out, errOut, code = batchRunCLI(t, srv.URL, "batch", "content", "J1", "3", "-o", path) diff --git a/internal/cmd/browser_test.go b/internal/cmd/browser_test.go index 9a619c1..7006aea 100644 --- a/internal/cmd/browser_test.go +++ b/internal/cmd/browser_test.go @@ -109,12 +109,13 @@ func TestBrowserURLLegacyEngineNamesStillWork(t *testing.T) { api := &fakeTokenAPI{} srv := api.server(t) _, errb, code := runCLI(t, srv.URL, "browser", "url", "--engine", legacy) - if code != 0 { - t.Fatalf("%s: exit %d: %s", legacy, code, errb) + if code != 0 || errb != "" { // JSON mode: no prose on stderr + t.Fatalf("%s: exit %d: %q", legacy, code, errb) } if api.body["engine"] != legacy { t.Errorf("%s: sent engine %v", legacy, api.body["engine"]) } + _, errb, _ = runCLIHuman(t, srv.URL, "browser", "url", "--engine", legacy) if !strings.Contains(errb, "warning: --engine: this value is deprecated; use browser or js") || strings.Contains(errb, legacy) { t.Errorf("%s: stderr %q", legacy, errb) } @@ -188,8 +189,12 @@ func TestBrowserURLHumanHintsAndWSWarning(t *testing.T) { // runCLIHuman is runCLI without --json, in human mode, with extra global args. func runCLIHuman(t *testing.T, baseURL string, args ...string) (string, string, int) { t.Helper() - t.Setenv("SPICRAWL_CONFIG", t.TempDir()+"/config.json") - t.Setenv("SPICRAWL_API_KEY", "spicrawl_test_key") + cfg := t.TempDir() + "/config.json" + if testConfig != "" { + cfg = testConfig + } + t.Setenv("SPICRAWL_CONFIG", cfg) + t.Setenv("SPICRAWL_API_KEY", testKey) var out, errb bytes.Buffer stdout, stderr, humanOutput = &out, &errb, true defer func() { diff --git a/internal/cmd/initcmd.go b/internal/cmd/initcmd.go index f953ec9..e13fdc1 100644 --- a/internal/cmd/initcmd.go +++ b/internal/cmd/initcmd.go @@ -188,8 +188,14 @@ func runInit(cmd *cobra.Command, _ []string) error { } } if pending > 0 && !initFlags.yes { - initPrintPlan(p.Err, errUI(p), rs, s) + // JSON mode keeps stderr for the problem document, unless a person is about to be asked. + if !p.JSON || initStdinIsTerminal() { + initPrintPlan(p.Err, errUI(p), rs, s) + } if !initStdinIsTerminal() { + if p.JSON { + return Usagef("init changes files and stdin is not a terminal: re-run with --yes to apply the changes") + } return Usagef("init changes files and stdin is not a terminal: re-run with --yes to apply the plan above") } ok, err := initConfirm(p.Err, initStdin) diff --git a/internal/cmd/json_stderr_test.go b/internal/cmd/json_stderr_test.go new file mode 100644 index 0000000..3209226 --- /dev/null +++ b/internal/cmd/json_stderr_test.go @@ -0,0 +1,90 @@ +package cmd + +// In JSON mode stderr is the problem document of a failure and nothing else +// (DESIGN.md, "Errors are the API's problem document"), so that +// +// spicrawl ... --json 2>err.json; jq . err.json +// +// always parses. This runs every golden case that is not a human-mode one and +// holds its stderr to that: empty, or exactly one JSON object. A case that +// fails (non-zero exit) must have the object. + +import ( + "encoding/json" + "fmt" + "io" + "strings" + "testing" +) + +// jsonStderrKnownGaps are cases that still break the rule, with the reason. +// The test fails when one starts to pass, so the entry gets deleted. +var jsonStderrKnownGaps = map[string]string{} + +// jsonStderrHumanByDesign are cases that run in JSON mode by the harness but +// leave it on purpose, so stderr is prose for a person. +var jsonStderrHumanByDesign = map[string]string{ + "browser/url_quiet_prints_bare_url": "browser url -q prints the bare URL even when piped (only --json overrides)", +} + +// jsonStderrProblem reports why stderr is not "empty or exactly one JSON object". +func jsonStderrProblem(stderr string, exit int) error { + if strings.TrimSpace(stderr) == "" { + if exit != 0 { + return fmt.Errorf("exit %d with an empty stderr: want the problem document", exit) + } + return nil + } + dec := json.NewDecoder(strings.NewReader(stderr)) + var doc map[string]any + if err := dec.Decode(&doc); err != nil { + return fmt.Errorf("stderr does not start with a JSON object: %v", err) + } + if _, err := dec.Token(); err != io.EOF { + return fmt.Errorf("stderr has more than one JSON document (or trailing text)") + } + return nil +} + +func TestJSONStderrIsOneProblemDocument(t *testing.T) { + for _, c := range goldenCases { + if _, ok := jsonStderrHumanByDesign[c.name]; ok || c.human { + continue + } + t.Run(strings.ReplaceAll(c.name, "/", "."), func(t *testing.T) { + r, _ := goldenExec(t, c, nil) + err := jsonStderrProblem(r.stderr, r.code) + if gap, ok := jsonStderrKnownGaps[c.name]; ok { + if err == nil { + t.Errorf("this case is clean now: delete it from jsonStderrKnownGaps (%s)", gap) + } + return + } + if err != nil { + t.Errorf("%v\nstderr: %q", err, r.stderr) + } + }) + } +} + +func TestJSONStderrCheck(t *testing.T) { + for _, c := range []struct { + stderr string + exit int + ok bool + }{ + {"", 0, true}, + {"{\"error\":\"x\",\"exit_code\":2}\n", 2, true}, + {"{\n \"code\": \"ERR::AUTH::INVALID_KEY\"\n}\n", 3, true}, + {"", 3, false}, // failed without the document + {"warning: x\n{\"error\":\"x\"}\n", 2, false}, // prose first + {"{\"error\":\"x\"}\nhint: y\n", 2, false}, // prose after + {"{\"a\":1}\n{\"b\":2}\n", 2, false}, // two documents + {"warning: this output is live\n", 0, false}, // prose on success + {"[1]\n", 2, false}, // not an object + } { + if err := jsonStderrProblem(c.stderr, c.exit); (err == nil) != c.ok { + t.Errorf("%q (exit %d): got %v, want ok=%v", c.stderr, c.exit, err, c.ok) + } + } +} diff --git a/internal/cmd/mcp_fix_test.go b/internal/cmd/mcp_fix_test.go index dd83e6c..f8f4c88 100644 --- a/internal/cmd/mcp_fix_test.go +++ b/internal/cmd/mcp_fix_test.go @@ -71,12 +71,19 @@ func TestMCPInstallAcceptsJSONCAndBOM(t *testing.T) { orig := "\xef\xbb\xbf{\n // my servers\n \"servers\": {\n \"other\": {\"url\": \"http://x//y\", /* inline */ },\n },\n}\n" agentWrite(t, path, orig) + // The warning about dropped comments is for a person: JSON mode keeps stderr empty. + other := filepath.Join(t.TempDir(), ".vscode", "mcp.json") + agentWrite(t, other, orig) + if _, errOut, code := runCLIHuman(t, agentTestBase, "mcp", "install", "--client", "vscode", "--dir", filepath.Dir(filepath.Dir(other))); code != 0 || !strings.Contains(errOut, "comments") { + t.Errorf("human: exit %d, no warning about dropped comments: %q", code, errOut) + } + rs, errOut, code := agentRun(t, "mcp", "install", "--client", "vscode", "--dir", dir) if code != 0 || rs[0].Action != "updated" { t.Fatalf("exit %d %+v: %s", code, rs, errOut) } - if !strings.Contains(errOut, "comments") { - t.Errorf("no warning about dropped comments: %s", errOut) + if errOut != "" { + t.Errorf("JSON mode wrote prose to stderr: %q", errOut) } servers := agentReadJSON(t, path)["servers"].(map[string]any) if servers["spicrawl"] == nil || servers["other"].(map[string]any)["url"] != "http://x//y" { diff --git a/internal/cmd/onboarding_test.go b/internal/cmd/onboarding_test.go index 8239ae2..d4625d5 100644 --- a/internal/cmd/onboarding_test.go +++ b/internal/cmd/onboarding_test.go @@ -109,6 +109,25 @@ func TestInitAllYesAPIKey(t *testing.T) { if savedKey(t) != onboardKey { t.Errorf("key not saved to the config file") } + // JSON mode: stderr is empty, whatever happened (DESIGN.md); the report is for a person. + if errb != "" { + t.Errorf("JSON mode wrote to stderr:\n%s", errb) + } + noKeyIn(t, onboardKey, "stdout", out) + noKeyIn(t, onboardKey, "stderr", errb) +} + +// What a person (or a CI log) reads after the run, in human mode: connected, +// skipped, the masked key and the next steps, as stable "name: value" lines. +func TestInitReportLines(t *testing.T) { + testKey = "" // $SPICRAWL_API_KEY is not exported in this shell + t.Cleanup(func() { testKey = "spicrawl_test_key" }) + base := onboardAPI(t) + dir := onboardProject(t) + _, errb, code := runCLIHuman(t, base, "init", "--all", "--yes", "--api-key", onboardKey, "--dir", dir) + if code != 0 { + t.Fatalf("exit %d: %s", code, errb) + } for _, want := range []string{ "connected: Claude Code, Cursor\n", "skipped: VS Code (Copilot) (not detected), Codex (not detected)\n", @@ -121,13 +140,11 @@ func TestInitAllYesAPIKey(t *testing.T) { t.Errorf("stderr lacks %q:\n%s", want, errb) } } - // Nothing to export: the key is in the configs. for _, not := range []string{"export", "note:"} { if strings.Contains(errb, not) { t.Errorf("stderr has %q although the key is embedded:\n%s", not, errb) } } - noKeyIn(t, onboardKey, "stdout", out) noKeyIn(t, onboardKey, "stderr", errb) } @@ -138,7 +155,7 @@ func TestInitAPIKeyUseEnvTrueReferencesTheEnvironment(t *testing.T) { t.Cleanup(func() { testKey = "spicrawl_test_key" }) base := onboardAPI(t) dir := onboardProject(t) - _, errb, code := runCLI(t, base, "init", "--all", "--yes", "--api-key", onboardKey, "--use-env=true", "--dir", dir) + _, errb, code := runCLIHuman(t, base, "init", "--all", "--yes", "--api-key", onboardKey, "--use-env=true", "--dir", dir) if code != 0 { t.Fatalf("exit %d: %s", code, errb) } @@ -184,9 +201,8 @@ func TestInitAPIKeyProjectFilesAreGitignored(t *testing.T) { if _, err := os.Stat(filepath.Join(dir, ".gitignore.bak")); err == nil { t.Error(".gitignore.bak written") } - // The warning stays; the "add it to .gitignore" advice is moot. - if !strings.Contains(errb, ".mcp.json now contains your API key in plain text") || strings.Contains(errb, ">> .gitignore") { - t.Errorf("stderr:\n%s", errb) + if errb != "" { + t.Errorf("JSON mode wrote to stderr:\n%s", errb) } var rs []agentResult _ = json.Unmarshal([]byte(out), &rs) @@ -213,7 +229,7 @@ func TestInitAPIKeyProjectFilesAreGitignored(t *testing.T) { func TestInitAPIKeyProjectFilesWithoutGitignoreWarn(t *testing.T) { base := onboardAPI(t) dir := onboardProject(t) - _, errb, code := runCLI(t, base, "init", "--all", "--yes", "--api-key", onboardKey, "--global=false", "--dir", dir) + _, errb, code := runCLIHuman(t, base, "init", "--all", "--yes", "--api-key", onboardKey, "--global=false", "--dir", dir) if code != 0 { t.Fatalf("exit %d: %s", code, errb) } @@ -353,7 +369,7 @@ func TestInitAllConflictsWithClient(t *testing.T) { // check it (no network) or copy it into the config file. func TestInitEnvKeyIsNotCheckedOrSaved(t *testing.T) { dir := onboardProject(t) - _, errb, code := runCLI(t, "http://127.0.0.1:1", "init", "--all", "--yes", "--dir", dir) + _, errb, code := runCLIHuman(t, "http://127.0.0.1:1", "init", "--all", "--yes", "--dir", dir) if code != 0 { t.Fatalf("exit %d: %s", code, errb) } @@ -388,7 +404,7 @@ func TestInitSavedKeyIsEmbedded(t *testing.T) { usesSavedKey(t) base := onboardAPI(t) dir := onboardProject(t) - out, errb, code := runCLI(t, base, "init", "--all", "--yes", "--dir", dir) + out, errb, code := runCLIHuman(t, base, "init", "--all", "--yes", "--dir", dir) if code != 0 { t.Fatalf("exit %d: %s", code, errb) } @@ -416,7 +432,7 @@ func TestInitSavedKeyIsEmbedded(t *testing.T) { func TestInitSavedKeyWithUseEnvTrueReferencesTheEnvironment(t *testing.T) { usesSavedKey(t) dir := onboardProject(t) - _, errb, code := runCLI(t, onboardAPI(t), "init", "--all", "--yes", "--use-env=true", "--dir", dir) + _, errb, code := runCLIHuman(t, onboardAPI(t), "init", "--all", "--yes", "--use-env=true", "--dir", dir) if code != 0 { t.Fatalf("exit %d: %s", code, errb) } @@ -428,7 +444,7 @@ func TestInitSavedKeyWithUseEnvTrueReferencesTheEnvironment(t *testing.T) { func TestInitNextStepUsesNpxWhenStartedByNpx(t *testing.T) { t.Setenv("npm_command", "exec") dir := onboardProject(t) - _, errb, code := runCLI(t, "http://127.0.0.1:1", "init", "--all", "--yes", "--dir", dir) + _, errb, code := runCLIHuman(t, "http://127.0.0.1:1", "init", "--all", "--yes", "--dir", dir) if code != 0 { t.Fatalf("exit %d: %s", code, errb) } diff --git a/internal/cmd/sessions_test.go b/internal/cmd/sessions_test.go index e24d6ac..dda9197 100644 --- a/internal/cmd/sessions_test.go +++ b/internal/cmd/sessions_test.go @@ -51,14 +51,15 @@ func TestSessionsCreateEngineTiersAndLegacyNames(t *testing.T) { _, _ = w.Write([]byte(sessionsTestBody)) }) _, errb, code := runCLI(t, srv.URL, "sessions", "create", "--engine", legacy) - if code != 0 { - t.Fatalf("%s: exit %d: %s", legacy, code, errb) + if code != 0 || errb != "" { // JSON mode: no prose on stderr + t.Fatalf("%s: exit %d: %q", legacy, code, errb) } var body map[string]any _ = json.Unmarshal((*reqs)[0].Body, &body) if body["engine"] != legacy { t.Errorf("%s: sent %v", legacy, body["engine"]) } + _, errb, _ = runCLIHuman(t, srv.URL, "sessions", "create", "--engine", legacy) if !strings.Contains(errb, "warning: --engine: this value is deprecated; use fetch, js or browser") || strings.Contains(errb, legacy) { t.Errorf("%s: stderr %q", legacy, errb) } diff --git a/internal/cmd/testdata/golden/agents/init_api_key_rejected_exit3.stderr b/internal/cmd/testdata/golden/agents/init_api_key_rejected_exit3.stderr index 9533ea2..cbdc3b7 100644 --- a/internal/cmd/testdata/golden/agents/init_api_key_rejected_exit3.stderr +++ b/internal/cmd/testdata/golden/agents/init_api_key_rejected_exit3.stderr @@ -1,5 +1,3 @@ -clients: Claude Code, Cursor -MCP server: http://SERVER/mcp { "type": "https://docs.spicrawl.com/errors", "title": "Unauthorized", diff --git a/internal/cmd/testdata/golden/agents/init_needs_yes_without_terminal.stderr b/internal/cmd/testdata/golden/agents/init_needs_yes_without_terminal.stderr index 2f9e2aa..1eb35b3 100644 --- a/internal/cmd/testdata/golden/agents/init_needs_yes_without_terminal.stderr +++ b/internal/cmd/testdata/golden/agents/init_needs_yes_without_terminal.stderr @@ -1,9 +1,4 @@ -clients: Claude Code -MCP server: http://127.0.0.1:1/mcp -plan: - create claude mcp .mcp.json - create claude skill .claude/skills/spicrawl/SKILL.md { - "error": "init changes files and stdin is not a terminal: re-run with --yes to apply the plan above", + "error": "init changes files and stdin is not a terminal: re-run with --yes to apply the changes", "exit_code": 2 } diff --git a/internal/cmd/testdata/golden/agents_human/init_all_yes_api_key.exit b/internal/cmd/testdata/golden/agents_human/init_all_yes_api_key.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/internal/cmd/testdata/golden/agents_human/init_all_yes_api_key.exit @@ -0,0 +1 @@ +0 diff --git a/internal/cmd/testdata/golden/agents/init_all_yes_api_key.stderr b/internal/cmd/testdata/golden/agents_human/init_all_yes_api_key.stderr similarity index 100% rename from internal/cmd/testdata/golden/agents/init_all_yes_api_key.stderr rename to internal/cmd/testdata/golden/agents_human/init_all_yes_api_key.stderr diff --git a/internal/cmd/testdata/golden/agents_human/init_all_yes_api_key.stdout b/internal/cmd/testdata/golden/agents_human/init_all_yes_api_key.stdout new file mode 100644 index 0000000..7d2f817 --- /dev/null +++ b/internal/cmd/testdata/golden/agents_human/init_all_yes_api_key.stdout @@ -0,0 +1,5 @@ +CLIENT KIND ACTION FILE NOTE +claude mcp created ~/.claude.json contains the literal API key +cursor mcp created ~/.cursor/mcp.json contains the literal API key +claude skill created ~/.claude/skills/spicrawl/SKILL.md +cursor skill skipped ~/.claude/skills/spicrawl/SKILL.md already reads this skill diff --git a/internal/cmd/testdata/golden/agents_human/init_all_yes_api_key_project.exit b/internal/cmd/testdata/golden/agents_human/init_all_yes_api_key_project.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/internal/cmd/testdata/golden/agents_human/init_all_yes_api_key_project.exit @@ -0,0 +1 @@ +0 diff --git a/internal/cmd/testdata/golden/agents/init_all_yes_api_key_project.stderr b/internal/cmd/testdata/golden/agents_human/init_all_yes_api_key_project.stderr similarity index 100% rename from internal/cmd/testdata/golden/agents/init_all_yes_api_key_project.stderr rename to internal/cmd/testdata/golden/agents_human/init_all_yes_api_key_project.stderr diff --git a/internal/cmd/testdata/golden/agents_human/init_all_yes_api_key_project.stdout b/internal/cmd/testdata/golden/agents_human/init_all_yes_api_key_project.stdout new file mode 100644 index 0000000..f23bdaa --- /dev/null +++ b/internal/cmd/testdata/golden/agents_human/init_all_yes_api_key_project.stdout @@ -0,0 +1,6 @@ +CLIENT KIND ACTION FILE NOTE +claude mcp created .mcp.json contains the literal API key +cursor mcp created .cursor/mcp.json contains the literal API key +claude skill created .claude/skills/spicrawl/SKILL.md +cursor skill skipped .claude/skills/spicrawl/SKILL.md already reads this skill + gitignore updated .gitignore keeps .mcp.json, .cursor/mcp.json (holds the API key) out of git diff --git a/internal/cmd/testdata/golden/agents_human/init_all_yes_api_key_use_env.exit b/internal/cmd/testdata/golden/agents_human/init_all_yes_api_key_use_env.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/internal/cmd/testdata/golden/agents_human/init_all_yes_api_key_use_env.exit @@ -0,0 +1 @@ +0 diff --git a/internal/cmd/testdata/golden/agents/init_all_yes_api_key_use_env.stderr b/internal/cmd/testdata/golden/agents_human/init_all_yes_api_key_use_env.stderr similarity index 100% rename from internal/cmd/testdata/golden/agents/init_all_yes_api_key_use_env.stderr rename to internal/cmd/testdata/golden/agents_human/init_all_yes_api_key_use_env.stderr diff --git a/internal/cmd/testdata/golden/agents_human/init_all_yes_api_key_use_env.stdout b/internal/cmd/testdata/golden/agents_human/init_all_yes_api_key_use_env.stdout new file mode 100644 index 0000000..3073b3e --- /dev/null +++ b/internal/cmd/testdata/golden/agents_human/init_all_yes_api_key_use_env.stdout @@ -0,0 +1,5 @@ +CLIENT KIND ACTION FILE NOTE +claude mcp created .mcp.json +cursor mcp created .cursor/mcp.json +claude skill created .claude/skills/spicrawl/SKILL.md +cursor skill skipped .claude/skills/spicrawl/SKILL.md already reads this skill diff --git a/internal/cmd/testdata/golden/agents_human/init_all_yes_env_key.exit b/internal/cmd/testdata/golden/agents_human/init_all_yes_env_key.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/internal/cmd/testdata/golden/agents_human/init_all_yes_env_key.exit @@ -0,0 +1 @@ +0 diff --git a/internal/cmd/testdata/golden/agents/init_all_yes_env_key.stderr b/internal/cmd/testdata/golden/agents_human/init_all_yes_env_key.stderr similarity index 100% rename from internal/cmd/testdata/golden/agents/init_all_yes_env_key.stderr rename to internal/cmd/testdata/golden/agents_human/init_all_yes_env_key.stderr diff --git a/internal/cmd/testdata/golden/agents_human/init_all_yes_env_key.stdout b/internal/cmd/testdata/golden/agents_human/init_all_yes_env_key.stdout new file mode 100644 index 0000000..3073b3e --- /dev/null +++ b/internal/cmd/testdata/golden/agents_human/init_all_yes_env_key.stdout @@ -0,0 +1,5 @@ +CLIENT KIND ACTION FILE NOTE +claude mcp created .mcp.json +cursor mcp created .cursor/mcp.json +claude skill created .claude/skills/spicrawl/SKILL.md +cursor skill skipped .claude/skills/spicrawl/SKILL.md already reads this skill diff --git a/internal/cmd/testdata/golden/agents_human/init_all_yes_no_key.exit b/internal/cmd/testdata/golden/agents_human/init_all_yes_no_key.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/internal/cmd/testdata/golden/agents_human/init_all_yes_no_key.exit @@ -0,0 +1 @@ +0 diff --git a/internal/cmd/testdata/golden/agents/init_all_yes_no_key.stderr b/internal/cmd/testdata/golden/agents_human/init_all_yes_no_key.stderr similarity index 100% rename from internal/cmd/testdata/golden/agents/init_all_yes_no_key.stderr rename to internal/cmd/testdata/golden/agents_human/init_all_yes_no_key.stderr diff --git a/internal/cmd/testdata/golden/agents_human/init_all_yes_no_key.stdout b/internal/cmd/testdata/golden/agents_human/init_all_yes_no_key.stdout new file mode 100644 index 0000000..3073b3e --- /dev/null +++ b/internal/cmd/testdata/golden/agents_human/init_all_yes_no_key.stdout @@ -0,0 +1,5 @@ +CLIENT KIND ACTION FILE NOTE +claude mcp created .mcp.json +cursor mcp created .cursor/mcp.json +claude skill created .claude/skills/spicrawl/SKILL.md +cursor skill skipped .claude/skills/spicrawl/SKILL.md already reads this skill diff --git a/internal/cmd/testdata/golden/agents_human/init_all_yes_saved_key.exit b/internal/cmd/testdata/golden/agents_human/init_all_yes_saved_key.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/internal/cmd/testdata/golden/agents_human/init_all_yes_saved_key.exit @@ -0,0 +1 @@ +0 diff --git a/internal/cmd/testdata/golden/agents/init_all_yes_saved_key.stderr b/internal/cmd/testdata/golden/agents_human/init_all_yes_saved_key.stderr similarity index 100% rename from internal/cmd/testdata/golden/agents/init_all_yes_saved_key.stderr rename to internal/cmd/testdata/golden/agents_human/init_all_yes_saved_key.stderr diff --git a/internal/cmd/testdata/golden/agents_human/init_all_yes_saved_key.stdout b/internal/cmd/testdata/golden/agents_human/init_all_yes_saved_key.stdout new file mode 100644 index 0000000..7d2f817 --- /dev/null +++ b/internal/cmd/testdata/golden/agents_human/init_all_yes_saved_key.stdout @@ -0,0 +1,5 @@ +CLIENT KIND ACTION FILE NOTE +claude mcp created ~/.claude.json contains the literal API key +cursor mcp created ~/.cursor/mcp.json contains the literal API key +claude skill created ~/.claude/skills/spicrawl/SKILL.md +cursor skill skipped ~/.claude/skills/spicrawl/SKILL.md already reads this skill diff --git a/internal/cmd/testdata/golden/agents_human/init_api_key_rejected_exit3.exit b/internal/cmd/testdata/golden/agents_human/init_api_key_rejected_exit3.exit new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/internal/cmd/testdata/golden/agents_human/init_api_key_rejected_exit3.exit @@ -0,0 +1 @@ +3 diff --git a/internal/cmd/testdata/golden/agents_human/init_api_key_rejected_exit3.stderr b/internal/cmd/testdata/golden/agents_human/init_api_key_rejected_exit3.stderr new file mode 100644 index 0000000..22df504 --- /dev/null +++ b/internal/cmd/testdata/golden/agents_human/init_api_key_rejected_exit3.stderr @@ -0,0 +1,5 @@ +clients: Claude Code, Cursor +MCP server: http://SERVER/mcp +error: ERR::AUTH::INVALID_KEY: the API key is not valid +hint: check the request and try again +request id: (spicrawl logs get ) diff --git a/internal/cmd/testdata/golden/agents_human/init_api_key_rejected_exit3.stdout b/internal/cmd/testdata/golden/agents_human/init_api_key_rejected_exit3.stdout new file mode 100644 index 0000000..e69de29 diff --git a/internal/cmd/testdata/golden/scrape/target_503_output_file_not_written.stderr b/internal/cmd/testdata/golden/scrape/target_503_output_file_not_written.stderr index 85c31dc..af601fb 100644 --- a/internal/cmd/testdata/golden/scrape/target_503_output_file_not_written.stderr +++ b/internal/cmd/testdata/golden/scrape/target_503_output_file_not_written.stderr @@ -1,4 +1,3 @@ -warning: not writing /page.html: the target failed (--allowed-status accepts it) { "error": "target returned 503 (pass --allowed-status 503 to accept it)", "exit_code": 6 diff --git a/internal/output/output.go b/internal/output/output.go index 89791f4..73746ca 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -108,9 +108,10 @@ func (p *Printer) Table(headers []string, rows [][]string) { } // Info writes a progress or status line to stderr unless --quiet. Like Warn it -// shows server prose with tier names (PublicText). +// shows server prose with tier names (PublicText), and like Warn it writes +// nothing in JSON mode. func (p *Printer) Info(format string, a ...any) { - if p.Quiet { + if p.Quiet || p.JSON { return } ui.Fprintf(p.Err, "%s", PublicText(fmt.Sprintf(format+"\n", a...))) @@ -118,6 +119,14 @@ func (p *Printer) Info(format string, a ...any) { // Warn writes a warning to stderr, even with --quiet. Server prose in it is // shown with tier names, never engine names (PublicText). +// +// In JSON mode it writes nothing: stderr there is the problem document of a +// failure and otherwise empty, so `cmd --json 2>err.json; jq . err.json` always +// parses (DESIGN.md, "Errors are the API's problem document"). The API's own +// warnings are already in the JSON on stdout. func (p *Printer) Warn(format string, a ...any) { + if p.JSON { + return + } ui.Fprintf(p.Err, "%s", PublicText(fmt.Sprintf("warning: "+format+"\n", a...))) } diff --git a/internal/output/stderr_test.go b/internal/output/stderr_test.go new file mode 100644 index 0000000..cf4707f --- /dev/null +++ b/internal/output/stderr_test.go @@ -0,0 +1,29 @@ +package output + +import ( + "bytes" + "testing" +) + +// In JSON mode stderr is the problem document or empty, so Warn and Info write +// nothing there; in human mode they are unchanged (Warn survives --quiet). +func TestWarnAndInfoByMode(t *testing.T) { + for _, c := range []struct { + name string + json, quiet bool + wantWarn string + wantInfo string + }{ + {"json", true, false, "", ""}, + {"json quiet", true, true, "", ""}, + {"human", false, false, "warning: w 1\n", "i 2\n"}, + {"human quiet", false, true, "warning: w 1\n", ""}, + } { + var warn, info bytes.Buffer + (&Printer{JSON: c.json, Quiet: c.quiet, Err: &warn}).Warn("w %d", 1) + (&Printer{JSON: c.json, Quiet: c.quiet, Err: &info}).Info("i %d", 2) + if warn.String() != c.wantWarn || info.String() != c.wantInfo { + t.Errorf("%s: warn %q info %q, want %q and %q", c.name, warn.String(), info.String(), c.wantWarn, c.wantInfo) + } + } +}