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