xsync-cli 0.1.1__tar.gz → 0.1.2__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 (51) hide show
  1. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/PKG-INFO +32 -6
  2. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/README.md +31 -5
  3. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/pyproject.toml +1 -1
  4. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/src/xsync_cli/adapters/codex_config.py +15 -0
  5. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/src/xsync_cli/adapters/codex_wiring.py +5 -5
  6. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/src/xsync_cli/cli.py +247 -52
  7. xsync_cli-0.1.2/src/xsync_cli/core/term.py +115 -0
  8. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/tests/test_cli_codex.py +67 -1
  9. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/tests/test_cli_profiles.py +63 -6
  10. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/tests/test_codex_config.py +31 -0
  11. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/tests/test_codex_wiring.py +18 -2
  12. xsync_cli-0.1.2/tests/test_term.py +96 -0
  13. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/uv.lock +1 -1
  14. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/.gitignore +0 -0
  15. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/LICENSE +0 -0
  16. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/docs/superpowers/plans/2026-09-13-xsync-cli.md +0 -0
  17. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/docs/superpowers/specs/2026-09-13-xsync-design.md +0 -0
  18. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/docs/verification-2026-09-13.md +0 -0
  19. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/src/xsync_cli/__init__.py +0 -0
  20. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/src/xsync_cli/adapters/__init__.py +0 -0
  21. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/src/xsync_cli/adapters/codex.py +0 -0
  22. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/src/xsync_cli/adapters/prompts/__init__.py +0 -0
  23. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/src/xsync_cli/adapters/prompts/generic-codex.md +0 -0
  24. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/src/xsync_cli/adapters/prompts/generic-codex.messages.json +0 -0
  25. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/src/xsync_cli/adapters/prompts/gpt-5.4.md +0 -0
  26. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/src/xsync_cli/adapters/prompts/gpt-5.4.messages.json +0 -0
  27. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/src/xsync_cli/adapters/prompts/gpt-5.5.md +0 -0
  28. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/src/xsync_cli/adapters/prompts/gpt-5.5.messages.json +0 -0
  29. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/src/xsync_cli/adapters/rules/__init__.py +0 -0
  30. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/src/xsync_cli/adapters/rules/codex.toml +0 -0
  31. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/src/xsync_cli/core/__init__.py +0 -0
  32. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/src/xsync_cli/core/atomic.py +0 -0
  33. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/src/xsync_cli/core/diff.py +0 -0
  34. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/src/xsync_cli/core/filters.py +0 -0
  35. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/src/xsync_cli/core/model.py +0 -0
  36. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/src/xsync_cli/core/profiles.py +0 -0
  37. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/src/xsync_cli/sources/__init__.py +0 -0
  38. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/src/xsync_cli/sources/openai_compat.py +0 -0
  39. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/tests/__init__.py +0 -0
  40. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/tests/fixtures/__init__.py +0 -0
  41. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/tests/fixtures/ninerouter_models.json +0 -0
  42. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/tests/fixtures/plain_openai_models.json +0 -0
  43. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/tests/test_atomic.py +0 -0
  44. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/tests/test_codex_render.py +0 -0
  45. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/tests/test_diff.py +0 -0
  46. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/tests/test_filters.py +0 -0
  47. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/tests/test_model.py +0 -0
  48. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/tests/test_openai_compat.py +0 -0
  49. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/tests/test_package_data.py +0 -0
  50. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/tests/test_profiles.py +0 -0
  51. {xsync_cli-0.1.1 → xsync_cli-0.1.2}/tools/extract_prompts.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: xsync-cli
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Sync the model list of an OpenAI-compatible endpoint into the Codex model catalog.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -77,7 +77,8 @@ Run this command again when the endpoint changes.
77
77
  | `xsync codex --profile <name>` | Use another profile for one run. |
78
78
  | `xsync codex --init` | Connect Codex to the endpoint of the profile. |
79
79
  | `xsync codex --reset` | Remove everything that xSync wrote. |
80
- | `xsync codex --reset --force` | Reset when no state file exists. |
80
+ | `xsync codex --reset --force` | Reset when no state file exists. It asks first. |
81
+ | `xsync codex --reset --force --yes` | Reset with no question. Use this in a script. |
81
82
 
82
83
  Exit codes: `0` for success, `1` for an error, `2` when the endpoint does not
83
84
  answer.
@@ -120,8 +121,21 @@ A profile uses `api_key` or `api_key_env`, but not both. Use `api_key_env`
120
121
  when you share the file. A profile with neither key sends no `Authorization`
121
122
  header.
122
123
 
123
- The `include` and `exclude` lists hold glob patterns. An empty `include`
124
- list keeps every model. The `exclude` list always wins.
124
+ ## The filters
125
+
126
+ A glob pattern selects a model by name. The sign `*` means any characters.
127
+
128
+ | Rule | Effect |
129
+ |---|---|
130
+ | `include = []` | Keep every model. This is the default. |
131
+ | `include = ["cx/*", "ed3n/*"]` | Keep only the models of those two providers. |
132
+ | `exclude = ["*embedding*"]` | Drop every model with `embedding` in the name. |
133
+ | `exclude = ["*-image-*", "*-tts-*"]` | Drop the image models and the speech models. |
134
+
135
+ The `exclude` list always wins over the `include` list. Use the filters when
136
+ an endpoint serves many models that you never choose in Codex.
137
+
138
+ The match ignores the letter case.
125
139
 
126
140
  ## Safety
127
141
 
@@ -136,9 +150,21 @@ list keeps every model. The `exclude` list always wins.
136
150
  - `--reset` removes only what the state file `~/.codex/.xsync-state.json`
137
151
  records. It never restores an old copy of `config.toml`, because Codex
138
152
  adds project entries to that file over time.
153
+ - `--reset` keeps the catalog backup `<name>.bak`. The backup is the only
154
+ way back after a reset.
139
155
  - With no state file, `--reset` prints the keys that it would remove and
140
- then stops. Add `--force` to continue.
141
- - xSync never prints an API key.
156
+ then stops. Add `--force` to continue. The command then asks a question
157
+ before it removes anything. Add `--yes` to answer the question in a
158
+ script.
159
+ - The setup hides the API key while you type it. It then shows the first
160
+ five characters only, and it never shows the length of the key.
161
+ - xSync never prints a whole API key.
162
+
163
+ ## The color
164
+
165
+ xSync writes color for a terminal. It writes plain text for a pipe, for a
166
+ dumb terminal, and when `NO_COLOR` holds a value. Set `FORCE_COLOR=1` to
167
+ keep the color in a pipe.
142
168
 
