coderouter-cli 2.11.0__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.
@@ -90,12 +90,28 @@ from __future__ import annotations
90
90
 
91
91
  import contextlib
92
92
  import json
93
+ import logging
93
94
  import re
94
95
  import uuid
96
+ from array import array
95
97
  from typing import Any
96
98
 
97
99
  __all__ = ["deduplicate_tool_calls", "repair_tool_calls_in_text"]
98
100
 
101
+ # Plain stdlib logger (same idiom as coderouter/translation/anthropic.py).
102
+ # This module is deliberately import-free of the rest of the package:
103
+ # benchmarks/tool-repair/run_offline.py loads it straight from its source
104
+ # path, with no `coderouter` package on sys.path.
105
+ logger = logging.getLogger(__name__)
106
+
107
+ # Hard ceiling on the text handed to the bare-JSON brace scanners. The
108
+ # scanners themselves are linear, but every candidate they emit still costs a
109
+ # JSON parse attempt plus a prose-cue check, and an assistant message this
110
+ # large is never a tool call anyway. Above the limit the bare-JSON step is
111
+ # skipped outright; the fenced / XML / R4a / R4c paths are regex-driven and
112
+ # linear, so they keep running.
113
+ _MAX_BARE_SCAN_CHARS = 262_144
114
+
99
115
 
100
116
  # ------------------------------------------------------------------
101
117
  # R2: shape detection + normalisation (with key aliases)
@@ -528,6 +544,139 @@ def _in_spans(pos: int, spans: list[tuple[int, int]]) -> bool:
528
544
  return any(start <= pos < end for start, end in spans)
529
545
 
530
546
 
