xsync-cli 0.1.1__tar.gz → 0.1.3__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.3}/PKG-INFO +32 -6
  2. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/README.md +31 -5
  3. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/pyproject.toml +1 -1
  4. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/adapters/codex.py +6 -4
  5. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/adapters/codex_config.py +15 -0
  6. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/adapters/codex_wiring.py +5 -5
  7. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/adapters/rules/codex.toml +1 -0
  8. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/cli.py +248 -52
  9. xsync_cli-0.1.3/src/xsync_cli/core/term.py +115 -0
  10. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/tests/test_cli_codex.py +67 -1
  11. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/tests/test_cli_profiles.py +63 -6
  12. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/tests/test_codex_config.py +31 -0
  13. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/tests/test_codex_render.py +33 -1
  14. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/tests/test_codex_wiring.py +18 -2
  15. xsync_cli-0.1.3/tests/test_term.py +96 -0
  16. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/uv.lock +1 -1
  17. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/.gitignore +0 -0
  18. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/LICENSE +0 -0
  19. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/docs/superpowers/plans/2026-09-13-xsync-cli.md +0 -0
  20. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/docs/superpowers/specs/2026-09-13-xsync-design.md +0 -0
  21. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/docs/verification-2026-09-13.md +0 -0
  22. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/__init__.py +0 -0
  23. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/adapters/__init__.py +0 -0
  24. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/adapters/prompts/__init__.py +0 -0
  25. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/adapters/prompts/generic-codex.md +0 -0
  26. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/adapters/prompts/generic-codex.messages.json +0 -0
  27. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/adapters/prompts/gpt-5.4.md +0 -0
  28. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/adapters/prompts/gpt-5.4.messages.json +0 -0
  29. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/adapters/prompts/gpt-5.5.md +0 -0
  30. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/adapters/prompts/gpt-5.5.messages.json +0 -0
  31. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/adapters/rules/__init__.py +0 -0
  32. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/core/__init__.py +0 -0
  33. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/core/atomic.py +0 -0
  34. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/core/diff.py +0 -0
  35. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/core/filters.py +0 -0
  36. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/core/model.py +0 -0
  37. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/core/profiles.py +0 -0
  38. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/sources/__init__.py +0 -0
  39. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/sources/openai_compat.py +0 -0
  40. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/tests/__init__.py +0 -0
  41. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/tests/fixtures/__init__.py +0 -0
  42. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/tests/fixtures/ninerouter_models.json +0 -0
  43. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/tests/fixtures/plain_openai_models.json +0 -0
  44. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/tests/test_atomic.py +0 -0
  45. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/tests/test_diff.py +0 -0
  46. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/tests/test_filters.py +0 -0
  47. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/tests/test_model.py +0 -0
  48. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/tests/test_openai_compat.py +0 -0
  49. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/tests/test_package_data.py +0 -0
  50. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/tests/test_profiles.py +0 -0
  51. {xsync_cli-0.1.1 → xsync_cli-0.1.3}/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.3
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 = ["openai/*", "anthropic/*"]` | 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 = ["openai/*", "anthropic/*"]` | 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.3"
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"
@@ -103,7 +103,8 @@ def render_entry(model: Model, rules: Rules) -> dict[str, Any]:
103
103
  "slug": model.slug,
104
104
  "display_name": values.get("display_name") or _display_name(model),
105
105
  "description": str(values["description_template"]).format(slug=model.slug),
106
- "default_reasoning_level": "medium" if model.reasoning else None,
106
+ # Codex accepts a string here. A null stops Codex at startup.
107
+ "default_reasoning_level": values["default_reasoning_level"],
107
108
  "supported_reasoning_levels": (
108
109
  [dict(level) for level in REASONING_LEVELS] if model.reasoning else []
109
110
  ),
@@ -127,9 +128,10 @@ def render_entry(model: Model, rules: Rules) -> dict[str, Any]:
127
128
  "support_verbosity": values["support_verbosity"],
128
129
  "default_verbosity": values["default_verbosity"],
129
130
  "apply_patch_tool_type": values["apply_patch_tool_type"],
130
- "web_search_tool_type": (
131
- values["web_search_tool_type"] if model.search else None
132
- ),
131
+ # Codex accepts a string or a map here. A null stops Codex at
132
+ # startup. The flag supports_search_tool carries the fact from
133
+ # the endpoint.
134
+ "web_search_tool_type": values["web_search_tool_type"],
133
135
  "truncation_policy": values["truncation_policy"],
134
136
  "supports_parallel_tool_calls": model.tools,
135
137
  "supports_image_detail_original": values["supports_image_detail_original"],