143
169
  ## What xSync reads and what it writes
144
170
 
@@ -65,7 +65,8 @@ Run this command again when the endpoint changes.
65
65
  | `xsync codex --profile <name>` | Use another profile for one run. |
66
66
  | `xsync codex --init` | Connect Codex to the endpoint of the profile. |
67
67
  | `xsync codex --reset` | Remove everything that xSync wrote. |
68
- | `xsync codex --reset --force` | Reset when no state file exists. |
68
+ | `xsync codex --reset --force` | Reset when no state file exists. It asks first. |
69
+ | `xsync codex --reset --force --yes` | Reset with no question. Use this in a script. |
69
70
 
70
71
  Exit codes: `0` for success, `1` for an error, `2` when the endpoint does not
71
72
  answer.
@@ -108,8 +109,21 @@ A profile uses `api_key` or `api_key_env`, but not both. Use `api_key_env`
108
109
  when you share the file. A profile with neither key sends no `Authorization`
109
110
  header.
110
111
 
111
- The `include` and `exclude` lists hold glob patterns. An empty `include`
112
- list keeps every model. The `exclude` list always wins.
112
+ ## The filters
113
+
114
+ A glob pattern selects a model by name. The sign `*` means any characters.
115
+
116
+ | Rule | Effect |
117
+ |---|---|
118
+ | `include = []` | Keep every model. This is the default. |
119
+ | `include = ["cx/*", "ed3n/*"]` | Keep only the models of those two providers. |
120
+ | `exclude = ["*embedding*"]` | Drop every model with `embedding` in the name. |
121
+ | `exclude = ["*-image-*", "*-tts-*"]` | Drop the image models and the speech models. |
122
+
123
+ The `exclude` list always wins over the `include` list. Use the filters when
124
+ an endpoint serves many models that you never choose in Codex.
125
+
126
+ The match ignores the letter case.
113
127
 
114
128
  ## Safety
115
129
 
@@ -124,9 +138,21 @@ list keeps every model. The `exclude` list always wins.
124
138
  - `--reset` removes only what the state file `~/.codex/.xsync-state.json`
125
139
  records. It never restores an old copy of `config.toml`, because Codex
126
140
  adds project entries to that file over time.
141
+ - `--reset` keeps the catalog backup `<name>.bak`. The backup is the only
142
+ way back after a reset.
127
143
  - With no state file, `--reset` prints the keys that it would remove and
128
- then stops. Add `--force` to continue.
129
- - xSync never prints an API key.
144
+ then stops. Add `--force` to continue. The command then asks a question
145
+ before it removes anything. Add `--yes` to answer the question in a
146
+ script.
147
+ - The setup hides the API key while you type it. It then shows the first
148
+ five characters only, and it never shows the length of the key.
149
+ - xSync never prints a whole API key.
150
+
151
+ ## The color
152
+
153
+ xSync writes color for a terminal. It writes plain text for a pipe, for a
154
+ dumb terminal, and when `NO_COLOR` holds a value. Set `FORCE_COLOR=1` to
155
+ keep the color in a pipe.
130
156
 
131
157
  ## What xSync reads and what it writes
132
158
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "xsync-cli"
7
- version = "0.1.1"
7
+ version = "0.1.2"
8
8
  description = "Sync the model list of an OpenAI-compatible endpoint into the Codex model catalog."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -68,6 +68,21 @@ def check_provider_match(config: dict[str, Any], profile: Profile) -> str | None
68
68
  return None
69
69
 
70
70
 
71
+ def wire_api_for_url(config: dict[str, Any], base_url: str) -> str | None:
72
+ """The wire API that Codex already uses for one base URL.
73
+
74
+ The function gives None when no provider block holds that URL.
75
+ """
76
+ wanted = base_url.rstrip("/")
77
+ for table in (config.get("model_providers") or {}).values():
78
+ if not isinstance(table, dict):
79
+ continue
80
+ if str(table.get("base_url", "")).rstrip("/") == wanted:
81
+ value = table.get("wire_api")
82
+ return str(value) if value else None
83
+ return None
84
+
85
+
71
86
  def catalog_path_for(profile: Profile, codex_home: Path) -> Path:
72
87
  """The catalog file of one profile."""
73
88
  return codex_home / f"{profile.name}-models.json"