547
+ # ------------------------------------------------------------------
548
+ # Brace scanning (bare-JSON candidate discovery)
549
+ #
550
+ # Both scanners below answer the same question for every ``{`` in the text:
551
+ # "does a *fresh* scan started right here reach a balanced close?". The
552
+ # obvious implementation re-scans to end-of-text once per ``{``, which is
553
+ # O(k*n) for k unclosed braces — a 48 KB assistant message full of stray
554
+ # braces took ~20 s, and because ``to_anthropic_response`` is called from the
555
+ # request path that stalled the whole server.
556
+ #
557
+ # Instead we precompute, in ONE right-to-left sweep, a table that answers
558
+ # "starting at offset j outside any string, where is the first ``}`` that is
559
+ # still at brace depth 0?". The closing brace of a ``{`` at offset q is then
560
+ # simply ``table[q + 1]``, so the driver below is a plain left-to-right walk
561
+ # and the whole scan is linear in the length of the text.
562
+ #
563
+ # The table is defined by a right-to-left recurrence over the scanner's state
564
+ # machine (outside a string / inside a string / previous char was a
565
+ # backslash). Only the "outside a string" row needs random access — the ``{``
566
+ # case has to jump over the nested object it just resolved — so the
567
+ # in-string rows are carried as rolling scalars instead of arrays.
568
+ # ------------------------------------------------------------------
569
+
570
+
571
+ def _scan_object_spans(text: str, close_table: array[int]) -> list[tuple[int, int, str]]:
572
+ """Walk ``text`` left to right emitting balanced ``{...}`` spans.
573
+
574
+ ``close_table`` comes from :func:`_strict_close_table` or
575
+ :func:`_lenient_close_table` and supplies, for each offset, the index of
576
+ the first depth-0 ``}`` at or after it (``-1`` when there is none). The
577
+ driver is shared; the flavour lives entirely in the table.
578
+
579
+ Returns (start, end_exclusive, substring) triples. See the two callers'
580
+ docstrings for the (deliberate, load-bearing) return-value contract.
581
+ """
582
+ out: list[tuple[int, int, str]] = []
583
+ n = len(text)
584
+ i = 0
585
+ while i < n:
586
+ start = text.find("{", i)
587
+ if start < 0:
588
+ break
589
+ close = close_table[start + 1]
590
+ if close < 0:
591
+ # Never closes — drop this `{` and resume at the next one. The
592
+ # restart deliberately begins from a clean "outside a string"
593
+ # state (see the contract note in the callers).
594
+ i = start + 1
595
+ continue
596
+ out.append((start, close + 1, text[start : close + 1]))
597
+ i = close + 1
598
+ return out
599
+
600
+
601
+ def _strict_close_table(text: str) -> array[int]:
602
+ """Depth-0 close-brace lookup table with *strict* JSON string rules.
603
+
604
+ ``table[j]`` is the offset of the first ``}`` that a scanner starting at
605
+ ``j`` — outside any string literal, at brace depth 0 — would reach while
606
+ still at depth 0, or ``-1`` if it runs off the end. Only ``"`` opens a
607
+ string; ``\\`` escapes the next character inside one.
608
+
609
+ Built in a single right-to-left sweep, so building plus scanning is
610
+ O(len(text)) regardless of how many unbalanced braces the text contains.
611
+ """
612
+ n = len(text)
613
+ outside = array("i", [-1]) * (n + 1)
614
+ in_str = -1 # answer when the scan *enters* offset j+1 inside a "..."
615
+ escaped = -1 # ... inside a "..." with the previous char a backslash
616
+ for j in range(n - 1, -1, -1):
617
+ c = text[j]
618
+ nxt_outside = outside[j + 1]
619
+ nxt_in_str = in_str
620
+ nxt_escaped = escaped
621
+ # Escaped char: consumed verbatim, then we are back inside the string.
622
+ escaped = nxt_in_str
623
+ # Inside a string literal.
624
+ if c == "\\":
625
+ in_str = nxt_escaped
626
+ elif c == '"':
627
+ in_str = nxt_outside
628
+ else:
629
+ in_str = nxt_in_str
630
+ # Outside any string literal.
631
+ if c == '"':
632
+ outside[j] = nxt_in_str
633
+ elif c == "{":
634
+ inner = nxt_outside # close of the nested object opening at j
635
+ outside[j] = -1 if inner < 0 else outside[inner + 1]
636
+ elif c == "}":
637
+ outside[j] = j
638
+ else:
639
+ outside[j] = nxt_outside
640
+ return outside
641
+
642
+
643
+ def _lenient_close_table(text: str) -> array[int]:
644
+ """Depth-0 close-brace lookup table tolerating single-quoted strings.
645
+
646
+ Same contract as :func:`_strict_close_table`, but BOTH ``'`` and ``"``
647
+ open a string literal (and only the matching quote closes it), which is
648
+ what the lenient pass needs to survive Python-repr / single-quoted
649
+ malformed objects. Five scanner states instead of three; again only the
650
+ "outside a string" row needs to be materialised as an array.
651
+ """
652
+ n = len(text)
653
+ outside = array("i", [-1]) * (n + 1)
654
+ in_sq = in_dq = esc_sq = esc_dq = -1
655
+ for j in range(n - 1, -1, -1):
656
+ c = text[j]
657
+ nxt_outside = outside[j + 1]
658
+ nxt_in_sq, nxt_in_dq = in_sq, in_dq
659
+ nxt_esc_sq, nxt_esc_dq = esc_sq, esc_dq
660
+ esc_sq, esc_dq = nxt_in_sq, nxt_in_dq
661
+ if c == "\\":
662
+ in_sq, in_dq = nxt_esc_sq, nxt_esc_dq
663
+ else:
664
+ in_sq = nxt_outside if c == "'" else nxt_in_sq
665
+ in_dq = nxt_outside if c == '"' else nxt_in_dq
666
+ if c == "'":
667
+ outside[j] = nxt_in_sq
668
+ elif c == '"':
669
+ outside[j] = nxt_in_dq
670
+ elif c == "{":
671
+ inner = nxt_outside
672
+ outside[j] = -1 if inner < 0 else outside[inner + 1]
673
+ elif c == "}":
674
+ outside[j] = j
675
+ else:
676
+ outside[j] = nxt_outside
677
+ return outside
678
+
679
+
531
680
  def _find_balanced_json_objects(text: str) -> list[tuple[int, int, str]]:
