vocalize-cli 0.2.0__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/PKG-INFO +3 -2
  2. {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/README.md +2 -1
  3. {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/tests/test_wizard.py +93 -7
  4. {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/vocalize/__init__.py +1 -1
  5. {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/vocalize/wizard.py +114 -46
  6. {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/.env.example +0 -0
  7. {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/.github/workflows/ci.yml +0 -0
  8. {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/.gitignore +0 -0
  9. {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/LICENSE +0 -0
  10. {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/hooks/claude_stop_hook.py +0 -0
  11. {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/hooks/install_hook.py +0 -0
  12. {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/pyproject.toml +0 -0
  13. {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/tests/conftest.py +0 -0
  14. {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/tests/test_audio.py +0 -0
  15. {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/tests/test_claude_stop_hook.py +0 -0
  16. {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/tests/test_cli.py +0 -0
  17. {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/tests/test_config.py +0 -0
  18. {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/tests/test_install_hook.py +0 -0
  19. {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/tests/test_preprocess.py +0 -0
  20. {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/tests/test_tts.py +0 -0
  21. {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/vocalize/__main__.py +0 -0
  22. {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/vocalize/audio.py +0 -0
  23. {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/vocalize/cli.py +0 -0
  24. {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/vocalize/config.py +0 -0
  25. {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/vocalize/exceptions.py +0 -0
  26. {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/vocalize/preprocess.py +0 -0
  27. {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/vocalize/tts.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: vocalize-cli
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: A CLI that turns text, markdown, or piped stdin into speech via the ElevenLabs API, with markdown-table-aware preprocessing.
5
5
  Project-URL: Homepage, https://github.com/matthager12-collab/vocalize
6
6
  Project-URL: Repository, https://github.com/matthager12-collab/vocalize
@@ -133,7 +133,8 @@ the highlighted one), model, and speed — shows you a summary, and writes the
133
133
  config file below. Unrecognised top-level keys already in that file are
134
134
  carried through; comments and layout are not preserved. A file containing a
135
135
  TOML table or array is left alone entirely, with a message saying to edit it
136
- by hand.
136
+ by hand. The wizard paints on the controlling terminal rather than on stdout,
137
+ so it still works under output-capturing wrappers like `op run`.
137
138
 
138
139
  ```bash
139
140
  vocalize config
@@ -100,7 +100,8 @@ the highlighted one), model, and speed — shows you a summary, and writes the
100
100
  config file below. Unrecognised top-level keys already in that file are
101
101
  carried through; comments and layout are not preserved. A file containing a
102
102
  TOML table or array is left alone entirely, with a message saying to edit it
103
- by hand.
103
+ by hand. The wizard paints on the controlling terminal rather than on stdout,
104
+ so it still works under output-capturing wrappers like `op run`.
104
105
 
105
106
  ```bash
106
107
  vocalize config
@@ -1,3 +1,4 @@
1
+ import io
1
2
  import sys
2
3
  from types import SimpleNamespace
3
4
 
@@ -27,6 +28,17 @@ class FakeStdin:
27
28
  return self._tty
28
29
 
29
30
 
31
+ class FakeTTY(io.StringIO):
32
+ """A /dev/tty stand-in that stays readable after the wizard closes it."""
33
+
34
+ def __init__(self):
35
+ super().__init__()
36
+ self.closed_by_wizard = False
37
+
38
+ def close(self):
39
+ self.closed_by_wizard = True
40
+
41
+
30
42
  class Keyboard:
31
43
  """A scripted keyboard: each getchar() call returns the next key."""
32
44
 
@@ -42,8 +54,22 @@ class Keyboard:
42
54
  return key
43
55
 
44
56
 
45
- def _setup(monkeypatch, tmp_path, keys, *, voices=None, prompts=(), confirm=True, api_key="fake-key"):
46
- """Point the wizard at a throwaway config file and a scripted keyboard."""
57
+ def _setup(
58
+ monkeypatch,
59
+ tmp_path,
60
+ keys,
61
+ *,
62
+ voices=None,
63
+ prompts=(),
64
+ confirm=True,
65
+ api_key="fake-key",
66
+ patch_ui=True,
67
+ ):
68
+ """Point the wizard at a throwaway config file and a scripted keyboard.
69
+
70
+ patch_ui=True aims the wizard's UI stream at sys.stdout, so capsys sees
71
+ the frames; the tty-seam tests pass False and drive the real factory.
72
+ """
47
73
  monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
48
74
  for var in ("VOCALIZE_VOICE", "VOCALIZE_MODEL", "VOCALIZE_SPEED"):
49
75
  monkeypatch.delenv(var, raising=False)
@@ -51,19 +77,21 @@ def _setup(monkeypatch, tmp_path, keys, *, voices=None, prompts=(), confirm=True
51
77
  monkeypatch.setenv("LINES", "40")
52
78
 
53
79
  monkeypatch.setattr(sys, "stdin", FakeStdin(tty=True))
54
- monkeypatch.setattr(click, "clear", lambda: None)
80
+ if patch_ui:
81
+ # Resolved lazily: sys.stdout is whatever capsys has installed.
82
+ monkeypatch.setattr(wizard, "_open_ui_stream", lambda: (sys.stdout, False))
55
83
  keyboard = Keyboard(keys)
56
84
  monkeypatch.setattr(click, "getchar", keyboard)
57
85
 
58
86
  answers = list(prompts)
59
87
 
60
- def fake_prompt(text, **kwargs):
88
+ def fake_ask(ui, label, **kwargs):
61
89
  if not answers:
62
- raise AssertionError(f"unexpected prompt: {text}")
90
+ raise AssertionError(f"unexpected prompt: {label}")
63
91
  return answers.pop(0)
64
92
 
65
- monkeypatch.setattr(click, "prompt", fake_prompt)
66
- monkeypatch.setattr(click, "confirm", lambda *args, **kwargs: confirm)
93
+ monkeypatch.setattr(wizard, "_ask", fake_ask)
94
+ monkeypatch.setattr(wizard, "_confirm", lambda *args, **kwargs: confirm)
67
95
 
68
96
  if api_key is None:
69
97
 
@@ -282,6 +310,64 @@ def test_keep_current_names_the_file_value_not_the_env_var(monkeypatch, tmp_path
282
310
  assert ctx.path.read_text() == 'voice = "from-the-file"\n'
283
311
 
284
312
 
313
+ def _piped(monkeypatch):
314
+ """stdout relayed down a pipe (`op run`), with /dev/tty still openable."""
315
+ tty = FakeTTY()
316
+ monkeypatch.setattr(sys, "stdout", io.StringIO())
317
+ monkeypatch.setattr(wizard, "_open_tty", lambda: tty)
318
+ assert not sys.stdout.isatty()
319
+ return tty
320
+
321
+
322
+ def test_piped_stdout_paints_to_dev_tty(monkeypatch, tmp_path):
323
+ keys = [DOWN, ENTER, DOWN, ENTER, DOWN, DOWN, DOWN, DOWN, ENTER]
324
+ ctx = _setup(monkeypatch, tmp_path, keys, patch_ui=False)
325
+ tty = _piped(monkeypatch)
326
+
327
+ wizard.run_wizard() # not refused: the keyboard and /dev/tty are both there
328
+
329
+ painted = tty.getvalue()
330
+ assert "\x1b[2J\x1b[H" in painted # our own clear; click.clear() would no-op
331
+ assert "Step 1 of 3 — Voice" in painted
332
+ assert "Step 2 of 3 — Model" in painted
333
+ assert "Step 3 of 3 — Speed" in painted
334
+ assert wizard.VOICE_HOTKEYS in painted
335
+ assert "About to write:" in painted
336
+ # None of it went down the pipe, and the stream we opened got closed
337
+ assert "Step 1 of 3 — Voice" not in sys.stdout.getvalue()
338
+ assert tty.closed_by_wizard
339
+ assert ctx.path.read_text() == (
340
+ 'voice = "abc123"\n'
341
+ 'model = "eleven_flash_v2_5"\n'
342
+ "speed = 0.9\n"
343
+ )
344
+
345
+
346
+ def test_headless_still_refused(monkeypatch, tmp_path):
347
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
348
+ monkeypatch.setattr(sys, "stdin", FakeStdin(tty=False))
349
+ opened = []
350
+ monkeypatch.setattr(wizard, "_open_tty", lambda: opened.append(True))
351
+
352
+ with pytest.raises(ConfigError, match="interactive terminal"):
353
+ wizard.run_wizard()
354
+
355
+ assert opened == [] # a dead stdin is decided on its own, before any tty
356
+ assert not (tmp_path / "config" / "vocalize" / "config.toml").exists()
357
+
358
+
359
+ def test_confirmation_line_reaches_stdout_too(monkeypatch, tmp_path):
360
+ keys = [DOWN, ENTER, DOWN, ENTER, DOWN, DOWN, DOWN, DOWN, ENTER]
361
+ ctx = _setup(monkeypatch, tmp_path, keys, patch_ui=False)
362
+ tty = _piped(monkeypatch)
363
+
364
+ wizard.run_wizard()
365
+
366
+ assert f"Wrote {ctx.path}" in tty.getvalue()
367
+ # Wrappers and logs only capture stdout, so the outcome has to land there
368
+ assert sys.stdout.getvalue().strip() == f"Wrote {ctx.path}"
369
+
370
+
285
371
  def test_the_current_voice_starts_under_the_cursor(monkeypatch, tmp_path, capsys):
286
372
  ctx = _setup(monkeypatch, tmp_path, [ENTER, ENTER, ENTER])
287
373
  monkeypatch.setenv("VOCALIZE_VOICE", "def456") # after _setup, which clears it
@@ -7,4 +7,4 @@ formatting into something that actually sounds good spoken aloud
7
7
  which is close to useless).
8
8
  """
9
9
 
10
- __version__ = "0.2.0"
10
+ __version__ = "0.2.1"
@@ -4,6 +4,13 @@ Three steps — voice, model, speed — each a keyboard-driven list rendered
4
4
  with click.echo and driven by click.getchar(). click is already a
5
5
  dependency, so this needs no curses, prompt_toolkit, or anything else.
6
6
 
7
+ Every frame is painted on the controlling terminal rather than on
8
+ stdout. Wrappers that relay a child's output — `op run`, the documented
9
+ way this project injects its API key — leave sys.stdout non-tty while
10
+ the keyboard still works, which makes click.clear() a no-op and lets the
11
+ relay's writes land in the middle of a raw-mode read. Painting at
12
+ /dev/tty sidesteps both.
13
+
7
14
  This module owns the *interaction* only. Every default, bound, and
8
15
  validation rule still comes from vocalize.config, and the voice list and
9
16
  previews go through the same tts/audio functions the rest of the CLI
@@ -61,15 +68,61 @@ _CHROME_LINES = 8
61
68
  _KEEP = object()
62
69
  _UNSET = object()
63
70
 
71
+ _NO_TERMINAL = (
72
+ "The config wizard needs an interactive terminal. "
73
+ "Run `vocalize config` in a terminal, not from a pipe or a script."
74
+ )
75
+
64
76
 
65
77
  class _Cancelled(Exception):
66
78
  """Raised inside a step on q, Escape, or EOF."""
67
79
 
68
80
 
69
- def _render(title: str, rows: list, cursor: int, legend: str, notes: list) -> None:
70
- click.clear()
71
- click.echo(title)
72
- click.echo()
81
+ def _open_tty():
82
+ """The controlling terminal as a writable stream, or None."""
83
+ try:
84
+ return open("/dev/tty", "w", encoding="utf-8")
85
+ except OSError:
86
+ return None
87
+
88
+
89
+ def _open_ui_stream():
90
+ """Where to paint the wizard: (stream, did_we_open_it).
91
+
92
+ Returns (None, False) when there's no terminal to paint on at all.
93
+ """
94
+ stdout = sys.stdout
95
+ if stdout is not None and stdout.isatty():
96
+ return stdout, False
97
+ tty = _open_tty()
98
+ return (tty, True) if tty is not None else (None, False)
99
+
100
+
101
+ def _clear(ui) -> None:
102
+ # click.clear() only ever writes to stdout, and no-ops when stdout
103
+ # isn't a tty — which is the whole case this wizard has to survive.
104
+ ui.write("\x1b[2J\x1b[H")
105
+ ui.flush()
106
+
107
+
108
+ def _ask(ui, label: str) -> str:
109
+ """Read one typed line. The tty driver echoes the typing itself."""
110
+ click.echo(f"{label}: ", file=ui, nl=False)
111
+ try:
112
+ return input()
113
+ except (EOFError, KeyboardInterrupt):
114
+ return ""
115
+
116
+
117
+ def _confirm(ui, label: str) -> bool:
118
+ """Default no: an empty answer, or an EOF, must not write the file."""
119
+ return _ask(ui, f"{label} [y/N]").strip().lower() in ("y", "yes")
120
+
121
+
122
+ def _render(ui, title: str, rows: list, cursor: int, legend: str, notes: list) -> None:
123
+ _clear(ui)
124
+ click.echo(title, file=ui)
125
+ click.echo(file=ui)
73
126
 
74
127
  # A full ElevenLabs voice list is longer than an 80x24 terminal, so the
75
128
  # list is windowed onto the cursor rather than echoed whole — otherwise
@@ -79,21 +132,21 @@ def _render(title: str, rows: list, cursor: int, legend: str, notes: list) -> No
79
132
  end = min(start + height, len(rows))
80
133
 
81
134
  if start > 0:
82
- click.echo(" …")
135
+ click.echo(" …", file=ui)
83
136
  for index in range(start, end):
84
- click.echo(f"{'>' if index == cursor else ' '} {rows[index][1]}")
137
+ click.echo(f"{'>' if index == cursor else ' '} {rows[index][1]}", file=ui)
85
138
  if end < len(rows):
86
- click.echo(" …")
139
+ click.echo(" …", file=ui)
87
140
 
88
- click.echo()
141
+ click.echo(file=ui)
89
142
  if notes:
90
143
  for note in notes:
91
- click.echo(note)
92
- click.echo()
93
- click.echo(legend)
144
+ click.echo(note, file=ui)
145
+ click.echo(file=ui)
146
+ click.echo(legend, file=ui)
94
147
 
95
148
 
96
- def _select(title, rows, cursor, *, legend=HOTKEYS, notes=(), manual=None, preview=None):
149
+ def _select(ui, title, rows, cursor, *, legend=HOTKEYS, notes=(), manual=None, preview=None):
97
150
  """Run one step of the wizard and return the chosen row value.
98
151
 
99
152
  `manual` and `preview` are callables for the m and p hotkeys; either
@@ -103,7 +156,7 @@ def _select(title, rows, cursor, *, legend=HOTKEYS, notes=(), manual=None, previ
103
156
  status = None
104
157
 
105
158
  while True:
106
- _render(title, rows, cursor, legend, notes + ([status] if status else []))
159
+ _render(ui, title, rows, cursor, legend, notes + ([status] if status else []))
107
160
  key = click.getchar()
108
161
 
109
162
  # An empty read means EOF (a closed pty). It matches no key set, so
@@ -126,22 +179,21 @@ def _select(title, rows, cursor, *, legend=HOTKEYS, notes=(), manual=None, previ
126
179
  status = preview(rows[cursor][0])
127
180
 
128
181
 
129
- def _manual_text(label: str) -> str | None:
182
+ def _manual_text(ui, label: str) -> str | None:
130
183
  """Type a value by hand. An empty answer means 'never mind'."""
131
- value = click.prompt(label, default="", show_default=False).strip()
132
- return value or None
184
+ return _ask(ui, label).strip() or None
133
185
 
134
186
 
135
- def _manual_speed() -> float | None:
187
+ def _manual_speed(ui) -> float | None:
136
188
  """Type a speed by hand, re-asking until it passes the config check."""
137
189
  while True:
138
- raw = click.prompt(f"Speed ({SPEED_MIN}–{SPEED_MAX})", default="", show_default=False).strip()
190
+ raw = _ask(ui, f"Speed ({SPEED_MIN}–{SPEED_MAX})").strip()
139
191
  if not raw:
140
192
  return None
141
193
  try:
142
194
  return validate_speed(raw, "manual entry")
143
195
  except ConfigError as exc:
144
- click.echo(str(exc))
196
+ click.echo(str(exc), file=ui)
145
197
 
146
198
 
147
199
  def _speed_choices() -> list[float]:
@@ -163,7 +215,7 @@ def _keep_label(existing: dict, key: str, resolved) -> str:
163
215
  return f"{resolved} — not in the file"
164
216
 
165
217
 
166
- def _voice_step(current: str, keep: str):
218
+ def _voice_step(ui, current: str, keep: str):
167
219
  rows = [(_KEEP, f"keep current ({keep})")]
168
220
  cursor = 0
169
221
  notes = []
@@ -194,7 +246,7 @@ def _voice_step(current: str, keep: str):
194
246
  return "Preview needs an API key."
195
247
  # Synthesis and playback both block; say so, or the frozen frame
196
248
  # reads as a hang.
197
- click.echo(f"Previewing {voice_id}…")
249
+ click.echo(f"Previewing {voice_id}…", file=ui)
198
250
  try:
199
251
  play(save(synthesize(client, PREVIEW_TEXT, Settings(voice_id=voice_id)), PREVIEW_PATH))
200
252
  except VocalizeError as exc:
@@ -202,17 +254,18 @@ def _voice_step(current: str, keep: str):
202
254
  return f"Previewed {voice_id}."
203
255
 
204
256
  return _select(
257
+ ui,
205
258
  "Step 1 of 3 — Voice",
206
259
  rows,
207
260
  cursor,
208
261
  legend=VOICE_HOTKEYS,
209
262
  notes=notes,
210
- manual=lambda: _manual_text("Voice ID"),
263
+ manual=lambda: _manual_text(ui, "Voice ID"),
211
264
  preview=preview,
212
265
  )
213
266
 
214
267
 
215
- def _model_step(current: str, keep: str):
268
+ def _model_step(ui, current: str, keep: str):
216
269
  rows = [(_KEEP, f"keep current ({keep})")]
217
270
  cursor = 0
218
271
 
@@ -224,14 +277,15 @@ def _model_step(current: str, keep: str):
224
277
  rows.append((model_id, label))
225
278
 
226
279
  return _select(
280
+ ui,
227
281
  "Step 2 of 3 — Model",
228
282
  rows,
229
283
  cursor,
230
- manual=lambda: _manual_text("Model ID"),
284
+ manual=lambda: _manual_text(ui, "Model ID"),
231
285
  )
232
286
 
233
287
 
234
- def _speed_step(current: float | None, keep: str):
288
+ def _speed_step(ui, current: float | None, keep: str):
235
289
  rows = [(_KEEP, f"keep current ({keep})"), (_UNSET, "unset (API default)")]
236
290
  cursor = 0
237
291
 
@@ -242,7 +296,7 @@ def _speed_step(current: float | None, keep: str):
242
296
  cursor = len(rows)
243
297
  rows.append((value, label))
244
298
 
245
- return _select("Step 3 of 3 — Speed", rows, cursor, manual=_manual_speed)
299
+ return _select(ui, "Step 3 of 3 — Speed", rows, cursor, manual=lambda: _manual_speed(ui))
246
300
 
247
301
 
248
302
  def _toml_value(key: str, value) -> str:
@@ -287,11 +341,21 @@ def run_wizard() -> None:
287
341
  """Walk through voice, model and speed, then write the config file."""
288
342
  stdin = sys.stdin
289
343
  if stdin is None or not stdin.isatty():
290
- raise ConfigError(
291
- "The config wizard needs an interactive terminal. "
292
- "Run `vocalize config` in a terminal, not from a pipe or a script."
293
- )
344
+ raise ConfigError(_NO_TERMINAL)
345
+
346
+ # The keyboard is only half of it there also has to be somewhere to
347
+ # paint. A relayed stdout is fine as long as /dev/tty opens.
348
+ ui, opened = _open_ui_stream()
349
+ if ui is None:
350
+ raise ConfigError(_NO_TERMINAL)
351
+ try:
352
+ _walk(ui)
353
+ finally:
354
+ if opened:
355
+ ui.close()
356
+
294
357
 
358
+ def _walk(ui) -> None:
295
359
  path = config_path()
296
360
  existing = load_config_file()
297
361
  # Dry-run the serialiser before asking any questions: fail fast rather
@@ -315,12 +379,12 @@ def run_wizard() -> None:
315
379
 
316
380
  try:
317
381
  chosen = {
318
- "voice": _voice_step(current.voice_id, keep["voice"]),
319
- "model": _model_step(current.model_id, keep["model"]),
320
- "speed": _speed_step(current.speed, keep["speed"]),
382
+ "voice": _voice_step(ui, current.voice_id, keep["voice"]),
383
+ "model": _model_step(ui, current.model_id, keep["model"]),
384
+ "speed": _speed_step(ui, current.speed, keep["speed"]),
321
385
  }
322
386
  except _Cancelled:
323
- click.echo("Cancelled — nothing changed.")
387
+ click.echo("Cancelled — nothing changed.", file=ui)
324
388
  return
325
389
 
326
390
  data = dict(existing) # unknown keys ride through untouched
@@ -332,20 +396,24 @@ def run_wizard() -> None:
332
396
  else:
333
397
  data[key] = value
334
398
 
335
- click.clear()
336
- click.echo("About to write:")
337
- click.echo()
399
+ _clear(ui)
400
+ click.echo("About to write:", file=ui)
401
+ click.echo(file=ui)
338
402
  for key, value in chosen.items():
339
- click.echo(f" {key:<5} → {_summary_value(value, keep[key])}")
340
- click.echo()
341
- click.echo(f"File: {path}")
342
- click.echo()
403
+ click.echo(f" {key:<5} → {_summary_value(value, keep[key])}", file=ui)
404
+ click.echo(file=ui)
405
+ click.echo(f"File: {path}", file=ui)
406
+ click.echo(file=ui)
343
407
 
344
- if not click.confirm("Write these settings?", default=False):
345
- click.echo("Cancelled — nothing changed.")
408
+ if not _confirm(ui, "Write these settings?"):
409
+ click.echo("Cancelled — nothing changed.", file=ui)
346
410
  return
347
411
 
348
412
  text = _write_config(path, data)
349
- click.echo(f"Wrote {path}")
350
- click.echo()
351
- click.echo(text.rstrip("\n") or "(empty every setting is back to its default)")
413
+ click.echo(f"Wrote {path}", file=ui)
414
+ if ui is not sys.stdout:
415
+ # The outcome line is the one thing a wrapper or a log should still
416
+ # see when the UI went to the terminal instead of down the pipe.
417
+ click.echo(f"Wrote {path}", file=sys.stdout)
418
+ click.echo(file=ui)
419
+ click.echo(text.rstrip("\n") or "(empty — every setting is back to its default)", file=ui)
File without changes
File without changes
File without changes