@@ -103,7 +103,11 @@ def init_config(
103
103
 
104
104
 
105
105
  def reset_config(config_path: Path, state: State) -> list[str]:
106
- """Remove every item that the state file records."""
106
+ """Remove every item that the state file records.
107
+
108
+ The function keeps the backup file `<name>.bak`. The backup is the
109
+ only way back after a reset.
110
+ """
107
111
  before = file_sha256(config_path)
108
112
  document = _load_document(config_path)
109
113
  removed: list[str] = []
@@ -129,9 +133,5 @@ def reset_config(config_path: Path, state: State) -> list[str]:
129
133
  if path.exists():
130
134
  path.unlink()
131
135
  removed.append(name)
132
- backup = path.with_name(path.name + ".bak")
133
- if backup.exists():
134
- backup.unlink()
135
- removed.append(str(backup))
136
136
 
137
137
  return removed
@@ -6,9 +6,11 @@ import argparse
6
6
  import json
7
7
  import os
8
8
  import sys
9
+ from getpass import getpass
9
10
  from pathlib import Path
10
11
 
11
12
  from xsync_cli.adapters.codex import load_rules, render_catalog
13
+ from xsync_cli.core import term
12
14
  from xsync_cli.adapters.codex_config import (
13
15
  STATE_FILENAME,
14
16
  ConfigError,
@@ -19,6 +21,7 @@ from xsync_cli.adapters.codex_config import (
19
21
  default_codex_home,
20
22
  read_config,
21
23
  read_state,
24
+ wire_api_for_url,
22
25
  write_state,
23
26
  )
24
27
  from xsync_cli.adapters.codex_wiring import init_config, known_removals, reset_config
@@ -55,62 +58,120 @@ def open_store() -> ProfileStore:
55
58
  return store
56
59
 
57
60
 
58
- def _ask(prompt: str, default: str = "") -> str:
59
- suffix = f" [{default}]" if default else ""
60
- answer = input(f"{prompt}{suffix}: ").strip()
61
+ def _ask(prompt: str, default: str = "", hint: str = "") -> str:
62
+ """Ask one question. An empty answer gives the default."""
63
+ if hint:
64
+ print(term.dim(f" {hint}"))
65
+ suffix = term.dim(f" [{default}]") if default else ""
66
+ answer = input(f"{term.cyan('?')} {term.bold(prompt)}{suffix}: ").strip()
61
67
  return answer or default
62
68
 
63
69
 
64
- def _ask_list(prompt: str) -> list[str]:
65
- answer = _ask(prompt)
70
+ def _ask_secret(prompt: str, hint: str = "") -> str:
71
+ """Ask for a secret. The terminal shows no character."""
72
+ if hint:
73
+ print(term.dim(f" {hint}"))
74
+ return getpass(f"{term.cyan('?')} {term.bold(prompt)}: ").strip()
75
+
76
+
77
+ def _ask_list(prompt: str, hint: str = "") -> list[str]:
78
+ answer = _ask(prompt, hint=hint)
66
79
  return [part.strip() for part in answer.split(",") if part.strip()]
67
80
 
68
81
 
82
+ def _ok(text: str) -> None:
83
+ print(f"{term.green('✓')} {text}")
84
+
85
+
86
+ def _warn(text: str) -> None:
87
+ print(f"{term.yellow('!')} {text}")
88
+
89
+
90
+ def _fail(text: str) -> None:
91
+ print(f"{term.red('✗')} {text}", file=sys.stderr)
92
+
93
+
69
94
  def cmd_setup(args: argparse.Namespace) -> int:
70
95
  """Make a profile."""
71
96
  store = open_store()
72
97
 
73
- name = _ask("profile name")
98
+ print(term.heading("New xsync profile"))
99
+ print(term.rule())
100
+
101
+ name = _ask("Profile name", hint="a short label, for example 9router")
74
102
  if not name:
75
- print("the profile name cannot be empty.", file=sys.stderr)
103
+ _fail("the profile name cannot be empty.")
76
104
  return EXIT_ERROR
105
+ if name in store.names():
106
+ _warn(f"the profile {name!r} exists. The answers replace it.")
77
107
 
78
- base_url = _ask("base URL (for example http://127.0.0.1:20128/v1)")
108
+ base_url = _ask(
109
+ "Base URL", hint="the endpoint, for example http://127.0.0.1:20128/v1"
110
+ )
79
111
  if not base_url:
80
- print("the base URL cannot be empty.", file=sys.stderr)
112
+ _fail("the base URL cannot be empty.")
81
113
  return EXIT_ERROR
82
114
 
83
- api_key = _ask("API key (leave empty when the endpoint needs none)")
84
- include = _ask_list("include globs, separated by a comma (optional)")
85
- exclude = _ask_list("exclude globs, separated by a comma (optional)")
115
+ api_key = _ask_secret(
116
+ "API key", hint="the terminal shows nothing. Press Enter when there is no key"
117
+ )
118
+ print(f" {term.dim('key:')} {term.dim(term.mask_key(api_key))}")
119
+
120
+ print(term.heading("Filters"))
121
+ print(
122
+ term.dim(
123
+ " A glob pattern selects models by name. The sign * means any\n"
124
+ " characters. Leave both empty to keep every model."
125
+ )
126
+ )
127
+ include = _ask_list(
128
+ "Include only these", hint="for example cx/*, ed3n/* (empty keeps all)"
129
+ )
130
+ exclude = _ask_list(
131
+ "Exclude these", hint="for example *embedding*, *-image-*"
132
+ )
86
133
 
87
- print(f"testing {base_url}/models")
134
+ print(term.heading(f"Testing {base_url}/models"))
88
135
  try:
89
136
  models = fetch_models(base_url, api_key or None)
90
137
  except EndpointUnreachable as error:
91
- print(f"error: {error}", file=sys.stderr)
138
+ _fail(str(error))
92
139
  return EXIT_UNREACHABLE
93
140
  except SourceError as error:
94
- print(f"error: {error}", file=sys.stderr)
141
+ _fail(str(error))
95
142
  return EXIT_ERROR
96
143
 
97
- print(f"\n{len(models)} models:")
98
- for model in models:
99
- window = f"{model.context_window:,}" if model.context_window else "unknown"
100
- flags = ",".join(
101
- flag
102
- for flag, on in (
103
- ("tools", model.tools),
104
- ("reasoning", model.reasoning),
105
- ("vision", model.vision),
106
- ("search", model.search),
144
+ kept = apply_filters(models, include, exclude)
145
+ _ok(f"the endpoint answered with {term.bold(str(len(models)))} models")
146
+ if len(kept) != len(models):
147
+ _ok(f"the filters keep {term.bold(str(len(kept)))} models")
148
+
149
+ _print_model_table(kept)
150
+
151
+ codex_home = default_codex_home()
152
+ known = wire_api_for_url(read_config(codex_home / "config.toml"), base_url)
153
+ print(term.heading("Wire API"))
154
+ if known:
155
+ print(
156
+ term.dim(
157
+ f" Codex already talks to this URL with {known!r}. "
158
+ "Press Enter to keep it."
107
159
  )
108
- if on
109
160
  )
110
- print(f" {model.slug:<48} {window:>12} {flags}")
111
-
112
- print()
113
- wire_api = _ask(f"wire API ({' or '.join(WIRE_APIS)})", "chat")
161
+ else:
162
+ print(
163
+ term.dim(
164
+ " Use chat for most OpenAI-compatible servers.\n"
165
+ " Use responses for the OpenAI Responses API.\n"
166
+ " A wrong value breaks every request."
167
+ )
168
+ )
169
+ wire_api = _ask(f"Wire API ({' or '.join(WIRE_APIS)})", known or "chat")
170
+ if known and wire_api != known:
171
+ _warn(
172
+ f"Codex uses {known!r} for this URL. The answer {wire_api!r} "
173
+ "disagrees with it."
174
+ )
114
175
 
115
176
  try:
116
177
  profile = Profile(
@@ -123,27 +184,92 @@ def cmd_setup(args: argparse.Namespace) -> int:
123
184
  exclude=exclude,
124
185
  )
125
186
  except ProfileError as error:
126
- print(f"error: {error}", file=sys.stderr)
187
+ _fail(str(error))
127
188
  return EXIT_ERROR
128
189
 
129
190
  store.add(profile)
130
191
  store.save()
131
- print(f"\nsaved profile {name!r} to {store.path}")
132
- if store.active == name:
133
- print(f"profile {name!r} is now active")
192
+
193
+ print(term.heading("Saved"))
194
+ print(term.rule())
195
+ _print_profile(profile, active=store.active == profile.name)
196
+ print(term.dim(f"\n file: {store.path}"))
197
+ print(term.heading("Next"))
198
+ print(f" {term.bold('xsync codex --init')} connect Codex to this endpoint")
199
+ print(f" {term.bold('xsync codex --dry-run')} see what would change")
200
+ print(f" {term.bold('xsync codex')} write the catalog\n")
134
201
  return EXIT_OK
135
202
 
136
203
 
204
+ def _print_model_table(models: list) -> None:
205
+ """Show the models in a table."""
206
+ if not models:
207
+ _warn("no model passes the filters.")
208
+ return
209
+ slug_size = max(len(m.slug) for m in models)
210
+ print()
211
+ print(
212
+ " "
213
+ + term.dim(term.pad("MODEL", slug_size))
214
+ + term.dim(" " + "CONTEXT".rjust(12))
215
+ + term.dim(" FEATURES")
216
+ )
217
+ for model in models:
218
+ window = f"{model.context_window:,}" if model.context_window else "—"
219
+ flags = " ".join(
220
+ term.green(flag)
221
+ for flag, on in (
222
+ ("tools", model.tools),
223
+ ("reason", model.reasoning),
224
+ ("vision", model.vision),
225
+ ("search", model.search),
226
+ )
227
+ if on
228
+ )
229
+ print(
230
+ " "
231
+ + term.pad(model.slug, slug_size)
232
+ + " "
233
+ + window.rjust(12)
234
+ + " "
235
+ + (flags or term.dim("—"))
236
+ )
237
+ print()
238
+
239
+
240
+ def _print_profile(profile: Profile, active: bool) -> None:
241
+ """Show one profile as a block."""
242
+ mark = term.green("●") if active else term.dim("○")
243
+ label = term.bold(profile.name) + (term.dim(" (active)") if active else "")
244
+ print(f"{mark} {label}")
245
+ rows = [
246
+ ("url", profile.base_url),
247
+ ("key", term.mask_key(profile.api_key)
248
+ if profile.api_key
249
+ else (f"${profile.api_key_env}" if profile.api_key_env else "(none)")),
250
+ ("wire api", profile.wire_api),
251
+ ]
252
+ if profile.include:
253
+ rows.append(("include", ", ".join(profile.include)))
254
+ if profile.exclude:
255
+ rows.append(("exclude", ", ".join(profile.exclude)))
256
+ for key, value in rows:
257
+ print(f" {term.dim(key.ljust(9))} {value}")
258
+
259
+
137
260
  def cmd_list(args: argparse.Namespace) -> int:
138
261
  """Show the profiles."""
139
262
  store = open_store()
140
263
  if not store.names():
141
- print("no profile exists. Run `xsync setup` first.")
264
+ print(term.heading("No profile"))
265
+ print(f" run {term.bold('xsync setup')} to make one.\n")
142
266
  return EXIT_OK
267
+ print(term.heading("Profiles"))
268
+ print(term.rule())
143
269
  for name in store.names():
144
- mark = "*" if name == store.active else " "
145
- profile = store.get(name)
146
- print(f"{mark} {name:<20} {profile.base_url:<40} {profile.wire_api}")
270
+ _print_profile(store.get(name), active=name == store.active)
271
+ print()
272
+ print(term.dim(f" file: {store.path}\n"))
147
273
  return EXIT_OK
148
274
 
149
275
 
@@ -183,7 +309,17 @@ def _load_catalog(path: Path) -> dict | None:
183
309
  return None
184
310
 
185
311
 
186
- def _do_reset(codex_home: Path, profile_name: str, force: bool) -> int:
312
+ def _confirm(question: str) -> bool:
313
+ """Ask for a yes. A pipe or a missing terminal gives no."""
314
+ if not getattr(sys.stdin, "isatty", lambda: False)():
315
+ return False
316
+ answer = input(f"{term.yellow('?')} {term.bold(question)} {term.dim('[yes/no]')}: ")
317
+ return answer.strip().lower() in ("y", "yes")
318
+
319
+
320
+ def _do_reset(
321
+ codex_home: Path, profile_name: str, force: bool, assume_yes: bool = False
322
+ ) -> int:
187
323
  """Remove everything that xSync wrote."""
188
324
  state_path = codex_home / STATE_FILENAME
189
325
  state = read_state(state_path)
@@ -191,12 +327,25 @@ def _do_reset(codex_home: Path, profile_name: str, force: bool) -> int:
191
327
  if state is None:
192
328
  planned = known_removals(profile_name)
193
329
  if not force:
194
- print("no state file exists. xSync would remove:")
330
+ print(term.heading("Reset without a state file"))
331
+ print(term.dim(" xsync would remove:"))
195
332
  for key in planned.keys_written:
196
- print(f" key {key}")
333
+ print(f" {term.red('-')} key {key}")
197
334
  for block in planned.blocks_written:
198
- print(f" block [{block}]")
199
- print("\nadd --force to continue.", file=sys.stderr)
335
+ print(f" {term.red('-')} block [{block}]")
336
+ _fail("add --force to continue.")
337
+ return EXIT_ERROR
338
+ print(term.heading("Reset without a state file"))
339
+ print(term.dim(" xsync did not record this setup. It would remove:"))
340
+ for key in planned.keys_written:
341
+ print(f" {term.red('-')} key {key}")
342
+ for block in planned.blocks_written:
343
+ print(f" {term.red('-')} block [{block}]")
344
+ print(term.dim("\n The catalog backup <name>.bak stays on the disk."))
345
+ if not assume_yes and not _confirm(
346
+ f"Remove the Codex setup of {profile_name!r}?"
347
+ ):
348
+ _fail("stopped. Nothing was removed.")
200
349
  return EXIT_ERROR
201
350
  state = State(
202
351
  profile=profile_name,
@@ -209,9 +358,13 @@ def _do_reset(codex_home: Path, profile_name: str, force: bool) -> int:
209
358
 
210
359
  removed = reset_config(codex_home / "config.toml", state)
211
360
  clear_state(state_path)
361
+ print(term.heading("Reset"))
362
+ print(term.rule())
212
363
  for name in removed:
213
- print(f"removed {name}")
214
- print("Codex is back at its own defaults.")
364
+ print(f" {term.red('-')} {name}")
365
+ print(term.rule())
366
+ _ok("Codex is back at its own defaults.")
367
+ print()
215
368
  return EXIT_OK
216
369
 
217
370
 
@@ -229,7 +382,7 @@ def cmd_codex(args: argparse.Namespace) -> int:
229
382
 
230
383
  if args.reset:
231
384
  try:
232
- return _do_reset(codex_home, profile.name, args.force)
385
+ return _do_reset(codex_home, profile.name, args.force, args.yes)
233
386
  except ConfigError as error:
234
387
  print(f"error: {error}", file=sys.stderr)
235
388
  return EXIT_ERROR
@@ -244,7 +397,10 @@ def cmd_codex(args: argparse.Namespace) -> int:
244
397
  print(f"error: {error}", file=sys.stderr)
245
398
  return EXIT_ERROR
246
399
  write_state(codex_home / STATE_FILENAME, state)
247
- print(f"Codex now routes to {profile.name!r} ({profile.base_url})")
400
+ _ok(
401
+ f"Codex now routes to {term.bold(profile.name)} "
402
+ f"{term.dim('(' + profile.base_url + ')')}"
403
+ )
248
404
 
249
405
  try:
250
406
  api_key = profile.resolve_key(os.environ)
@@ -262,24 +418,60 @@ def cmd_codex(args: argparse.Namespace) -> int:
262
418
  message = check_provider_match(config, profile)
263
419
  if message:
264
420
  if args.dry_run:
265
- print(f"warning:\n{message}\n")
421
+ _warn(message)
422
+ print()
266
423
  else:
267
- print(f"error:\n{message}", file=sys.stderr)
424
+ _fail(message)
268
425
  return EXIT_ERROR
269
426
 
270
427
  catalog = render_catalog(models, load_rules())
271
428
  report = diff_catalogs(_load_catalog(catalog_path), catalog)
272
- print(report.render())
429
+ _print_report(report, profile, len(catalog["models"]))
273
430
 
274
431
  if args.dry_run:
275
- print("no files written (--dry-run)")
432
+ print(term.dim(" no file written (--dry-run)\n"))
276
433
  return EXIT_OK
277
434
 
278
435
  write_json_atomic(catalog_path, catalog)
279
- print(f"wrote {len(catalog['models'])} models to {catalog_path}")
436
+ _ok(
437
+ f"wrote {term.bold(str(len(catalog['models'])))} models to "
438
+ f"{term.dim(str(catalog_path))}"
439
+ )
440
+ print()
280
441
  return EXIT_OK
281
442
 
282
443
 
444
+ def _print_report(report, profile: Profile, total: int) -> None:
445
+ """Show the difference of one sync."""
446
+ print(term.heading(f"Sync {profile.name} → Codex"))
447
+ print(term.dim(f" {profile.base_url}"))
448
+ print(term.rule())
449
+
450
+ if report.is_empty:
451
+ _ok(f"up to date. {term.bold(str(total))} models, no change")
452
+ return
453
+
454
+ for slug in report.added:
455
+ print(f" {term.green('+')} {slug}")
456
+ for slug in report.removed:
457
+ print(f" {term.red('-')} {term.dim(slug)}")
458
+ for slug, fields in report.changed:
459
+ head = ", ".join(fields[:3])
460
+ more = f" +{len(fields) - 3} more" if len(fields) > 3 else ""
461
+ print(f" {term.yellow('~')} {slug}{term.dim(f' ({head}{more})')}")
462
+
463
+ print(term.rule())
464
+ parts = []
465
+ if report.added:
466
+ parts.append(term.green(f"{len(report.added)} added"))
467
+ if report.removed:
468
+ parts.append(term.red(f"{len(report.removed)} removed"))
469
+ if report.changed:
470
+ parts.append(term.yellow(f"{len(report.changed)} changed"))
471
+ parts.append(term.dim(f"{len(report.unchanged)} unchanged"))
472
+ print(" " + " ".join(parts))
473
+
474
+
283
475
  EPILOG = """\
284
476
  examples:
285
477
  xsync setup make a profile, and read the model list
@@ -328,6 +520,9 @@ def build_parser() -> argparse.ArgumentParser:
328
520
  "--reset", action="store_true", help="remove everything xSync wrote"
329
521
  )
330
522
  codex.add_argument("--force", action="store_true", help="reset without a state file")
523
+ codex.add_argument(
524
+ "--yes", action="store_true", help="answer yes to the reset question"
525
+ )
331
526
  codex.set_defaults(func=cmd_codex)
332
527
 
333
528
  return parser
@@ -0,0 +1,115 @@
1
+ """The terminal style.
2
+
3
+ The module writes ANSI codes only for a terminal. It writes plain text
4
+ for a pipe, for a dumb terminal, and when the variable NO_COLOR holds a
5
+ value. The variable FORCE_COLOR turns the color on again.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import re
12
+ import sys
13
+
14
+ RESET = "\x1b[0m"
15
+ _CODES = {
16
+ "bold": "\x1b[1m",
17
+ "dim": "\x1b[2m",
18
+ "red": "\x1b[31m",
19
+ "green": "\x1b[32m",
20
+ "yellow": "\x1b[33m",
21
+ "blue": "\x1b[34m",
22
+ "magenta": "\x1b[35m",
23
+ "cyan": "\x1b[36m",
24
+ }
25
+ _ANSI = re.compile(r"\x1b\[[0-9;]*m")
26
+
27
+
28
+ def _is_set(name: str) -> bool:
29
+ """True when the variable holds a value other than 0, false, or no."""
30
+ value = os.environ.get(name)
31
+ if value is None:
32
+ return False
33
+ return value.strip().lower() not in ("", "0", "false", "no")
34
+
35
+
36
+ def supports_color() -> bool:
37
+ """True when the output accepts ANSI codes."""
38
+ # The rule of no-color.org: the variable disables the color when it
39
+ # is present and it is not empty. The value 0 also disables it.
40
+ if os.environ.get("NO_COLOR", "") != "":
41
+ return False
42
+ if _is_set("FORCE_COLOR"):
43
+ return True
44
+ if os.environ.get("TERM") == "dumb":
45
+ return False
46
+ stream = sys.stdout
47
+ return bool(getattr(stream, "isatty", lambda: False)())
48
+
49
+
50
+ def paint(text: str, *names: str) -> str:
51
+ """Add one or more style codes to the text."""
52
+ if not names or not supports_color():
53
+ return text
54
+ prefix = "".join(_CODES[name] for name in names)
55
+ return f"{prefix}{text}{RESET}"
56
+
57
+
58
+ def bold(text: str) -> str:
59
+ return paint(text, "bold")
60
+
61
+
62
+ def dim(text: str) -> str:
63
+ return paint(text, "dim")
64
+
65
+
66
+ def red(text: str) -> str:
67
+ return paint(text, "red")
68
+
69
+
70
+ def green(text: str) -> str:
71
+ return paint(text, "green")
72
+
73
+
74
+ def yellow(text: str) -> str:
75
+ return paint(text, "yellow")
76
+
77
+
78
+ def blue(text: str) -> str:
79
+ return paint(text, "blue")
80
+
81
+
82
+ def cyan(text: str) -> str:
83
+ return paint(text, "cyan")
84
+
85
+
86
+ def width(text: str) -> int:
87
+ """The printed width of the text. The style codes count as nothing."""
88
+ return len(_ANSI.sub("", text))
89
+
90
+
91
+ def pad(text: str, size: int) -> str:
92
+ """Add spaces on the right until the text fills the size."""
93
+ return text + " " * max(0, size - width(text))
94
+
95
+
96
+ def rule(size: int = 64) -> str:
97
+ """A horizontal line."""
98
+ return dim("─" * size)
99
+
100
+
101
+ def heading(title: str) -> str:
102
+ """A title line."""
103
+ return f"\n{bold(cyan(title))}"
104
+
105
+
106
+ def mask_key(key: str | None) -> str:
107
+ """The first five characters of a key, and nothing more.
108
+
109
+ The function never shows the length of the key.
110
+ """
111
+ if not key:
112
+ return "(none)"
113
+ if len(key) <= 5:
114
+ return "…"
115
+ return f"{key[:5]}…"
@@ -41,7 +41,7 @@ def test_dry_run_writes_nothing(home, monkeypatch, capsys):
41
41
  monkeypatch.setattr(cli, "fetch_models", stub(["a/one"]))
42
42
  assert cli.main(["codex", "--dry-run"]) == 0
43
43
  assert not (home / "p-models.json").exists()
44
- assert "no files written" in capsys.readouterr().out
44
+ assert "no file written" in capsys.readouterr().out
45
45
 
46
46
 
47
47
  def test_the_report_names_the_added_models(home, monkeypatch, capsys):
@@ -139,3 +139,69 @@ def test_a_profile_override_uses_the_named_profile(home, monkeypatch):
139
139
  )
140
140
  monkeypatch.setattr(cli, "fetch_models", stub(["a/one"]))
141
141
  assert cli.main(["codex", "--profile", "q", "--dry-run"]) == 0
142
+
143
+
144
+ def test_the_sync_output_names_the_profile_and_the_endpoint(home, monkeypatch, capsys):
145
+ monkeypatch.setattr(cli, "fetch_models", stub(["a/one"]))
146
+ cli.main(["codex", "--dry-run"])
147
+ out = capsys.readouterr().out
148
+ assert "p" in out
149
+ assert "http://h/v1" in out
150
+
151
+
152
+ def test_the_sync_output_lists_added_models_on_their_own_lines(
153
+ home, monkeypatch, capsys
154
+ ):
155
+ monkeypatch.setattr(cli, "fetch_models", stub(["a/one", "b/two"]))
156
+ cli.main(["codex", "--dry-run"])
157
+ lines = capsys.readouterr().out.splitlines()
158
+ assert any(line.strip().endswith("a/one") for line in lines)
159
+ assert any(line.strip().endswith("b/two") for line in lines)
160
+
161
+
162
+ def test_an_unchanged_sync_says_so(home, monkeypatch, capsys):
163
+ monkeypatch.setattr(cli, "fetch_models", stub(["a/one"]))
164
+ cli.main(["codex"])
165
+ capsys.readouterr()
166
+ cli.main(["codex", "--dry-run"])
167
+ assert "up to date" in capsys.readouterr().out
168
+
169
+
170
+ def test_reset_force_asks_before_it_removes(home, monkeypatch, capsys):
171
+ asked = {}
172
+
173
+ def fake_input(prompt=""):
174
+ asked["prompt"] = prompt
175
+ return "no"
176
+
177
+ monkeypatch.setattr("builtins.input", fake_input)
178
+ monkeypatch.setattr(cli.sys.stdin, "isatty", lambda: True, raising=False)
179
+
180
+ (home / "config.toml").write_text(
181
+ 'model_provider = "p"\nmodel_catalog_json = "/x.json"\n\n'
182
+ '[model_providers.p]\nbase_url = "http://h/v1"\n'
183
+ )
184
+ assert cli.main(["codex", "--reset", "--force"]) == 1
185
+ assert "p" in asked["prompt"]
186
+ assert "model_provider" in (home / "config.toml").read_text()
187
+
188
+
189
+ def test_reset_force_removes_after_a_yes(home, monkeypatch):
190
+ monkeypatch.setattr("builtins.input", lambda prompt="": "yes")
191
+ monkeypatch.setattr(cli.sys.stdin, "isatty", lambda: True, raising=False)
192
+
193
+ (home / "config.toml").write_text(
194
+ 'model_provider = "p"\nmodel_catalog_json = "/x.json"\n\n'
195
+ '[model_providers.p]\nbase_url = "http://h/v1"\n'
196
+ )
197
+ assert cli.main(["codex", "--reset", "--force"]) == 0
198
+ assert "model_provider" not in (home / "config.toml").read_text()
199
+
200
+
201
+ def test_reset_force_needs_no_answer_without_a_terminal(home, monkeypatch):
202
+ monkeypatch.setattr(cli.sys.stdin, "isatty", lambda: False, raising=False)
203
+ (home / "config.toml").write_text(
204
+ 'model_provider = "p"\nmodel_catalog_json = "/x.json"\n\n'
205
+ '[model_providers.p]\nbase_url = "http://h/v1"\n'
206
+ )
207
+ assert cli.main(["codex", "--reset", "--force", "--yes"]) == 0
@@ -20,8 +20,9 @@ def fake_fetch(models):
20
20
 
21
21
 
22
22
  def test_setup_writes_a_profile(store_path, monkeypatch, capsys):
23
- answers = iter(["9router", "http://127.0.0.1:20128/v1", "sk-1", "", "", "responses"])
23
+ answers = iter(["9router", "http://127.0.0.1:20128/v1", "", "", "responses"])
24
24
  monkeypatch.setattr("builtins.input", lambda prompt="": next(answers))
25
+ monkeypatch.setattr(cli, "getpass", lambda prompt="": "sk-1")
25
26
  monkeypatch.setattr(cli, "fetch_models", fake_fetch(["a/one", "b/two"]))
26
27
 
27
28
  assert cli.main(["setup"]) == 0
@@ -31,8 +32,9 @@ def test_setup_writes_a_profile(store_path, monkeypatch, capsys):
31
32
 
32
33
 
33
34
  def test_setup_prints_the_models(store_path, monkeypatch, capsys):
34
- answers = iter(["p", "http://h/v1", "", "", "", "chat"])
35
+ answers = iter(["p", "http://h/v1", "", "", "chat"])
35
36
  monkeypatch.setattr("builtins.input", lambda prompt="": next(answers))
37
+ monkeypatch.setattr(cli, "getpass", lambda prompt="": "")
36
38
  monkeypatch.setattr(cli, "fetch_models", fake_fetch(["a/one", "b/two"]))
37
39
 
38
40
  cli.main(["setup"])
@@ -43,8 +45,9 @@ def test_setup_prints_the_models(store_path, monkeypatch, capsys):
43
45
 
44
46
 
45
47
  def test_setup_never_prints_the_key(store_path, monkeypatch, capsys):
46
- answers = iter(["p", "http://h/v1", "sk-secret-value", "", "", "chat"])
48
+ answers = iter(["p", "http://h/v1", "", "", "chat"])
47
49
  monkeypatch.setattr("builtins.input", lambda prompt="": next(answers))
50
+ monkeypatch.setattr(cli, "getpass", lambda prompt="": "sk-secret-value")
48
51
  monkeypatch.setattr(cli, "fetch_models", fake_fetch(["a/one"]))
49
52
 
50
53
  cli.main(["setup"])
@@ -54,8 +57,9 @@ def test_setup_never_prints_the_key(store_path, monkeypatch, capsys):
54
57
  def test_setup_stops_when_the_endpoint_fails(store_path, monkeypatch):
55
58
  from xsync_cli.sources.openai_compat import EndpointUnreachable
56
59
 
57
- answers = iter(["p", "http://h/v1", "", "", "", "chat"])
60
+ answers = iter(["p", "http://h/v1", "", "", "chat"])
58
61
  monkeypatch.setattr("builtins.input", lambda prompt="": next(answers))
62
+ monkeypatch.setattr(cli, "getpass", lambda prompt="": "")
59
63
 
60
64
  def failing(*args, **kwargs):
61
65
  raise EndpointUnreachable("refused")
@@ -74,8 +78,9 @@ def test_list_marks_the_active_profile(store_path, capsys):
74
78
  )
75
79
  assert cli.main(["list"]) == 0
76
80
  out = capsys.readouterr().out
77
- assert "* a" in out
78
- assert " b" in out
81
+ assert "a" in out and "b" in out
82
+ assert "(active)" in out
83
+ assert "http://a/v1" in out
79
84
 
80
85
 
81
86
  def test_use_switches_the_active_profile(store_path):
@@ -117,3 +122,55 @@ def test_the_help_names_the_profile_file(capsys):
117
122
  def test_the_help_shows_an_example(capsys):
118
123
  cli.main([])
119
124
  assert "xsync setup" in capsys.readouterr().out
125
+
126
+
127
+ def test_setup_hides_the_key_while_the_user_types(store_path, monkeypatch, capsys):
128
+ asked = {}
129
+
130
+ def fake_getpass(prompt=""):
131
+ asked["prompt"] = prompt
132
+ return "sk-92465eb0c2f2e2d5"
133
+
134
+ answers = iter(["p", "http://h/v1", "", "", "chat"])
135
+ monkeypatch.setattr("builtins.input", lambda prompt="": next(answers))
136
+ monkeypatch.setattr(cli, "getpass", fake_getpass)
137
+ monkeypatch.setattr(cli, "fetch_models", fake_fetch(["a/one"]))
138
+
139
+ assert cli.main(["setup"]) == 0
140
+ assert "API key" in asked["prompt"]
141
+
142
+ out = capsys.readouterr().out
143
+ assert "sk-92…" in out
144
+ assert "sk-92465eb0c2f2e2d5" not in out
145
+
146
+
147
+ def test_setup_never_shows_the_key_length(store_path, monkeypatch, capsys):
148
+ answers = iter(["p", "http://h/v1", "", "", "chat"])
149
+ monkeypatch.setattr("builtins.input", lambda prompt="": next(answers))
150
+ monkeypatch.setattr(cli, "getpass", lambda prompt="": "sk-92465eb0c2f2e2d5")
151
+ monkeypatch.setattr(cli, "fetch_models", fake_fetch(["a/one"]))
152
+
153
+ cli.main(["setup"])
154
+ out = capsys.readouterr().out
155
+ assert "18" not in out
156
+ assert "chars" not in out
157
+
158
+
159
+ def test_setup_takes_the_wire_api_from_an_existing_provider(
160
+ store_path, tmp_path, monkeypatch, capsys
161
+ ):
162
+ codex = tmp_path / "codex"
163
+ codex.mkdir()
164
+ (codex / "config.toml").write_text(
165
+ '[model_providers.old]\n'
166
+ 'base_url = "http://h/v1"\nwire_api = "responses"\n'
167
+ )
168
+ monkeypatch.setenv("CODEX_HOME", str(codex))
169
+
170
+ answers = iter(["p", "http://h/v1", "", "", ""])
171
+ monkeypatch.setattr("builtins.input", lambda prompt="": next(answers))
172
+ monkeypatch.setattr(cli, "getpass", lambda prompt="": "")
173
+ monkeypatch.setattr(cli, "fetch_models", fake_fetch(["a/one"]))
174
+
175
+ assert cli.main(["setup"]) == 0
176
+ assert 'wire_api = "responses"' in store_path.read_text()
@@ -102,3 +102,34 @@ def test_the_hash_changes_with_the_content(tmp_path):
102
102
  first = file_sha256(path)
103
103
  path.write_text("a = 2")
104
104
  assert file_sha256(path) != first
105
+
106
+
107
+ def test_wire_api_for_a_known_url_is_found():
108
+ from xsync_cli.adapters.codex_config import wire_api_for_url
109
+
110
+ config = {
111
+ "model_providers": {
112
+ "9router": {
113
+ "base_url": "http://127.0.0.1:20128/v1",
114
+ "wire_api": "responses",
115
+ }
116
+ }
117
+ }
118
+ assert wire_api_for_url(config, "http://127.0.0.1:20128/v1") == "responses"
119
+
120
+
121
+ def test_wire_api_ignores_a_trailing_slash():
122
+ from xsync_cli.adapters.codex_config import wire_api_for_url
123
+
124
+ config = {
125
+ "model_providers": {
126
+ "p": {"base_url": "http://h/v1/", "wire_api": "chat"}
127
+ }
128
+ }
129
+ assert wire_api_for_url(config, "http://h/v1") == "chat"
130
+
131
+
132
+ def test_wire_api_for_an_unknown_url_is_none():
133
+ from xsync_cli.adapters.codex_config import wire_api_for_url
134
+
135
+ assert wire_api_for_url({}, "http://h/v1") is None
@@ -102,7 +102,7 @@ def test_reset_removes_only_the_recorded_items(tmp_path):
102
102
  assert data["model_reasoning_effort"] == "max"
103
103
 
104
104
 
105
- def test_reset_deletes_the_catalog_file(tmp_path):
105
+ def test_reset_deletes_the_catalog_but_not_the_backup(tmp_path):
106
106
  config = tmp_path / "config.toml"
107
107
  config.write_text(EXISTING)
108
108
  catalog = tmp_path / "c.json"
@@ -113,7 +113,7 @@ def test_reset_deletes_the_catalog_file(tmp_path):
113
113
 
114
114
  reset_config(config, state)
115
115
  assert not catalog.exists()
116
- assert not backup.exists()
116
+ assert backup.exists()
117
117
 
118
118
 
119
119
  def test_the_hash_guard_stops_a_concurrent_edit(tmp_path, monkeypatch):
@@ -141,3 +141,19 @@ def test_known_removals_names_the_standard_keys():
141
141
  state = known_removals("9router")
142
142
  assert "model_catalog_json" in state.keys_written
143
143
  assert "model_providers.9router" in state.blocks_written
144
+
145
+
146
+ def test_reset_keeps_the_backup_of_the_catalog(tmp_path):
147
+ config = tmp_path / "config.toml"
148
+ config.write_text(EXISTING)
149
+ catalog = tmp_path / "c.json"
150
+ catalog.write_text('{"models": []}')
151
+ backup = tmp_path / "c.json.bak"
152
+ backup.write_text('{"models": ["old"]}')
153
+ state = init_config(config, profile(), catalog, "sk-1")
154
+
155
+ reset_config(config, state)
156
+
157
+ assert not catalog.exists()
158
+ assert backup.exists(), "the backup is the only way back. Keep it."
159
+ assert backup.read_text() == '{"models": ["old"]}'
@@ -0,0 +1,96 @@
1
+ import io
2
+
3
+ import pytest
4
+
5
+ from xsync_cli.core import term
6
+
7
+
8
+ @pytest.fixture(autouse=True)
9
+ def plain_env(monkeypatch):
10
+ monkeypatch.delenv("NO_COLOR", raising=False)
11
+ monkeypatch.delenv("FORCE_COLOR", raising=False)
12
+ monkeypatch.setenv("TERM", "xterm-256color")
13
+
14
+
15
+ def as_tty(monkeypatch, value: bool):
16
+ stream = io.StringIO()
17
+ monkeypatch.setattr(stream, "isatty", lambda: value, raising=False)
18
+ monkeypatch.setattr(term.sys, "stdout", stream)
19
+
20
+
21
+ def test_color_is_on_for_a_terminal(monkeypatch):
22
+ as_tty(monkeypatch, True)
23
+ assert term.supports_color() is True
24
+
25
+
26
+ def test_color_is_off_for_a_pipe(monkeypatch):
27
+ as_tty(monkeypatch, False)
28
+ assert term.supports_color() is False
29
+
30
+
31
+ def test_no_color_turns_the_color_off(monkeypatch):
32
+ as_tty(monkeypatch, True)
33
+ monkeypatch.setenv("NO_COLOR", "1")
34
+ assert term.supports_color() is False
35
+
36
+
37
+ def test_a_dumb_terminal_gets_no_color(monkeypatch):
38
+ as_tty(monkeypatch, True)
39
+ monkeypatch.setenv("TERM", "dumb")
40
+ assert term.supports_color() is False
41
+
42
+
43
+ def test_style_adds_a_code_for_a_terminal(monkeypatch):
44
+ as_tty(monkeypatch, True)
45
+ assert term.green("ok") == "\x1b[32mok\x1b[0m"
46
+
47
+
48
+ def test_style_adds_nothing_for_a_pipe(monkeypatch):
49
+ as_tty(monkeypatch, False)
50
+ assert term.green("ok") == "ok"
51
+
52
+
53
+ def test_every_style_keeps_the_text(monkeypatch):
54
+ as_tty(monkeypatch, False)
55
+ for style in (term.bold, term.dim, term.green, term.red, term.yellow, term.cyan):
56
+ assert style("text") == "text"
57
+
58
+
59
+ def test_the_width_of_a_styled_string_ignores_the_codes(monkeypatch):
60
+ as_tty(monkeypatch, True)
61
+ assert term.width(term.green("abc")) == 3
62
+
63
+
64
+ def test_mask_key_keeps_the_first_five_characters():
65
+ assert term.mask_key("sk-92465eb0c2f2e2d5") == "sk-92…"
66
+
67
+
68
+ def test_mask_key_of_a_short_key_shows_no_character():
69
+ assert term.mask_key("abc") == "…"
70
+
71
+
72
+ def test_mask_key_of_no_key_says_none():
73
+ assert term.mask_key(None) == "(none)"
74
+ assert term.mask_key("") == "(none)"
75
+
76
+
77
+ def test_rule_fills_the_width(monkeypatch):
78
+ as_tty(monkeypatch, False)
79
+ assert term.rule(10) == "─" * 10
80
+
81
+
82
+ def test_a_heading_holds_the_title(monkeypatch):
83
+ as_tty(monkeypatch, False)
84
+ assert "Profiles" in term.heading("Profiles")
85
+
86
+
87
+ def test_force_color_zero_does_not_turn_the_color_on(monkeypatch):
88
+ as_tty(monkeypatch, False)
89
+ monkeypatch.setenv("FORCE_COLOR", "0")
90
+ assert term.supports_color() is False
91
+
92
+
93
+ def test_no_color_zero_still_turns_the_color_off(monkeypatch):
94
+ as_tty(monkeypatch, True)
95
+ monkeypatch.setenv("NO_COLOR", "0")
96
+ assert term.supports_color() is False
@@ -74,7 +74,7 @@ wheels = [
74
74
 
75
75
  [[package]]
76
76
  name = "xsync-cli"
77
- version = "0.1.0"
77
+ version = "0.1.1"
78
78
  source = { editable = "." }
79
79
  dependencies = [
80
80
  { name = "tomlkit" },
File without changes
File without changes
File without changes
File without changes
File without changes