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.
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/PKG-INFO +32 -6
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/README.md +31 -5
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/pyproject.toml +1 -1
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/adapters/codex.py +6 -4
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/adapters/codex_config.py +15 -0
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/adapters/codex_wiring.py +5 -5
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/adapters/rules/codex.toml +1 -0
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/cli.py +248 -52
- xsync_cli-0.1.3/src/xsync_cli/core/term.py +115 -0
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/tests/test_cli_codex.py +67 -1
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/tests/test_cli_profiles.py +63 -6
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/tests/test_codex_config.py +31 -0
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/tests/test_codex_render.py +33 -1
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/tests/test_codex_wiring.py +18 -2
- xsync_cli-0.1.3/tests/test_term.py +96 -0
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/uv.lock +1 -1
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/.gitignore +0 -0
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/LICENSE +0 -0
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/docs/superpowers/plans/2026-09-13-xsync-cli.md +0 -0
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/docs/superpowers/specs/2026-09-13-xsync-design.md +0 -0
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/docs/verification-2026-09-13.md +0 -0
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/__init__.py +0 -0
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/adapters/__init__.py +0 -0
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/adapters/prompts/__init__.py +0 -0
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/adapters/prompts/generic-codex.md +0 -0
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/adapters/prompts/generic-codex.messages.json +0 -0
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/adapters/prompts/gpt-5.4.md +0 -0
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/adapters/prompts/gpt-5.4.messages.json +0 -0
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/adapters/prompts/gpt-5.5.md +0 -0
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/adapters/prompts/gpt-5.5.messages.json +0 -0
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/adapters/rules/__init__.py +0 -0
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/core/__init__.py +0 -0
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/core/atomic.py +0 -0
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/core/diff.py +0 -0
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/core/filters.py +0 -0
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/core/model.py +0 -0
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/core/profiles.py +0 -0
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/sources/__init__.py +0 -0
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/src/xsync_cli/sources/openai_compat.py +0 -0
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/tests/__init__.py +0 -0
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/tests/fixtures/__init__.py +0 -0
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/tests/fixtures/ninerouter_models.json +0 -0
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/tests/fixtures/plain_openai_models.json +0 -0
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/tests/test_atomic.py +0 -0
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/tests/test_diff.py +0 -0
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/tests/test_filters.py +0 -0
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/tests/test_model.py +0 -0
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/tests/test_openai_compat.py +0 -0
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/tests/test_package_data.py +0 -0
- {xsync_cli-0.1.1 → xsync_cli-0.1.3}/tests/test_profiles.py +0 -0
- {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.
|
|
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
|
|
124
|
-
|
|
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
|
-
|
|
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
|
|
112
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
112
|
+
_fail("the base URL cannot be empty.")
|
|
81
113
|
return EXIT_ERROR
|
|
82
114
|
|
|
83
|
-
api_key =
|
|
84
|
-
|
|
85
|
-
|
|
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"
|
|
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
|
-
|
|
139
|
+
_fail(str(error))
|
|
92
140
|
return EXIT_UNREACHABLE
|
|
93
141
|
except SourceError as error:
|
|
94
|
-
|
|
142
|
+
_fail(str(error))
|
|
95
143
|
return EXIT_ERROR
|
|
96
144
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
188
|
+
_fail(str(error))
|
|
127
189
|
return EXIT_ERROR
|
|
128
190
|
|
|
129
191
|
store.add(profile)
|
|
130
192
|
store.save()
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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("
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
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("
|
|
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"
|
|
334
|
+
print(f" {term.red('-')} key {key}")
|
|
197
335
|
for block in planned.blocks_written:
|
|
198
|
-
print(f"
|
|
199
|
-
|
|
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"
|
|
214
|
-
print(
|
|
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
|
-
|
|
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
|
-
|
|
422
|
+
_warn(message)
|
|
423
|
+
print()
|
|
266
424
|
else:
|
|
267
|
-
|
|
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
|
-
|
|
430
|
+
_print_report(report, profile, len(catalog["models"]))
|
|
273
431
|
|
|
274
432
|
if args.dry_run:
|
|
275
|
-
print("no
|
|
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
|
-
|
|
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
|