coderouter-cli 2.9.3__py3-none-any.whl → 2.10.0__py3-none-any.whl
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.
- coderouter/cli.py +125 -0
- coderouter/config/schemas.py +51 -1
- coderouter/ingress/launcher_routes.py +796 -3
- coderouter/launcher_devices.py +518 -0
- coderouter/translation/anthropic.py +26 -5
- coderouter/vscode_init.py +573 -0
- {coderouter_cli-2.9.3.dist-info → coderouter_cli-2.10.0.dist-info}/METADATA +5 -2
- {coderouter_cli-2.9.3.dist-info → coderouter_cli-2.10.0.dist-info}/RECORD +11 -9
- {coderouter_cli-2.9.3.dist-info → coderouter_cli-2.10.0.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.9.3.dist-info → coderouter_cli-2.10.0.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.9.3.dist-info → coderouter_cli-2.10.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
"""v2.10-A: ``coderouter vscode-init`` — one-shot VSCode workspace scaffolder.
|
|
2
|
+
|
|
3
|
+
Writes ``.vscode/settings.json`` with ``terminal.integrated.env.*`` so a
|
|
4
|
+
Claude Code session launched from VSCode's integrated terminal
|
|
5
|
+
auto-points at CodeRouter. Optionally emits a direnv ``.envrc`` in the
|
|
6
|
+
same run.
|
|
7
|
+
|
|
8
|
+
The design contract:
|
|
9
|
+
|
|
10
|
+
* stdlib only — no new runtime deps (keeps the strict-5 rule in
|
|
11
|
+
``pyproject.toml`` intact).
|
|
12
|
+
* Never overwrite unrelated keys in an existing ``settings.json``. Only
|
|
13
|
+
``terminal.integrated.env.osx`` / ``.linux`` / ``.windows`` are
|
|
14
|
+
touched, and inside them only the CodeRouter-managed env vars.
|
|
15
|
+
* When an existing target value differs from the desired one, refuse
|
|
16
|
+
without ``--force`` and print a unified diff so the operator decides.
|
|
17
|
+
* ``--dry-run`` is byte-identical to the write path minus the actual
|
|
18
|
+
``os.replace`` at the end.
|
|
19
|
+
* All writes are atomic (tmp file + ``os.replace``) so a partial write
|
|
20
|
+
cannot corrupt an existing ``settings.json``. This matters more here
|
|
21
|
+
than in most CodeRouter modules because the file we're editing is
|
|
22
|
+
workspace-critical: a broken JSON blob would silently break every
|
|
23
|
+
VSCode terminal env until a human debugged it.
|
|
24
|
+
|
|
25
|
+
Files touched (opt-in per flag):
|
|
26
|
+
|
|
27
|
+
* ``{target}/.vscode/settings.json`` — always
|
|
28
|
+
* ``{target}/.envrc`` — ``--with-envrc``
|
|
29
|
+
|
|
30
|
+
Non-Claude-Code extensions (Cline / Roo / Kilo / Continue) are handled
|
|
31
|
+
by the cheat sheet printed to stdout at end of run and the fuller
|
|
32
|
+
``docs/guides/vscode.md`` — this CLI deliberately does NOT reach into
|
|
33
|
+
those extensions' settings, because each ships its own config schema
|
|
34
|
+
that changes with its own release cadence.
|
|
35
|
+
|
|
36
|
+
CLI plumbing lives in :mod:`coderouter.cli` (``_run_vscode_init``); this
|
|
37
|
+
module is import-clean so tests can exercise ``run_vscode_init``
|
|
38
|
+
directly without argparse in the way.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import json
|
|
44
|
+
import os
|
|
45
|
+
from dataclasses import dataclass
|
|
46
|
+
from difflib import unified_diff
|
|
47
|
+
from pathlib import Path
|
|
48
|
+
|
|
49
|
+
# v2.10-A: The README, quickstart, and every backend guide teach 8088 as
|
|
50
|
+
# the CodeRouter port. ``coderouter serve`` still defaults to 4000 (a
|
|
51
|
+
# pre-README default that predates the docs unification); vscode-init
|
|
52
|
+
# sides with the docs so that the settings.json it writes matches every
|
|
53
|
+
# tutorial the user is likely to be reading. Operators who deviate can
|
|
54
|
+
# pass ``--port`` explicitly.
|
|
55
|
+
DEFAULT_PORT = 8088
|
|
56
|
+
|
|
57
|
+
# Bogus token — CodeRouter's chat ingress does not validate ANTHROPIC_AUTH_TOKEN
|
|
58
|
+
# (real API keys are managed on the ``providers.yaml`` side). Using a
|
|
59
|
+
# fixed sentinel here means we never accidentally embed the operator's
|
|
60
|
+
# real token into a workspace file.
|
|
61
|
+
DEFAULT_TOKEN = "dummy" # noqa: S105 — intentional placeholder, not a secret
|
|
62
|
+
|
|
63
|
+
# VSCode reads terminal env from a separate key per host OS. Writing all
|
|
64
|
+
# three means the same ``.vscode/`` folder works whether the developer
|
|
65
|
+
# opens it on macOS, Linux, or WSL/Windows without a second scaffolder
|
|
66
|
+
# run. The keys are stable across VSCode versions (documented public
|
|
67
|
+
# API since 1.x).
|
|
68
|
+
_TERMINAL_ENV_KEYS: tuple[str, ...] = (
|
|
69
|
+
"terminal.integrated.env.osx",
|
|
70
|
+
"terminal.integrated.env.linux",
|
|
71
|
+
"terminal.integrated.env.windows",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Env vars this scaffolder owns inside the terminal-env blocks. Anything
|
|
75
|
+
# NOT in this set that already exists under one of the terminal-env
|
|
76
|
+
# keys is preserved verbatim on merge (e.g. the user's PATH tweak).
|
|
77
|
+
_MANAGED_ENV_KEYS: tuple[str, ...] = (
|
|
78
|
+
"ANTHROPIC_BASE_URL",
|
|
79
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
80
|
+
"CODEROUTER_MODE",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class FileOutcome:
|
|
86
|
+
"""The outcome of touching (or planning to touch) one file.
|
|
87
|
+
|
|
88
|
+
``action`` values:
|
|
89
|
+
|
|
90
|
+
* ``created`` — file didn't exist, we wrote it
|
|
91
|
+
* ``updated`` — file existed, we changed it
|
|
92
|
+
* ``unchanged`` — file already had the desired content
|
|
93
|
+
* ``conflict`` — existing content differs and ``--force`` was
|
|
94
|
+
not set; the file was NOT written
|
|
95
|
+
* ``skipped`` — hard error prevented processing (unparseable
|
|
96
|
+
JSON, wrong type, permission denied); NOT written
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
path: Path
|
|
100
|
+
action: str
|
|
101
|
+
diff: str = ""
|
|
102
|
+
reason: str = ""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class VSCodeInitResult:
|
|
107
|
+
outcomes: list[FileOutcome]
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def has_conflicts(self) -> bool:
|
|
111
|
+
return any(o.action == "conflict" for o in self.outcomes)
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def has_skipped(self) -> bool:
|
|
115
|
+
return any(o.action == "skipped" for o in self.outcomes)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def run_vscode_init(
|
|
119
|
+
target: Path,
|
|
120
|
+
*,
|
|
121
|
+
port: int = DEFAULT_PORT,
|
|
122
|
+
profile: str | None = None,
|
|
123
|
+
with_envrc: bool = False,
|
|
124
|
+
dry_run: bool = False,
|
|
125
|
+
force: bool = False,
|
|
126
|
+
) -> VSCodeInitResult:
|
|
127
|
+
"""Scaffold VSCode workspace settings for CodeRouter.
|
|
128
|
+
|
|
129
|
+
Parameters
|
|
130
|
+
----------
|
|
131
|
+
target:
|
|
132
|
+
Workspace root — the folder that VSCode opens. ``.vscode/`` and
|
|
133
|
+
(if ``with_envrc``) ``.envrc`` are placed under this directory.
|
|
134
|
+
Must already exist as a directory; we do not create the
|
|
135
|
+
workspace root itself.
|
|
136
|
+
port:
|
|
137
|
+
CodeRouter port for ``ANTHROPIC_BASE_URL``. Defaults to 8088
|
|
138
|
+
(docs-standard); override to match a non-default ``serve``.
|
|
139
|
+
profile:
|
|
140
|
+
Optional ``CODEROUTER_MODE`` value to preload. When set, the
|
|
141
|
+
VSCode terminal gets ``CODEROUTER_MODE=<profile>`` too.
|
|
142
|
+
with_envrc:
|
|
143
|
+
When true, also write ``.envrc`` (direnv). Idempotent and
|
|
144
|
+
conflict-aware just like ``settings.json``.
|
|
145
|
+
dry_run:
|
|
146
|
+
When true, compute all the outcomes and diffs but skip the
|
|
147
|
+
actual ``os.replace``. Guarantees byte-parity with the write
|
|
148
|
+
path — see the module docstring for the invariant.
|
|
149
|
+
force:
|
|
150
|
+
When true, existing conflicting values are overwritten. Without
|
|
151
|
+
this flag, a conflict is reported with a diff and the file is
|
|
152
|
+
left untouched.
|
|
153
|
+
|
|
154
|
+
Raises
|
|
155
|
+
------
|
|
156
|
+
FileNotFoundError:
|
|
157
|
+
The target directory does not exist. All other issues (bad
|
|
158
|
+
JSON, permission denied, existing conflicts) are captured as
|
|
159
|
+
``FileOutcome`` entries with the appropriate action.
|
|
160
|
+
"""
|
|
161
|
+
target = Path(target).expanduser().resolve()
|
|
162
|
+
if not target.is_dir():
|
|
163
|
+
raise FileNotFoundError(f"target directory does not exist: {target}")
|
|
164
|
+
|
|
165
|
+
base_url = f"http://localhost:{port}"
|
|
166
|
+
outcomes: list[FileOutcome] = []
|
|
167
|
+
|
|
168
|
+
# 1. .vscode/settings.json (always).
|
|
169
|
+
outcomes.append(
|
|
170
|
+
_apply_settings_json(
|
|
171
|
+
target / ".vscode" / "settings.json",
|
|
172
|
+
base_url=base_url,
|
|
173
|
+
profile=profile,
|
|
174
|
+
force=force,
|
|
175
|
+
dry_run=dry_run,
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# 2. .envrc (opt-in via --with-envrc).
|
|
180
|
+
if with_envrc:
|
|
181
|
+
outcomes.append(
|
|
182
|
+
_apply_envrc(
|
|
183
|
+
target / ".envrc",
|
|
184
|
+
base_url=base_url,
|
|
185
|
+
profile=profile,
|
|
186
|
+
force=force,
|
|
187
|
+
dry_run=dry_run,
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
return VSCodeInitResult(outcomes=outcomes)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ---------------------------------------------------------------------------
|
|
195
|
+
# settings.json
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _apply_settings_json(
|
|
200
|
+
path: Path,
|
|
201
|
+
*,
|
|
202
|
+
base_url: str,
|
|
203
|
+
profile: str | None,
|
|
204
|
+
force: bool,
|
|
205
|
+
dry_run: bool,
|
|
206
|
+
) -> FileOutcome:
|
|
207
|
+
desired_env: dict[str, str] = {
|
|
208
|
+
"ANTHROPIC_BASE_URL": base_url,
|
|
209
|
+
"ANTHROPIC_AUTH_TOKEN": DEFAULT_TOKEN,
|
|
210
|
+
}
|
|
211
|
+
if profile:
|
|
212
|
+
desired_env["CODEROUTER_MODE"] = profile
|
|
213
|
+
|
|
214
|
+
old_text = ""
|
|
215
|
+
if path.exists():
|
|
216
|
+
try:
|
|
217
|
+
old_text = path.read_text(encoding="utf-8")
|
|
218
|
+
except OSError as exc:
|
|
219
|
+
return FileOutcome(
|
|
220
|
+
path=path,
|
|
221
|
+
action="skipped",
|
|
222
|
+
reason=f"cannot read existing file: {exc}",
|
|
223
|
+
)
|
|
224
|
+
try:
|
|
225
|
+
existing = json.loads(old_text) if old_text.strip() else {}
|
|
226
|
+
except json.JSONDecodeError as exc:
|
|
227
|
+
return FileOutcome(
|
|
228
|
+
path=path,
|
|
229
|
+
action="skipped",
|
|
230
|
+
reason=(
|
|
231
|
+
"existing settings.json is not valid JSON "
|
|
232
|
+
f"({exc.msg} at line {exc.lineno}). Fix or move it "
|
|
233
|
+
"aside and re-run."
|
|
234
|
+
),
|
|
235
|
+
)
|
|
236
|
+
if not isinstance(existing, dict):
|
|
237
|
+
return FileOutcome(
|
|
238
|
+
path=path,
|
|
239
|
+
action="skipped",
|
|
240
|
+
reason=(
|
|
241
|
+
"existing settings.json is not a JSON object "
|
|
242
|
+
f"(got {type(existing).__name__}). Fix or move it "
|
|
243
|
+
"aside and re-run."
|
|
244
|
+
),
|
|
245
|
+
)
|
|
246
|
+
else:
|
|
247
|
+
existing = {}
|
|
248
|
+
|
|
249
|
+
merged, conflicts = _merge_terminal_env(existing, desired_env, force=force)
|
|
250
|
+
|
|
251
|
+
if conflicts and not force:
|
|
252
|
+
# Report the diff between existing and what --force WOULD write
|
|
253
|
+
# so the operator can decide whether the change is desired.
|
|
254
|
+
force_merged, _ = _merge_terminal_env(existing, desired_env, force=True)
|
|
255
|
+
diff = _json_diff(existing, force_merged, path)
|
|
256
|
+
return FileOutcome(
|
|
257
|
+
path=path,
|
|
258
|
+
action="conflict",
|
|
259
|
+
diff=diff,
|
|
260
|
+
reason=(
|
|
261
|
+
"existing settings.json has different value(s) for "
|
|
262
|
+
f"{', '.join(sorted(conflicts))}. Re-run with --force to overwrite."
|
|
263
|
+
),
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
new_text = _dump_json(merged)
|
|
267
|
+
if new_text == old_text:
|
|
268
|
+
return FileOutcome(path=path, action="unchanged")
|
|
269
|
+
|
|
270
|
+
diff = _text_diff(old_text, new_text, path)
|
|
271
|
+
if not dry_run:
|
|
272
|
+
try:
|
|
273
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
274
|
+
_atomic_write(path, new_text)
|
|
275
|
+
except OSError as exc:
|
|
276
|
+
return FileOutcome(
|
|
277
|
+
path=path,
|
|
278
|
+
action="skipped",
|
|
279
|
+
reason=f"write failed: {exc}",
|
|
280
|
+
diff=diff,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
return FileOutcome(
|
|
284
|
+
path=path,
|
|
285
|
+
action="updated" if old_text else "created",
|
|
286
|
+
diff=diff,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _merge_terminal_env(
|
|
291
|
+
existing: dict,
|
|
292
|
+
desired_env: dict[str, str],
|
|
293
|
+
*,
|
|
294
|
+
force: bool,
|
|
295
|
+
) -> tuple[dict, set[str]]:
|
|
296
|
+
"""Merge our env vars into a settings.json dict.
|
|
297
|
+
|
|
298
|
+
Preserves unrelated top-level keys (``editor.fontSize`` etc.) AND
|
|
299
|
+
unrelated env keys inside the three ``terminal.integrated.env.*``
|
|
300
|
+
blocks. When ``force`` is false, an existing conflicting value for
|
|
301
|
+
one of our managed keys is left in place and its dotted path
|
|
302
|
+
(e.g. ``terminal.integrated.env.osx.ANTHROPIC_BASE_URL``) is added
|
|
303
|
+
to the returned conflict set.
|
|
304
|
+
|
|
305
|
+
Returns
|
|
306
|
+
-------
|
|
307
|
+
merged:
|
|
308
|
+
The merged dict, safe to serialize with :func:`_dump_json`.
|
|
309
|
+
conflicts:
|
|
310
|
+
Set of dotted paths whose existing value differs from the
|
|
311
|
+
desired one and were NOT overwritten (because ``force=False``).
|
|
312
|
+
"""
|
|
313
|
+
# Deep copy via json round-trip — the input dict may share nested
|
|
314
|
+
# references with the caller and we must not mutate it.
|
|
315
|
+
merged = json.loads(json.dumps(existing))
|
|
316
|
+
conflicts: set[str] = set()
|
|
317
|
+
|
|
318
|
+
for os_key in _TERMINAL_ENV_KEYS:
|
|
319
|
+
sub = merged.get(os_key)
|
|
320
|
+
if not isinstance(sub, dict):
|
|
321
|
+
# Overwrite any non-dict value at this key — VSCode would
|
|
322
|
+
# ignore it anyway. Preserve nothing since the previous
|
|
323
|
+
# value was already broken.
|
|
324
|
+
sub = {}
|
|
325
|
+
|
|
326
|
+
for env_key, env_val in desired_env.items():
|
|
327
|
+
if env_key in sub and sub[env_key] != env_val and not force:
|
|
328
|
+
conflicts.add(f"{os_key}.{env_key}")
|
|
329
|
+
# Leave sub[env_key] unchanged.
|
|
330
|
+
continue
|
|
331
|
+
sub[env_key] = env_val
|
|
332
|
+
|
|
333
|
+
merged[os_key] = sub
|
|
334
|
+
|
|
335
|
+
return merged, conflicts
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# ---------------------------------------------------------------------------
|
|
339
|
+
# .envrc
|
|
340
|
+
# ---------------------------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _apply_envrc(
|
|
344
|
+
path: Path,
|
|
345
|
+
*,
|
|
346
|
+
base_url: str,
|
|
347
|
+
profile: str | None,
|
|
348
|
+
force: bool,
|
|
349
|
+
dry_run: bool,
|
|
350
|
+
) -> FileOutcome:
|
|
351
|
+
new_text = _render_envrc(base_url=base_url, profile=profile)
|
|
352
|
+
old_text = ""
|
|
353
|
+
if path.exists():
|
|
354
|
+
try:
|
|
355
|
+
old_text = path.read_text(encoding="utf-8")
|
|
356
|
+
except OSError as exc:
|
|
357
|
+
return FileOutcome(
|
|
358
|
+
path=path,
|
|
359
|
+
action="skipped",
|
|
360
|
+
reason=f"cannot read existing file: {exc}",
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
if new_text == old_text:
|
|
364
|
+
return FileOutcome(path=path, action="unchanged")
|
|
365
|
+
|
|
366
|
+
diff = _text_diff(old_text, new_text, path)
|
|
367
|
+
|
|
368
|
+
if old_text and not force:
|
|
369
|
+
return FileOutcome(
|
|
370
|
+
path=path,
|
|
371
|
+
action="conflict",
|
|
372
|
+
diff=diff,
|
|
373
|
+
reason=(
|
|
374
|
+
".envrc already exists and its contents differ. "
|
|
375
|
+
"Re-run with --force to overwrite. "
|
|
376
|
+
"(Tip: keep secrets in a separate .envrc.local and "
|
|
377
|
+
"``source_env_if_exists`` from .envrc.)"
|
|
378
|
+
),
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
if not dry_run:
|
|
382
|
+
try:
|
|
383
|
+
_atomic_write(path, new_text)
|
|
384
|
+
except OSError as exc:
|
|
385
|
+
return FileOutcome(
|
|
386
|
+
path=path,
|
|
387
|
+
action="skipped",
|
|
388
|
+
reason=f"write failed: {exc}",
|
|
389
|
+
diff=diff,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
return FileOutcome(
|
|
393
|
+
path=path,
|
|
394
|
+
action="updated" if old_text else "created",
|
|
395
|
+
diff=diff,
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _render_envrc(*, base_url: str, profile: str | None) -> str:
|
|
400
|
+
"""Build the .envrc contents. Kept split for test targeting."""
|
|
401
|
+
lines = [
|
|
402
|
+
"# Auto-generated by `coderouter vscode-init`. Safe to edit.",
|
|
403
|
+
"# direnv will export these when you `cd` into this directory.",
|
|
404
|
+
"# Run `direnv allow` once after generation.",
|
|
405
|
+
"",
|
|
406
|
+
f'export ANTHROPIC_BASE_URL="{base_url}"',
|
|
407
|
+
f'export ANTHROPIC_AUTH_TOKEN="{DEFAULT_TOKEN}"',
|
|
408
|
+
]
|
|
409
|
+
if profile:
|
|
410
|
+
lines.append(f'export CODEROUTER_MODE="{profile}"')
|
|
411
|
+
lines.append("") # trailing newline
|
|
412
|
+
return "\n".join(lines)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
# ---------------------------------------------------------------------------
|
|
416
|
+
# Rendering helpers
|
|
417
|
+
# ---------------------------------------------------------------------------
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def format_result(result: VSCodeInitResult, *, dry_run: bool, port: int) -> str:
|
|
421
|
+
"""Human-readable rendering of the outcomes for CLI stdout.
|
|
422
|
+
|
|
423
|
+
The output has three sections:
|
|
424
|
+
|
|
425
|
+
1. Per-file outcome lines (with unified diffs where relevant).
|
|
426
|
+
2. A cheat sheet for the extensions this scaffolder does NOT wire
|
|
427
|
+
(Cline / Roo / Kilo / Continue) — the user copy-pastes.
|
|
428
|
+
3. A trailing hint about the fuller ``docs/guides/vscode.md``.
|
|
429
|
+
|
|
430
|
+
``dry_run`` toggles the "(dry-run — nothing written)" reminder.
|
|
431
|
+
"""
|
|
432
|
+
lines: list[str] = []
|
|
433
|
+
marker_for = {
|
|
434
|
+
"created": " new ",
|
|
435
|
+
"updated": " diff ",
|
|
436
|
+
"unchanged": " nop ",
|
|
437
|
+
"conflict": "CONFLICT",
|
|
438
|
+
"skipped": " SKIP ",
|
|
439
|
+
}
|
|
440
|
+
for o in result.outcomes:
|
|
441
|
+
marker = marker_for.get(o.action, f" {o.action} ")
|
|
442
|
+
lines.append(f"[{marker}] {o.path}")
|
|
443
|
+
if o.reason:
|
|
444
|
+
lines.append(f" → {o.reason}")
|
|
445
|
+
if o.diff and o.action in ("created", "updated", "conflict"):
|
|
446
|
+
lines.append("")
|
|
447
|
+
lines.extend(o.diff.rstrip("\n").splitlines())
|
|
448
|
+
lines.append("")
|
|
449
|
+
|
|
450
|
+
if dry_run:
|
|
451
|
+
lines.append("")
|
|
452
|
+
lines.append("(dry-run — no files were written. Re-run without --dry-run to apply.)")
|
|
453
|
+
|
|
454
|
+
lines.append("")
|
|
455
|
+
lines.append(_render_cheat_sheet(port))
|
|
456
|
+
return "\n".join(lines)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _render_cheat_sheet(port: int) -> str:
|
|
460
|
+
"""Emit the copy-paste snippets shown after every ``vscode-init`` run.
|
|
461
|
+
|
|
462
|
+
The Continue.dev snippet is built with :func:`json.dumps` rather
|
|
463
|
+
than hand-formatted string concatenation to guarantee the output
|
|
464
|
+
is syntactically valid JSON. An earlier hand-formatted version
|
|
465
|
+
accidentally leaked an extra ``}`` from mismatched f-string /
|
|
466
|
+
plain-string brace escaping — the JSON round-trip closes that
|
|
467
|
+
class of bug and the test in ``tests/test_vscode_init.py`` pins
|
|
468
|
+
the invariant.
|
|
469
|
+
"""
|
|
470
|
+
continue_snippet = _render_continue_snippet(port)
|
|
471
|
+
return (
|
|
472
|
+
"─── Other extensions (copy/paste) ─────────────────────────────\n"
|
|
473
|
+
"\n"
|
|
474
|
+
" Cline / Roo Code / Kilo Code:\n"
|
|
475
|
+
" API Provider: OpenAI Compatible\n"
|
|
476
|
+
f" Base URL: http://localhost:{port}/v1\n"
|
|
477
|
+
" API Key: dummy (any non-empty value)\n"
|
|
478
|
+
" Model ID: anything (CodeRouter routes via default_profile)\n"
|
|
479
|
+
"\n"
|
|
480
|
+
" Continue.dev — add to the models array in ~/.continue/config.json:\n"
|
|
481
|
+
f"{continue_snippet}\n"
|
|
482
|
+
"\n"
|
|
483
|
+
"Full guide: docs/guides/vscode.md in the CodeRouter repo.\n"
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _render_continue_snippet(port: int) -> str:
|
|
488
|
+
"""Return one indented pretty-printed JSON block for Continue.dev.
|
|
489
|
+
|
|
490
|
+
Split out from :func:`_render_cheat_sheet` so tests can
|
|
491
|
+
``json.loads`` the exact bytes users copy-paste and verify the
|
|
492
|
+
snippet is valid.
|
|
493
|
+
"""
|
|
494
|
+
model_entry: dict[str, str] = {
|
|
495
|
+
"title": "CodeRouter",
|
|
496
|
+
"provider": "openai",
|
|
497
|
+
"model": "any-id",
|
|
498
|
+
"apiBase": f"http://localhost:{port}/v1",
|
|
499
|
+
"apiKey": "dummy",
|
|
500
|
+
}
|
|
501
|
+
pretty = json.dumps(model_entry, indent=2, ensure_ascii=False)
|
|
502
|
+
# Indent every line by 4 spaces so it aligns under the "─── Other
|
|
503
|
+
# extensions" header exactly like the Cline block above.
|
|
504
|
+
return "\n".join(" " + line for line in pretty.splitlines())
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def exit_code_for(result: VSCodeInitResult) -> int:
|
|
508
|
+
"""Map the outcome set to a shell exit code.
|
|
509
|
+
|
|
510
|
+
* 0 — every file is clean or a no-op.
|
|
511
|
+
* 2 — at least one conflict (needs ``--force``); nothing was
|
|
512
|
+
written for the conflicting file(s), but other files may
|
|
513
|
+
have been.
|
|
514
|
+
* 1 — a hard error prevented processing (unparseable JSON, write
|
|
515
|
+
failure). Treated as more severe than a conflict.
|
|
516
|
+
"""
|
|
517
|
+
if result.has_skipped:
|
|
518
|
+
return 1
|
|
519
|
+
if result.has_conflicts:
|
|
520
|
+
return 2
|
|
521
|
+
return 0
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
# ---------------------------------------------------------------------------
|
|
525
|
+
# Low-level I/O helpers
|
|
526
|
+
# ---------------------------------------------------------------------------
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _dump_json(obj: object) -> str:
|
|
530
|
+
"""Deterministic pretty JSON for settings.json.
|
|
531
|
+
|
|
532
|
+
``indent=2`` matches VSCode's own formatter default, ``ensure_ascii=False``
|
|
533
|
+
keeps CJK env values (e.g. a profile name) readable in the file.
|
|
534
|
+
A trailing newline is appended so POSIX tools don't complain.
|
|
535
|
+
"""
|
|
536
|
+
return json.dumps(obj, indent=2, ensure_ascii=False) + "\n"
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _atomic_write(path: Path, text: str) -> None:
|
|
540
|
+
"""Write ``text`` to ``path`` atomically (tmp + os.replace).
|
|
541
|
+
|
|
542
|
+
A partial write cannot corrupt the target file: the tmp file lives
|
|
543
|
+
on the same filesystem (same parent directory) so ``os.replace`` is
|
|
544
|
+
an atomic inode swap on POSIX. On Windows ``os.replace`` is also
|
|
545
|
+
atomic since Python 3.3.
|
|
546
|
+
|
|
547
|
+
The tmp file uses the parent + ``<name>.tmp`` pattern rather than
|
|
548
|
+
``Path.with_suffix`` because ``.envrc`` has no stem/suffix in the
|
|
549
|
+
``Path`` sense and ``with_suffix`` would silently misbehave.
|
|
550
|
+
"""
|
|
551
|
+
tmp = path.parent / (path.name + ".tmp")
|
|
552
|
+
tmp.write_text(text, encoding="utf-8")
|
|
553
|
+
os.replace(tmp, path)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _text_diff(old: str, new: str, path: Path) -> str:
|
|
557
|
+
"""Unified diff between two text buffers, labelled by file name."""
|
|
558
|
+
return "".join(
|
|
559
|
+
unified_diff(
|
|
560
|
+
old.splitlines(keepends=True),
|
|
561
|
+
new.splitlines(keepends=True),
|
|
562
|
+
fromfile=f"a/{path.name}",
|
|
563
|
+
tofile=f"b/{path.name}",
|
|
564
|
+
n=3,
|
|
565
|
+
)
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _json_diff(old: dict, new: dict, path: Path) -> str:
|
|
570
|
+
"""Diff between two JSON dicts, formatted as ``_dump_json`` would."""
|
|
571
|
+
old_text = _dump_json(old) if old else ""
|
|
572
|
+
new_text = _dump_json(new)
|
|
573
|
+
return _text_diff(old_text, new_text, path)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: coderouter-cli
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.10.0
|
|
4
4
|
Summary: Local-first, free-first, fallback-built-in LLM router. Claude Code / OpenAI compatible.
|
|
5
5
|
Project-URL: Homepage, https://github.com/zephel01/CodeRouter
|
|
6
6
|
Project-URL: Repository, https://github.com/zephel01/CodeRouter
|
|
@@ -40,7 +40,7 @@ Requires-Dist: types-pyyaml>=6.0.12; extra == 'dev'
|
|
|
40
40
|
Provides-Extra: doctor
|
|
41
41
|
Requires-Dist: ruamel-yaml>=0.18.6; extra == 'doctor'
|
|
42
42
|
Provides-Extra: repair
|
|
43
|
-
Requires-Dist: json-repair>=0.
|
|
43
|
+
Requires-Dist: json-repair>=0.60.1; extra == 'repair'
|
|
44
44
|
Description-Content-Type: text/markdown
|
|
45
45
|
|
|
46
46
|
<h1 align="center">CodeRouter</h1>
|
|
@@ -132,6 +132,8 @@ ANTHROPIC_BASE_URL=http://localhost:8088 ANTHROPIC_AUTH_TOKEN=dummy claude
|
|
|
132
132
|
|
|
133
133
|
これだけ。Claude Code はいつも通り動きますが、裏ではローカルの Ollama が答えています。
|
|
134
134
|
|
|
135
|
+
**VSCode の統合ターミナルから使う場合** は、環境変数の書き忘れを避けるため `coderouter vscode-init` が便利です。プロジェクトルートで 1 回叩くと `.vscode/settings.json` に `terminal.integrated.env.*` をマージ書き込みするので、以後 VSCode のターミナルで `claude` と打つだけで通ります。Cline / Roo Code / Continue.dev の設定コピペも含めて → [VSCode 連携ガイド](./docs/guides/vscode.md)
|
|
136
|
+
|
|
135
137
|
---
|
|
136
138
|
|
|
137
139
|
## 自分に必要?
|
|
@@ -281,6 +283,7 @@ providers:
|
|
|
281
283
|
| 無料で回す | [無料枠ガイド](./docs/guides/free-tier-guide.md) |
|
|
282
284
|
| llama.cpp / vllm を GUI で起動 | [Launcher ガイド](./docs/backends/launcher.md) |
|
|
283
285
|
| 言語税を計測・回避する | [言語税ガイド](./docs/guides/language-tax.md) |
|
|
286
|
+
| VSCode / Cline / Continue から使う | [VSCode 連携ガイド](./docs/guides/vscode.md) |
|
|
284
287
|
| 別の PC から安全に繋ぐ | [リモートアクセスガイド](./docs/guides/remote-access.md) |
|
|
285
288
|
| 詰まった | [トラブルシューティング](./docs/guides/troubleshooting.md) |
|
|
286
289
|
| 設計を知りたい | [アーキテクチャ詳細](./docs/concepts/architecture.md) |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
coderouter/__init__.py,sha256=ghdjPrLtnRzY8fyQ4CJZI1UJKADyNTLtA3G7se8H7Ns,696
|
|
2
2
|
coderouter/__main__.py,sha256=-LCgxJnvgUV240HjQKv7ly-mn2NuKHpC4nCpvTHjeSU,130
|
|
3
|
-
coderouter/cli.py,sha256=
|
|
3
|
+
coderouter/cli.py,sha256=rEo-G9fyIBXnRWmVCHxWBlfzII2CWMcFvhRJW5h2oBU,34650
|
|
4
4
|
coderouter/cli_stats.py,sha256=CCjzc1G4hTRHZ2gG1XhxhDpUkJnnl3NXbcbp1T18jpg,29894
|
|
5
5
|
coderouter/cost.py,sha256=32h6uzb4nxh2eA5d2Hn3kD9yJbtis6CFDAbeIy5KRkM,7431
|
|
6
6
|
coderouter/doctor.py,sha256=x0OqWUMGPzYaX2poWSH2UA90sEr4Z69-gVYLXYQFCFU,85483
|
|
@@ -10,12 +10,14 @@ coderouter/errors.py,sha256=Xmq67lheyw8iv3Ox39jh2c4tvNI5RcUR4QkoxVDN6l4,1130
|
|
|
10
10
|
coderouter/gguf_introspect.py,sha256=KeE00CfbYRa4gSTrzuwC6zVuHi20IzLC4nUQa98BEXI,10387
|
|
11
11
|
coderouter/hardware.py,sha256=gn3_9qbVcGRR81yKMn1lJE_8-YDRau0LxIH_M-f7pxE,8356
|
|
12
12
|
coderouter/language_tax.py,sha256=LTbE3tIfoJuV2O3T0NixRKhzq_dEOTUuPEerJv2q9uk,9360
|
|
13
|
+
coderouter/launcher_devices.py,sha256=SoGpzknRqvnmvg_IeXy1TkeE7aLc_MOFqLzXdd3CjVs,19275
|
|
13
14
|
coderouter/launcher_speculative.py,sha256=kWaHNmhzBsbWuM_lRGDlbWkgJ1suuxBWQNTKhJOC-yg,12644
|
|
14
15
|
coderouter/launcher_swap.py,sha256=W7qpdB-8Npr8yHGfx0N3SgZ2vDJ6BR9JuxW0I3TaZ-4,23783
|
|
15
16
|
coderouter/logging.py,sha256=5rfi4_R4aoeeUYDc642dgs61wA0aBX7wvubyB-k3Uyo,58074
|
|
16
17
|
coderouter/output_filters.py,sha256=0ry_rPiS_kC-FnHgaNVP6v7e6Al2djxzu9vBzZ8kEkE,25314
|
|
17
18
|
coderouter/token_estimation.py,sha256=iz22vZEEW2P7uKLB2pYvPNpIbZGbgXRO5MtfkS_-9Sk,7531
|
|
18
19
|
coderouter/token_estimation_accurate.py,sha256=GTfzrBVnvAGjeVzmzAeUdOYZvWZKLAxcxPpFiJGlzjk,4609
|
|
20
|
+
coderouter/vscode_init.py,sha256=fAcxJmwGXX8u0ylW0GHGACe_Co9VL6wdAKNFw7-ulwg,19952
|
|
19
21
|
coderouter/adapters/__init__.py,sha256=7dIDSZ-FE_0iSqLSDc_lK1idRdLTKcM2hP9tCJipgPI,463
|
|
20
22
|
coderouter/adapters/anthropic_native.py,sha256=CT9Hitun-c3z83YHT2zZXgwZpY3_t6eRnOfAmNud2aw,23610
|
|
21
23
|
coderouter/adapters/base.py,sha256=ykKMaiIVVGr23oFFXilK2iP_YhI8-CtsEiJyFXaC9bE,10396
|
|
@@ -25,7 +27,7 @@ coderouter/config/__init__.py,sha256=FODEn74fN-qZnt4INPSHswqhOlEgpL6-_onxsitSx8g
|
|
|
25
27
|
coderouter/config/capability_registry.py,sha256=RZgzHF54dXy0CCDocRCz2ee21qXGbbgTSKTkRI9iLL4,16936
|
|
26
28
|
coderouter/config/env_file.py,sha256=CoMK27fuAXm-NtoLzXb8yN2E-wDFjHQuFwiIlmgTBQw,10356
|
|
27
29
|
coderouter/config/loader.py,sha256=FUEe8m4Tnmj_aul0vSctD8vKvNW-oLRoMRbTpSKqSmc,4077
|
|
28
|
-
coderouter/config/schemas.py,sha256=
|
|
30
|
+
coderouter/config/schemas.py,sha256=X2LeonDTTIkesbqzmmo3QYcD1dwmjGiCalMyS5X-A1o,120986
|
|
29
31
|
coderouter/data/__init__.py,sha256=uNyfD9jaCvTWsBAWtaw1Fr25OSxzv3psGMfBjT1z0Cc,328
|
|
30
32
|
coderouter/data/model-capabilities.yaml,sha256=S9jt6SC6-3s2-icZ_n-a14iEMnc2yB1C2R6q-N_tZWQ,19309
|
|
31
33
|
coderouter/guards/__init__.py,sha256=5qliYBqygvVPneej7nx0uSjxDKsz7t8VzvrDgVBJlvU,1170
|
|
@@ -43,7 +45,7 @@ coderouter/ingress/__init__.py,sha256=WQsCH2CGJCAhy0mS6GSEdeYZRkkQu2OHDsP4CJWTLu
|
|
|
43
45
|
coderouter/ingress/anthropic_routes.py,sha256=1ThXzx8VgxRV7UCi3NF5ey7kbr0ADPKTXyQuYS5gK1w,27063
|
|
44
46
|
coderouter/ingress/app.py,sha256=6e_qNlYZIyjLKhY20t0ZNL-x5Fv8R8oaIH_HCdxa9Ic,20921
|
|
45
47
|
coderouter/ingress/dashboard_routes.py,sha256=1SqNNrVTfQiZcx5n2MvjAzWirittX0e61tCt-MKp4Ag,25617
|
|
46
|
-
coderouter/ingress/launcher_routes.py,sha256=
|
|
48
|
+
coderouter/ingress/launcher_routes.py,sha256=lU2EILVAi-lvHBHIxyixsMTRZlp-Rv7-2sw-eW4ccYg,119687
|
|
47
49
|
coderouter/ingress/metrics_routes.py,sha256=M22dwOGn24P05Ge4W3c7d7mYytSGWjIR-pPSPOAiHJY,3965
|
|
48
50
|
coderouter/ingress/openai_routes.py,sha256=TrugsQNJfNPgKKLzfT1aXzrs4emWYGE4XebEBuaZoWk,12505
|
|
49
51
|
coderouter/metrics/__init__.py,sha256=7Es351DPS7yLM0yVF_F0eesmiD83n7Zzhie44chht38,1465
|
|
@@ -66,11 +68,11 @@ coderouter/state/request_log.py,sha256=mNxQJ9IDGpUtT5rZbCo_-CTyhxmvig7C2Gfvdn9rc
|
|
|
66
68
|
coderouter/state/store.py,sha256=h-rsMJq8GILsOfCP94nI40cuHaj4Vqycsm9UNN77REI,7445
|
|
67
69
|
coderouter/state/suggest_rules.py,sha256=FvdhEvao5NvdKp9zs8AkcoFKHY4yqqXY2HekvSjpDFA,16670
|
|
68
70
|
coderouter/translation/__init__.py,sha256=PYXN7XVEwpG1uC8RLy6fvnGbzEZhhrEuUapH8IYOtG8,1788
|
|
69
|
-
coderouter/translation/anthropic.py,sha256=
|
|
71
|
+
coderouter/translation/anthropic.py,sha256=uEScq-icLn6ZXFWaxsKROv7feaq3zD0ZogjbTJ3Y7gQ,12578
|
|
70
72
|
coderouter/translation/convert.py,sha256=VV4kpu4dvLmGBmJfQnCLY5ryy_AnQcV2NuwjqRtaR8Q,52633
|
|
71
73
|
coderouter/translation/tool_repair.py,sha256=DGOKArM53yfVbIfiAveoul1vGq9NEKmjagf43Qh4IkM,57630
|
|
72
|
-
coderouter_cli-2.
|
|
73
|
-
coderouter_cli-2.
|
|
74
|
-
coderouter_cli-2.
|
|
75
|
-
coderouter_cli-2.
|
|
76
|
-
coderouter_cli-2.
|
|
74
|
+
coderouter_cli-2.10.0.dist-info/METADATA,sha256=quMG0dkYOOZpkWxidZQIxREB-vHl3-U7VktyA00bE4Y,16578
|
|
75
|
+
coderouter_cli-2.10.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
76
|
+
coderouter_cli-2.10.0.dist-info/entry_points.txt,sha256=-dnLfD1YZ2WjH2zSdNCvlO65wYltM9bsHt9Fhg3yGss,51
|
|
77
|
+
coderouter_cli-2.10.0.dist-info/licenses/LICENSE,sha256=wkEzoR86jFw33jvfOHjULqmkGEfxTFMgMaJnpR8mPRw,1065
|
|
78
|
+
coderouter_cli-2.10.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|