coderouter-cli 2.11.1__py3-none-any.whl → 2.11.2__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/vscode_init.py +172 -16
- {coderouter_cli-2.11.1.dist-info → coderouter_cli-2.11.2.dist-info}/METADATA +1 -1
- {coderouter_cli-2.11.1.dist-info → coderouter_cli-2.11.2.dist-info}/RECORD +6 -6
- {coderouter_cli-2.11.1.dist-info → coderouter_cli-2.11.2.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.11.1.dist-info → coderouter_cli-2.11.2.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.11.1.dist-info → coderouter_cli-2.11.2.dist-info}/licenses/LICENSE +0 -0
coderouter/vscode_init.py
CHANGED
|
@@ -16,11 +16,21 @@ The design contract:
|
|
|
16
16
|
without ``--force`` and print a unified diff so the operator decides.
|
|
17
17
|
* ``--dry-run`` is byte-identical to the write path minus the actual
|
|
18
18
|
``os.replace`` at the end.
|
|
19
|
-
* All writes are atomic (
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
19
|
+
* All writes are atomic (``tempfile.mkstemp`` + ``fsync`` +
|
|
20
|
+
``os.replace``) so a partial write cannot corrupt an existing
|
|
21
|
+
``settings.json``. This matters more here than in most CodeRouter
|
|
22
|
+
modules because the file we're editing is workspace-critical: a
|
|
23
|
+
broken JSON blob would silently break every VSCode terminal env
|
|
24
|
+
until a human debugged it.
|
|
25
|
+
* Nothing an operator wrote is destroyed without a copy: before any
|
|
26
|
+
existing file is rewritten, its previous bytes (and mode) land in
|
|
27
|
+
``<name>.bak``. ``--force`` on ``.envrc`` is a whole-file replace,
|
|
28
|
+
not a merge, so this is the only thing standing between the user and
|
|
29
|
+
a lost ``source_env_if_exists .envrc.local`` line.
|
|
30
|
+
* A freshly generated ``.envrc`` is created 0600 — it carries
|
|
31
|
+
``ANTHROPIC_AUTH_TOKEN`` and sits next to the operator's real
|
|
32
|
+
secrets. An existing file's mode is preserved across rewrites
|
|
33
|
+
instead of being reset to the umask default by ``os.replace``.
|
|
24
34
|
|
|
25
35
|
Files touched (opt-in per flag):
|
|
26
36
|
|
|
@@ -40,8 +50,12 @@ directly without argparse in the way.
|
|
|
40
50
|
|
|
41
51
|
from __future__ import annotations
|
|
42
52
|
|
|
53
|
+
import contextlib
|
|
43
54
|
import json
|
|
44
55
|
import os
|
|
56
|
+
import shutil
|
|
57
|
+
import stat
|
|
58
|
+
import tempfile
|
|
45
59
|
from dataclasses import dataclass
|
|
46
60
|
from difflib import unified_diff
|
|
47
61
|
from pathlib import Path
|
|
@@ -62,6 +76,15 @@ DEFAULT_PORT = 8088
|
|
|
62
76
|
# then flag the unused directive.)
|
|
63
77
|
DEFAULT_TOKEN = "dummy"
|
|
64
78
|
|
|
79
|
+
# v2.11 (H-11): a generated .envrc carries ANTHROPIC_AUTH_TOKEN, and
|
|
80
|
+
# users routinely add their real key next to it (or point
|
|
81
|
+
# ``source_env_if_exists .envrc.local`` at one). ``env_security``
|
|
82
|
+
# already WARNs on any ``.env`` whose mode has 0o077 bits set and tells
|
|
83
|
+
# the operator to ``chmod 0600``; a file we generate ourselves should
|
|
84
|
+
# not be born failing that check. Applies to newly created files only —
|
|
85
|
+
# an existing file keeps whatever mode its owner chose.
|
|
86
|
+
_ENVRC_MODE = 0o600
|
|
87
|
+
|
|
65
88
|
# VSCode reads terminal env from a separate key per host OS. Writing all
|
|
66
89
|
# three means the same ``.vscode/`` folder works whether the developer
|
|
67
90
|
# opens it on macOS, Linux, or WSL/Windows without a second scaffolder
|
|
@@ -261,7 +284,9 @@ def _apply_settings_json(
|
|
|
261
284
|
diff=diff,
|
|
262
285
|
reason=(
|
|
263
286
|
"existing settings.json has different value(s) for "
|
|
264
|
-
f"{', '.join(sorted(conflicts))}. Re-run with --force to
|
|
287
|
+
f"{', '.join(sorted(conflicts))}. Re-run with --force to "
|
|
288
|
+
"overwrite those keys (unrelated keys are preserved; the "
|
|
289
|
+
f"previous file is saved to {_backup_path(path).name})."
|
|
265
290
|
),
|
|
266
291
|
)
|
|
267
292
|
|
|
@@ -270,7 +295,24 @@ def _apply_settings_json(
|
|
|
270
295
|
return FileOutcome(path=path, action="unchanged")
|
|
271
296
|
|
|
272
297
|
diff = _text_diff(old_text, new_text, path)
|
|
298
|
+
reason = ""
|
|
273
299
|
if not dry_run:
|
|
300
|
+
# H-11: same deal as .envrc. The merge preserves unrelated keys
|
|
301
|
+
# by design, but a bug in _merge_terminal_env would rewrite a
|
|
302
|
+
# hand-tuned settings.json with no way back — so keep the old
|
|
303
|
+
# bytes. Unlike .envrc this is not gated on --force: an additive
|
|
304
|
+
# merge rewrites the file without --force too, and that is
|
|
305
|
+
# exactly the path a merge bug would take.
|
|
306
|
+
if old_text:
|
|
307
|
+
backup = _make_backup(path)
|
|
308
|
+
reason = (
|
|
309
|
+
f"previous contents backed up to {backup}"
|
|
310
|
+
if backup is not None
|
|
311
|
+
else (
|
|
312
|
+
f"could not write {_backup_path(path).name} — "
|
|
313
|
+
"previous contents were NOT preserved"
|
|
314
|
+
)
|
|
315
|
+
)
|
|
274
316
|
try:
|
|
275
317
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
276
318
|
_atomic_write(path, new_text)
|
|
@@ -281,11 +323,14 @@ def _apply_settings_json(
|
|
|
281
323
|
reason=f"write failed: {exc}",
|
|
282
324
|
diff=diff,
|
|
283
325
|
)
|
|
326
|
+
elif old_text:
|
|
327
|
+
reason = f"would back up previous contents to {_backup_path(path)}"
|
|
284
328
|
|
|
285
329
|
return FileOutcome(
|
|
286
330
|
path=path,
|
|
287
331
|
action="updated" if old_text else "created",
|
|
288
332
|
diff=diff,
|
|
333
|
+
reason=reason,
|
|
289
334
|
)
|
|
290
335
|
|
|
291
336
|
|
|
@@ -374,15 +419,32 @@ def _apply_envrc(
|
|
|
374
419
|
diff=diff,
|
|
375
420
|
reason=(
|
|
376
421
|
".envrc already exists and its contents differ. "
|
|
377
|
-
"Re-run with --force to
|
|
422
|
+
"Re-run with --force to replace the WHOLE file with the "
|
|
423
|
+
"generated contents (this is not a merge — custom lines "
|
|
424
|
+
"such as `source_env_if_exists .envrc.local` are dropped); "
|
|
425
|
+
f"the previous contents are saved to {_backup_path(path).name}. "
|
|
378
426
|
"(Tip: keep secrets in a separate .envrc.local and "
|
|
379
427
|
"``source_env_if_exists`` from .envrc.)"
|
|
380
428
|
),
|
|
381
429
|
)
|
|
382
430
|
|
|
431
|
+
reason = ""
|
|
383
432
|
if not dry_run:
|
|
433
|
+
# H-11: --force replaces the whole file, so stash the old bytes
|
|
434
|
+
# (mode included) before they are gone. Inside the dry_run guard
|
|
435
|
+
# on purpose — --dry-run must not touch the filesystem at all.
|
|
436
|
+
if old_text:
|
|
437
|
+
backup = _make_backup(path)
|
|
438
|
+
reason = (
|
|
439
|
+
f"previous contents backed up to {backup}"
|
|
440
|
+
if backup is not None
|
|
441
|
+
else (
|
|
442
|
+
f"could not write {_backup_path(path).name} — "
|
|
443
|
+
"previous contents were NOT preserved"
|
|
444
|
+
)
|
|
445
|
+
)
|
|
384
446
|
try:
|
|
385
|
-
_atomic_write(path, new_text)
|
|
447
|
+
_atomic_write(path, new_text, mode=_ENVRC_MODE)
|
|
386
448
|
except OSError as exc:
|
|
387
449
|
return FileOutcome(
|
|
388
450
|
path=path,
|
|
@@ -390,11 +452,14 @@ def _apply_envrc(
|
|
|
390
452
|
reason=f"write failed: {exc}",
|
|
391
453
|
diff=diff,
|
|
392
454
|
)
|
|
455
|
+
elif old_text:
|
|
456
|
+
reason = f"would back up previous contents to {_backup_path(path)}"
|
|
393
457
|
|
|
394
458
|
return FileOutcome(
|
|
395
459
|
path=path,
|
|
396
460
|
action="updated" if old_text else "created",
|
|
397
461
|
diff=diff,
|
|
462
|
+
reason=reason,
|
|
398
463
|
)
|
|
399
464
|
|
|
400
465
|
|
|
@@ -538,21 +603,112 @@ def _dump_json(obj: object) -> str:
|
|
|
538
603
|
return json.dumps(obj, indent=2, ensure_ascii=False) + "\n"
|
|
539
604
|
|
|
540
605
|
|
|
541
|
-
def
|
|
542
|
-
"""
|
|
606
|
+
def _default_file_mode() -> int:
|
|
607
|
+
"""Return the mode a plain ``open(..., "w")`` would produce.
|
|
608
|
+
|
|
609
|
+
``tempfile.mkstemp`` deliberately creates its file 0600. That is the
|
|
610
|
+
right default for a secret-bearing ``.envrc``, but it would silently
|
|
611
|
+
tighten ``settings.json`` (previously umask-derived, typically
|
|
612
|
+
0644). Reading the umask back keeps the pre-existing behaviour for
|
|
613
|
+
files that have no explicit mode requirement.
|
|
614
|
+
"""
|
|
615
|
+
current = os.umask(0)
|
|
616
|
+
os.umask(current)
|
|
617
|
+
return 0o666 & ~current
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _atomic_write(path: Path, text: str, *, mode: int | None = None) -> None:
|
|
621
|
+
"""Write ``text`` to ``path`` atomically (tmp + ``os.replace``).
|
|
543
622
|
|
|
544
623
|
A partial write cannot corrupt the target file: the tmp file lives
|
|
545
624
|
on the same filesystem (same parent directory) so ``os.replace`` is
|
|
546
625
|
an atomic inode swap on POSIX. On Windows ``os.replace`` is also
|
|
547
626
|
atomic since Python 3.3.
|
|
548
627
|
|
|
549
|
-
The tmp file
|
|
550
|
-
``
|
|
551
|
-
|
|
628
|
+
v2.11 hardening (H-11). The tmp file used to be the predictable
|
|
629
|
+
``<name>.tmp`` next to the target, written with ``Path.write_text``:
|
|
630
|
+
|
|
631
|
+
* a pre-planted symlink at that exact path was *followed*, so the
|
|
632
|
+
write landed on the link's target (CWE-377);
|
|
633
|
+
* two concurrent ``vscode-init`` runs on the same workspace
|
|
634
|
+
clobbered each other's tmp file;
|
|
635
|
+
* ``os.replace`` swaps inodes, so the target's mode was reset to the
|
|
636
|
+
umask default — a 0600 ``.envrc`` holding ``ANTHROPIC_AUTH_TOKEN``
|
|
637
|
+
silently became 0644;
|
|
638
|
+
* without ``fsync`` a crash right after the rename could leave a
|
|
639
|
+
zero-length file.
|
|
640
|
+
|
|
641
|
+
So: ``tempfile.mkstemp`` (random name, ``O_EXCL``, no symlink
|
|
642
|
+
following), ``fsync`` before the rename, mode restored from the
|
|
643
|
+
file being replaced (or from ``mode`` for a fresh file), and the tmp
|
|
644
|
+
file unlinked in ``finally`` so a failed write leaves no debris.
|
|
645
|
+
|
|
646
|
+
Parameters
|
|
647
|
+
----------
|
|
648
|
+
mode:
|
|
649
|
+
POSIX mode for a *newly created* file. Ignored when ``path``
|
|
650
|
+
already exists — an existing file's own mode wins, so we never
|
|
651
|
+
loosen (or tighten) what the operator chose. ``None`` means
|
|
652
|
+
"whatever the umask would have given us".
|
|
653
|
+
"""
|
|
654
|
+
existing_mode: int | None = None
|
|
655
|
+
try:
|
|
656
|
+
existing_mode = stat.S_IMODE(path.stat().st_mode)
|
|
657
|
+
except OSError:
|
|
658
|
+
existing_mode = None
|
|
659
|
+
|
|
660
|
+
target_mode = existing_mode if existing_mode is not None else mode
|
|
661
|
+
if target_mode is None:
|
|
662
|
+
target_mode = _default_file_mode()
|
|
663
|
+
|
|
664
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
665
|
+
dir=str(path.parent),
|
|
666
|
+
prefix=f".{path.name}.",
|
|
667
|
+
suffix=".tmp",
|
|
668
|
+
)
|
|
669
|
+
tmp = Path(tmp_name)
|
|
670
|
+
try:
|
|
671
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
672
|
+
handle.write(text)
|
|
673
|
+
handle.flush()
|
|
674
|
+
os.fsync(handle.fileno())
|
|
675
|
+
# Windows has no POSIX mode bits worth honouring; os.chmod there
|
|
676
|
+
# only toggles the read-only flag, which would be a surprising
|
|
677
|
+
# side effect of "preserve the mode".
|
|
678
|
+
if os.name != "nt":
|
|
679
|
+
os.chmod(tmp, target_mode)
|
|
680
|
+
os.replace(tmp, path)
|
|
681
|
+
finally:
|
|
682
|
+
# Best effort: a leftover tmp file is noise, not corruption, and
|
|
683
|
+
# cleaning it up must never mask the original exception.
|
|
684
|
+
with contextlib.suppress(OSError):
|
|
685
|
+
tmp.unlink(missing_ok=True)
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _backup_path(path: Path) -> Path:
|
|
689
|
+
"""Return the ``.bak`` sibling used to preserve ``path``'s old bytes."""
|
|
690
|
+
return path.with_name(path.name + ".bak")
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _make_backup(path: Path) -> Path | None:
|
|
694
|
+
"""Copy ``path`` to ``<name>.bak``, preserving mode and mtime.
|
|
695
|
+
|
|
696
|
+
Returns the backup path, or ``None`` when the copy failed (the
|
|
697
|
+
caller then proceeds without a backup rather than refusing to write
|
|
698
|
+
— but the failure is not silent, see the ``reason`` plumbing).
|
|
699
|
+
|
|
700
|
+
The destination is unlinked first: ``shutil.copy2`` opens the
|
|
701
|
+
destination for writing and would happily follow a symlink planted
|
|
702
|
+
at ``<name>.bak``. ``Path.unlink`` removes the link itself, so the
|
|
703
|
+
subsequent copy always creates a fresh regular file.
|
|
552
704
|
"""
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
705
|
+
backup = _backup_path(path)
|
|
706
|
+
try:
|
|
707
|
+
backup.unlink(missing_ok=True)
|
|
708
|
+
shutil.copy2(path, backup)
|
|
709
|
+
except OSError:
|
|
710
|
+
return None
|
|
711
|
+
return backup
|
|
556
712
|
|
|
557
713
|
|
|
558
714
|
def _text_diff(old: str, new: str, path: Path) -> str:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: coderouter-cli
|
|
3
|
-
Version: 2.11.
|
|
3
|
+
Version: 2.11.2
|
|
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
|
|
@@ -17,7 +17,7 @@ coderouter/logging.py,sha256=5rfi4_R4aoeeUYDc642dgs61wA0aBX7wvubyB-k3Uyo,58074
|
|
|
17
17
|
coderouter/output_filters.py,sha256=0ry_rPiS_kC-FnHgaNVP6v7e6Al2djxzu9vBzZ8kEkE,25314
|
|
18
18
|
coderouter/token_estimation.py,sha256=iz22vZEEW2P7uKLB2pYvPNpIbZGbgXRO5MtfkS_-9Sk,7531
|
|
19
19
|
coderouter/token_estimation_accurate.py,sha256=GTfzrBVnvAGjeVzmzAeUdOYZvWZKLAxcxPpFiJGlzjk,4609
|
|
20
|
-
coderouter/vscode_init.py,sha256=
|
|
20
|
+
coderouter/vscode_init.py,sha256=fW0chCDTngvWQuJgQ7aqYmE6hnZO3hCk8mAiEkZ9FCo,26741
|
|
21
21
|
coderouter/adapters/__init__.py,sha256=7dIDSZ-FE_0iSqLSDc_lK1idRdLTKcM2hP9tCJipgPI,463
|
|
22
22
|
coderouter/adapters/anthropic_native.py,sha256=-FGTQFlFL5Z2IZms4NUGPcNkIN-fVvQrCY8e8nxW17s,24777
|
|
23
23
|
coderouter/adapters/base.py,sha256=ykKMaiIVVGr23oFFXilK2iP_YhI8-CtsEiJyFXaC9bE,10396
|
|
@@ -71,8 +71,8 @@ coderouter/translation/__init__.py,sha256=PYXN7XVEwpG1uC8RLy6fvnGbzEZhhrEuUapH8I
|
|
|
71
71
|
coderouter/translation/anthropic.py,sha256=ZQQGS9itN_3o7IKD68lKr5gR_Kem32Jsd7OcGjJzv8Q,13468
|
|
72
72
|
coderouter/translation/convert.py,sha256=VrstDAMsUpcQ1VgMTn_-ETp1OjGX8v0ZGhCkcd4icyU,53037
|
|
73
73
|
coderouter/translation/tool_repair.py,sha256=1p8rmdyyscGX2g0DDOYitMnLv-nKfR1SLuLaRB4z7GM,64244
|
|
74
|
-
coderouter_cli-2.11.
|
|
75
|
-
coderouter_cli-2.11.
|
|
76
|
-
coderouter_cli-2.11.
|
|
77
|
-
coderouter_cli-2.11.
|
|
78
|
-
coderouter_cli-2.11.
|
|
74
|
+
coderouter_cli-2.11.2.dist-info/METADATA,sha256=zw-XK_yuV-v6dBPqAohL8iYdQJW_rmbI0UhmVvyfT0s,16897
|
|
75
|
+
coderouter_cli-2.11.2.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
76
|
+
coderouter_cli-2.11.2.dist-info/entry_points.txt,sha256=-dnLfD1YZ2WjH2zSdNCvlO65wYltM9bsHt9Fhg3yGss,51
|
|
77
|
+
coderouter_cli-2.11.2.dist-info/licenses/LICENSE,sha256=wkEzoR86jFw33jvfOHjULqmkGEfxTFMgMaJnpR8mPRw,1065
|
|
78
|
+
coderouter_cli-2.11.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|