532
681
  """Find top-level `{...}` JSON substrings by a brace-counter scan.
533
682
 
@@ -539,45 +688,27 @@ def _find_balanced_json_objects(text: str) -> list[tuple[int, int, str]]:
539
688
  brace balancing. Single-quoted / unquoted malformed objects are found by
540
689
  :func:`_find_candidate_object_spans` instead, which brace-balances without
541
690
  assuming JSON string syntax.
691
+
692
+ Return-value contract (load-bearing — downstream recall depends on all
693
+ three; do NOT "simplify" this into a single global stack pass):
694
+
695
+ 1. **Top-level objects only.** Once a ``{`` closes, the scan resumes after
696
+ its ``}``, so objects nested inside a *closed* object are not returned.
697
+ ``'{"a": {"b": 1}}'`` yields the outer object alone.
698
+ 2. **Nested objects surface when the outer never closes.** An unbalanced
699
+ ``{`` is dropped and the scan restarts at the next ``{``, so
700
+ ``'{"a": {"b":1}'`` yields ``[(6, 13, '{"b":1}')]`` — the inner
701
+ object is still repairable even though the outer one is broken.
702
+ 3. **Every restart begins with a fresh string state.** The scan between
703
+ candidates does not track quotes, so a stray/unbalanced quote before a
704
+ ``{`` cannot swallow it. This is what keeps prose apostrophes
705
+ (``"I'll call the tool now."``) and quoted braces (``'{ "z{}" '`` ->
706
+ ``[(4, 6, '{}')]``) from hiding a real call. A single global pass that
707
+ carried string state across candidates loses both.
542
708
  """
543
- out: list[tuple[int, int, str]] = []
544
- n = len(text)
545
- i = 0
546
- while i < n:
547
- if text[i] != "{":
548
- i += 1
549
- continue
550
- # Scan forward to find a balanced close.
551
- depth = 0
552
- j = i
553
- in_str = False
554
- escape = False
555
- while j < n:
556
- c = text[j]
557
- if escape:
558
- escape = False
559
- elif in_str:
560
- if c == "\\":
561
- escape = True
562
- elif c == '"':
563
- in_str = False
564
- else:
565
- if c == '"':
566
- in_str = True
567
- elif c == "{":
568
- depth += 1
569
- elif c == "}":
570
- depth -= 1
571
- if depth == 0:
572
- out.append((i, j + 1, text[i : j + 1]))
573
- i = j + 1
574
- break
575
- j += 1
576
- else:
577
- # Ran off the end without closing — skip this `{` and move on.
578
- i += 1
579
- continue
580
- return out
709
+ if "{" not in text:
710
+ return []
711
+ return _scan_object_spans(text, _strict_close_table(text))
581
712
 
582
713
 
583
714
  def _find_candidate_object_spans(text: str) -> list[tuple[int, int, str]]:
@@ -587,43 +718,17 @@ def _find_candidate_object_spans(text: str) -> list[tuple[int, int, str]]:
587
718
  respecting BOTH ``'`` and ``"`` string delimiters, so it can locate
588
719
  Python-repr / single-quoted malformed objects for the lenient parser.
589
720
  Returns (start, end_exclusive, substring).