@@ -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
@@ -20,6 +20,7 @@ shell_type = "shell_command"
20
20
  supported_in_api = true
21
21
  apply_patch_tool_type = "freeform"
22
22
  web_search_tool_type = "text_and_image"
23
+ default_reasoning_level = "medium"
23
24
  supports_reasoning_summaries = true
24
25
  default_reasoning_summary = "none"
25
26
  support_verbosity = true
@@ -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,121 @@ 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",
129
+ hint="for example openai/*, *-mini (empty keeps every model)",
130
+ )
131
+ exclude = _ask_list(
132
+ "Exclude these", hint="for example *embedding*, *-image-*"
133
+ )
86
134
 
87
- print(f"testing {base_url}/models")
135
+ print(term.heading(f"Testing {base_url}/models"))
88
136
  try:
89
137
  models = fetch_models(base_url, api_key or None)
90
138
  except EndpointUnreachable as error:
91
- print(f"error: {error}", file=sys.stderr)
139
+ _fail(str(error))
92
140
  return EXIT_UNREACHABLE
93
141
  except SourceError as error:
94
- print(f"error: {error}", file=sys.stderr)
142
+ _fail(str(error))
95
143
  return EXIT_ERROR
96
144
 
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),
145
+ kept = apply_filters(models, include, exclude)
146
+ _ok(f"the endpoint answered with {term.bold(str(len(models)))} models")
147
+ if len(kept) != len(models):
148
+ _ok(f"the filters keep {term.bold(str(len(kept)))} models")
149
+
150
+ _print_model_table(kept)
151
+
152
+ codex_home = default_codex_home()
153
+ known = wire_api_for_url(read_config(codex_home / "config.toml"), base_url)
154
+ print(term.heading("Wire API"))
155
+ if known:
156
+ print(
157
+ term.dim(
158
+ f" Codex already talks to this URL with {known!r}. "
159
+ "Press Enter to keep it."
107
160
  )
108
- if on
109
161
  )
110
- print(f" {model.slug:<48} {window:>12} {flags}")
111
-
112
- print()
113
- wire_api = _ask(f"wire API ({' or '.join(WIRE_APIS)})", "chat")
162
+ else:
163
+ print(
164
+ term.dim(
165
+ " Use chat for most OpenAI-compatible servers.\n"
166
+ " Use responses for the OpenAI Responses API.\n"
167
+ " A wrong value breaks every request."
168
+ )
169
+ )
170
+ wire_api = _ask(f"Wire API ({' or '.join(WIRE_APIS)})", known or "chat")
171
+ if known and wire_api != known:
172
+ _warn(
173
+ f"Codex uses {known!r} for this URL. The answer {wire_api!r} "
174
+ "disagrees with it."
175
+ )
114
176
 
115
177
  try:
116
178
  profile = Profile(
@@ -123,27 +185,92 @@ def cmd_setup(args: argparse.Namespace) -> int:
123
185
  exclude=exclude,
124
186
  )
125
187
  except ProfileError as error:
126
- print(f"error: {error}", file=sys.stderr)
188
+ _fail(str(error))
127
189
  return EXIT_ERROR
128
190
 
129
191
  store.add(profile)
130
192
  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")
193
+
194
+ print(term.heading("Saved"))
195
+ print(term.rule())
196
+ _print_profile(profile, active=store.active == profile.name)
197
+ print(term.dim(f"\n file: {store.path}"))
198
+ print(term.heading("Next"))
199
+ print(f" {term.bold('xsync codex --init')} connect Codex to this endpoint")
200
+ print(f" {term.bold('xsync codex --dry-run')} see what would change")
201
+ print(f" {term.bold('xsync codex')} write the catalog\n")
134
202
  return EXIT_OK
135
203
 
136
204
 