721
+
722
+ The same three-point return-value contract documented on
723
+ :func:`_find_balanced_json_objects` applies here: top-level objects only,
724
+ nested objects surface when the outer never closes, and every restart
725
+ begins from a fresh string state. Point 3 matters most on this flavour —
726
+ ``'`` opens a string here, so carrying state across candidates would let
727
+ an ordinary prose apostrophe swallow every following object.
590
728
  """
591
- out: list[tuple[int, int, str]] = []
592
- n = len(text)
593
- i = 0
594
- while i < n:
595
- if text[i] != "{":
596
- i += 1
597
- continue
598
- depth = 0
599
- j = i
600
- quote: str | None = None
601
- escape = False
602
- while j < n:
603
- c = text[j]
604
- if escape:
605
- escape = False
606
- elif quote is not None:
607
- if c == "\\":
608
- escape = True
609
- elif c == quote:
610
- quote = None
611
- else:
612
- if c == '"' or c == "'":
613
- quote = c
614
- elif c == "{":
615
- depth += 1
616
- elif c == "}":
617
- depth -= 1
618
- if depth == 0:
619
- out.append((i, j + 1, text[i : j + 1]))
620
- i = j + 1
621
- break
622
- j += 1
623
- else:
624
- i += 1
625
- continue
626
- return out
729
+ if "{" not in text:
730
+ return []
731
+ return _scan_object_spans(text, _lenient_close_table(text))
627
732
 
628
733
 
629
734
  # ------------------------------------------------------------------
@@ -1405,12 +1510,24 @@ def repair_tool_calls_in_text(
1405
1510
  # Skip anything inside a protected code fence or introduced by a prose
1406
1511
  # example cue. Walk back-to-front so slicing removals don't shift the
1407
1512
  # indices of earlier matches.
1513
+ # Oversized inputs skip this step entirely (see _MAX_BARE_SCAN_CHARS):
1514
+ # the per-candidate JSON parsing / cue checking is the expensive part
1515
+ # and a message that large is not a tool call. Steps 1/2/2b/2c above
1516
+ # are regex-driven and linear, so they are NOT skipped.
1408
1517
  protected = _protected_code_spans(cleaned)
1409
1518
  spans_to_remove: list[tuple[int, int]] = []
1410
1519
  repaired_from_bare: list[dict[str, Any]] = []
1411
1520
 
1521
+ strict_spans: list[tuple[int, int, str]] = []
1522
+ lenient_spans: list[tuple[int, int, str]] = []
1523
+ if len(cleaned) > _MAX_BARE_SCAN_CHARS:
1524
+ logger.warning("tool-repair-input-too-large", extra={"chars": len(cleaned)})
1525
+ else:
1526
+ strict_spans = _find_balanced_json_objects(cleaned)
1527
+ lenient_spans = _find_candidate_object_spans(cleaned)
1528
+
1412
1529
  # First pass: strict-JSON balanced objects.
1413
- for start, end, substr in _find_balanced_json_objects(cleaned):
1530
+ for start, end, substr in strict_spans:
1414
1531
  if _in_spans(start, protected):
1415
1532
  continue
1416
1533
  if _preceded_by_prose_cue(cleaned, start):
@@ -1436,7 +1553,7 @@ def repair_tool_calls_in_text(
1436
1553
 
1437
1554
  # Second pass: lenient objects (single-quoted / unquoted / trailing comma /
1438
1555
  # double-brace) that strict parsing missed. Skip spans already claimed.
1439
- for start, end, substr in _find_candidate_object_spans(cleaned):
1556
+ for start, end, substr in lenient_spans:
1440
1557
  if _in_spans(start, spans_to_remove):
1441
1558
  continue
1442
1559
  if _in_spans(start, protected):
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 (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.
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 overwrite."
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 overwrite. "
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 _atomic_write(path: Path, text: str) -> None:
542
- """Write ``text`` to ``path`` atomically (tmp + os.replace).
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 uses the parent + ``<name>.tmp`` pattern rather than
550
- ``Path.with_suffix`` because ``.envrc`` has no stem/suffix in the
551
- ``Path`` sense and ``with_suffix`` would silently misbehave.
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
- tmp = path.parent / (path.name + ".tmp")
554
- tmp.write_text(text, encoding="utf-8")
555
- os.replace(tmp, path)
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.0
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