205
+ def _print_model_table(models: list) -> None:
206
+ """Show the models in a table."""
207
+ if not models:
208
+ _warn("no model passes the filters.")
209
+ return
210
+ slug_size = max(len(m.slug) for m in models)
211
+ print()
212
+ print(
213
+ " "
214
+ + term.dim(term.pad("MODEL", slug_size))
215
+ + term.dim(" " + "CONTEXT".rjust(12))
216
+ + term.dim(" FEATURES")
217
+ )
218
+ for model in models:
219
+ window = f"{model.context_window:,}" if model.context_window else "—"
220
+ flags = " ".join(
221
+ term.green(flag)
222
+ for flag, on in (
223
+ ("tools", model.tools),
224
+ ("reason", model.reasoning),
225
+ ("vision", model.vision),
226
+ ("search", model.search),
227
+ )
228
+ if on
229
+ )
230
+ print(
231
+ " "
232
+ + term.pad(model.slug, slug_size)
233
+ + " "
234
+ + window.rjust(12)
235
+ + " "
236
+ + (flags or term.dim("—"))
237
+ )
238
+ print()
239
+
240
+
241
+ def _print_profile(profile: Profile, active: bool) -> None:
242
+ """Show one profile as a block."""
243
+ mark = term.green("●") if active else term.dim("○")
244
+ label = term.bold(profile.name) + (term.dim(" (active)") if active else "")
245
+ print(f"{mark} {label}")
246
+ rows = [
247
+ ("url", profile.base_url),
248
+ ("key", term.mask_key(profile.api_key)
249
+ if profile.api_key
250
+ else (f"${profile.api_key_env}" if profile.api_key_env else "(none)")),
251
+ ("wire api", profile.wire_api),
252
+ ]
253
+ if profile.include:
254
+ rows.append(("include", ", ".join(profile.include)))
255
+ if profile.exclude:
256
+ rows.append(("exclude", ", ".join(profile.exclude)))
257
+ for key, value in rows:
258
+ print(f" {term.dim(key.ljust(9))} {value}")
259
+
260
+
137
261
  def cmd_list(args: argparse.Namespace) -> int:
138
262
  """Show the profiles."""
139
263
  store = open_store()
140
264
  if not store.names():
141
- print("no profile exists. Run `xsync setup` first.")
265
+ print(term.heading("No profile"))
266
+ print(f" run {term.bold('xsync setup')} to make one.\n")
142
267
  return EXIT_OK
268
+ print(term.heading("Profiles"))
269
+ print(term.rule())
143
270
  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}")
271
+ _print_profile(store.get(name), active=name == store.active)
272
+ print()
273
+ print(term.dim(f" file: {store.path}\n"))
147
274
  return EXIT_OK
148
275
 
149
276
 
@@ -183,7 +310,17 @@ def _load_catalog(path: Path) -> dict | None:
183
310
  return None
184
311
 
185
312
 
186
- def _do_reset(codex_home: Path, profile_name: str, force: bool) -> int:
313
+ def _confirm(question: str) -> bool:
314
+ """Ask for a yes. A pipe or a missing terminal gives no."""
315
+ if not getattr(sys.stdin, "isatty", lambda: False)():
316
+ return False
317
+ answer = input(f"{term.yellow('?')} {term.bold(question)} {term.dim('[yes/no]')}: ")
318
+ return answer.strip().lower() in ("y", "yes")
319
+
320
+
321
+ def _do_reset(
322
+ codex_home: Path, profile_name: str, force: bool, assume_yes: bool = False
323
+ ) -> int:
187
324
  """Remove everything that xSync wrote."""
188
325
  state_path = codex_home / STATE_FILENAME
189
326
  state = read_state(state_path)
@@ -191,12 +328,25 @@ def _do_reset(codex_home: Path, profile_name: str, force: bool) -> int:
191
328
  if state is None:
192
329
  planned = known_removals(profile_name)
193
330
  if not force:
194
- print("no state file exists. xSync would remove:")
331
+ print(term.heading("Reset without a state file"))
332
+ print(term.dim(" xsync would remove:"))
195
333
  for key in planned.keys_written:
196
- print(f" key {key}")
334
+ print(f" {term.red('-')} key {key}")
197
335
  for block in planned.blocks_written:
198
- print(f" block [{block}]")
199
- print("\nadd --force to continue.", file=sys.stderr)
336
+ print(f" {term.red('-')} block [{block}]")
337
+ _fail("add --force to continue.")
338
+ return EXIT_ERROR
339
+ print(term.heading("Reset without a state file"))
340
+ print(term.dim(" xsync did not record this setup. It would remove:"))
341
+ for key in planned.keys_written:
342
+ print(f" {term.red('-')} key {key}")
343
+ for block in planned.blocks_written:
344
+ print(f" {term.red('-')} block [{block}]")
345
+ print(term.dim("\n The catalog backup <name>.bak stays on the disk."))
346
+ if not assume_yes and not _confirm(
347
+ f"Remove the Codex setup of {profile_name!r}?"
348
+ ):
349
+ _fail("stopped. Nothing was removed.")
200
350
  return EXIT_ERROR
201
351
  state = State(
202
352
  profile=profile_name,
@@ -209,9 +359,13 @@ def _do_reset(codex_home: Path, profile_name: str, force: bool) -> int:
209
359
 
210
360
  removed = reset_config(codex_home / "config.toml", state)
211
361
  clear_state(state_path)
362
+ print(term.heading("Reset"))
363
+ print(term.rule())
212
364
  for name in removed:
213
- print(f"removed {name}")
214
- print("Codex is back at its own defaults.")
365
+ print(f" {term.red('-')} {name}")
366
+ print(term.rule())
367
+ _ok("Codex is back at its own defaults.")
368
+ print()
215
369
  return EXIT_OK
216
370
 
217
371
 
@@ -229,7 +383,7 @@ def cmd_codex(args: argparse.Namespace) -> int:
229
383
 
230
384
  if args.reset:
231
385
  try:
232
- return _do_reset(codex_home, profile.name, args.force)
386
+ return _do_reset(codex_home, profile.name, args.force, args.yes)
233
387
  except ConfigError as error:
234
388
  print(f"error: {error}", file=sys.stderr)
235
389
  return EXIT_ERROR
@@ -244,7 +398,10 @@ def cmd_codex(args: argparse.Namespace) -> int:
244
398
  print(f"error: {error}", file=sys.stderr)
245
399
  return EXIT_ERROR
246
400
  write_state(codex_home / STATE_FILENAME, state)
247
- print(f"Codex now routes to {profile.name!r} ({profile.base_url})")
401
+ _ok(
402
+ f"Codex now routes to {term.bold(profile.name)} "
403
+ f"{term.dim('(' + profile.base_url + ')')}"
404
+ )
248
405
 
249
406
  try:
250
407
  api_key = profile.resolve_key(os.environ)
@@ -262,24 +419,60 @@ def cmd_codex(args: argparse.Namespace) -> int:
262
419
  message = check_provider_match(config, profile)
263
420
  if message:
264
421
  if args.dry_run:
265
- print(f"warning:\n{message}\n")
422
+ _warn(message)
423
+ print()
266
424
  else:
267
- print(f"error:\n{message}", file=sys.stderr)
425
+ _fail(message)
268
426
  return EXIT_ERROR
269
427
 
270
428
  catalog = render_catalog(models, load_rules())
271
429
  report = diff_catalogs(_load_catalog(catalog_path), catalog)
272
- print(report.render())
430
+ _print_report(report, profile, len(catalog["models"]))
273
431
 
274
432
  if args.dry_run:
275
- print("no files written (--dry-run)")
433
+ print(term.dim(" no file written (--dry-run)\n"))
276
434
  return EXIT_OK
277
435
 
278
436
  write_json_atomic(catalog_path, catalog)
279
- print(f"wrote {len(catalog['models'])} models to {catalog_path}")
437
+ _ok(
438
+ f"wrote {term.bold(str(len(catalog['models'])))} models to "
439
+ f"{term.dim(str(catalog_path))}"
440
+ )
441
+ print()
280
442
  return EXIT_OK
281
443
 
282
444
 
445
+ def _print_report(report, profile: Profile, total: int) -> None:
446
+ """Show the difference of one sync."""
447
+ print(term.heading(f"Sync {profile.name} → Codex"))
448
+ print(term.dim(f" {profile.base_url}"))
449
+ print(term.rule())
450
+
451
+ if report.is_empty:
452
+ _ok(f"up to date. {term.bold(str(total))} models, no change")
453
+ return
454
+
455
+ for slug in report.added:
456
+ print(f" {term.green('+')} {slug}")
457
+ for slug in report.removed:
458
+ print(f" {term.red('-')} {term.dim(slug)}")
459
+ for slug, fields in report.changed:
460
+ head = ", ".join(fields[:3])
461
+ more = f" +{len(fields) - 3} more" if len(fields) > 3 else ""
462
+ print(f" {term.yellow('~')} {slug}{term.dim(f' ({head}{more})')}")
463
+
464
+ print(term.rule())
465
+ parts = []
466
+ if report.added:
467
+ parts.append(term.green(f"{len(report.added)} added"))
468
+ if report.removed:
469
+ parts.append(term.red(f"{len(report.removed)} removed"))
470
+ if report.changed:
471
+ parts.append(term.yellow(f"{len(report.changed)} changed"))
472
+ parts.append(term.dim(f"{len(report.unchanged)} unchanged"))
473
+ print(" " + " ".join(parts))
474
+
475
+
283
476
  EPILOG = """\
284
477
  examples:
285
478
  xsync setup make a profile, and read the model list
@@ -328,6 +521,9 @@ def build_parser() -> argparse.ArgumentParser:
328
521
  "--reset", action="store_true", help="remove everything xSync wrote"
329
522
  )
330
523
  codex.add_argument("--force", action="store_true", help="reset without a state file")
524
+ codex.add_argument(
525
+ "--yes", action="store_true", help="answer yes to the reset question"
526
+ )
331
527
  codex.set_defaults(func=cmd_codex)
332
528
 
333
529
  return parser