coderouter-cli 2.9.4__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 CHANGED
@@ -336,6 +336,78 @@ def _build_parser() -> argparse.ArgumentParser:
336
336
  ),
337
337
  )
338
338
 
339
+ # v2.10-A: `coderouter vscode-init` — scaffold a VSCode workspace so
340
+ # Claude Code launched from the integrated terminal auto-points at
341
+ # CodeRouter (no manual env-var juggling). Optionally writes .envrc
342
+ # for direnv users. Cline / Roo / Continue are covered by the cheat
343
+ # sheet printed on completion and docs/guides/vscode.md — this
344
+ # command deliberately does NOT touch those extensions' settings
345
+ # (their schemas change with their own release cadence).
346
+ vscode_init = sub.add_parser(
347
+ "vscode-init",
348
+ help="Scaffold VSCode workspace settings for CodeRouter (v2.10-A).",
349
+ description=(
350
+ "Write .vscode/settings.json (terminal.integrated.env.*) so a "
351
+ "Claude Code session launched from VSCode's integrated terminal "
352
+ "auto-points at CodeRouter. Optionally emit a direnv .envrc. "
353
+ "Idempotent: safe to re-run. Conflict-aware: refuses to overwrite "
354
+ "differing existing values without --force."
355
+ ),
356
+ )
357
+ vscode_init.add_argument(
358
+ "--target",
359
+ default=".",
360
+ metavar="PATH",
361
+ help="Workspace root (default: current directory).",
362
+ )
363
+ vscode_init.add_argument(
364
+ "--port",
365
+ type=int,
366
+ default=None,
367
+ metavar="PORT",
368
+ help=(
369
+ "CodeRouter port for ANTHROPIC_BASE_URL. Defaults to 8088 "
370
+ "(matches every docs / quickstart example). Note that "
371
+ "`coderouter serve` alone defaults to 4000; if you serve on "
372
+ "4000, pass --port 4000 here too."
373
+ ),
374
+ )
375
+ vscode_init.add_argument(
376
+ "--profile",
377
+ default=None,
378
+ metavar="NAME",
379
+ help=(
380
+ "Preload CODEROUTER_MODE=<profile> so the VSCode terminal "
381
+ "routes to a non-default profile without a per-request header."
382
+ ),
383
+ )
384
+ vscode_init.add_argument(
385
+ "--with-envrc",
386
+ action="store_true",
387
+ help=(
388
+ "Also write .envrc (direnv). Run `direnv allow` once after "
389
+ "generation. Tip: keep secrets in a separate .envrc.local."
390
+ ),
391
+ )
392
+ vscode_init.add_argument(
393
+ "--dry-run",
394
+ action="store_true",
395
+ help=(
396
+ "Compute all changes and print unified diffs, but do not write "
397
+ "any files. Byte-identical to the write path minus the final "
398
+ "os.replace."
399
+ ),
400
+ )
401
+ vscode_init.add_argument(
402
+ "--force",
403
+ action="store_true",
404
+ help=(
405
+ "Overwrite existing conflicting values. Without this flag, a "
406
+ "conflict is reported with a diff and the file is left "
407
+ "untouched (exit 2)."
408
+ ),
409
+ )
410
+
339
411
  return parser
340
412
 
341
413
 
@@ -432,6 +504,9 @@ def main(argv: list[str] | None = None) -> int:
432
504
  if args.command == "replay":
433
505
  return _run_replay(args)
434
506
 
507
+ if args.command == "vscode-init":
508
+ return _run_vscode_init(args)
509
+
435
510
  print(f"unknown command: {args.command}", file=sys.stderr)
436
511
  return 2
437
512
 
@@ -765,6 +840,56 @@ def _run_replay(args: argparse.Namespace) -> int:
765
840
  return 0
766
841
 
767
842
 
843
+ def _run_vscode_init(args: argparse.Namespace) -> int:
844
+ """v2.10-A: drive :func:`coderouter.vscode_init.run_vscode_init`.
845
+
846
+ Kept small and testable — the actual scaffolding logic lives in
847
+ :mod:`coderouter.vscode_init` so tests can exercise it without
848
+ argparse in the way. This wrapper's job is:
849
+
850
+ * resolve the ``--target`` argument to a real directory
851
+ * map ``--port`` (default None) to the module's ``DEFAULT_PORT``
852
+ * translate exceptions (missing target directory) to a friendly
853
+ stderr message + exit 1
854
+ * print the formatted result and propagate the module's exit code
855
+ """
856
+ from coderouter.vscode_init import (
857
+ DEFAULT_PORT,
858
+ exit_code_for,
859
+ format_result,
860
+ run_vscode_init,
861
+ )
862
+
863
+ target = Path(args.target).expanduser()
864
+ if not target.is_dir():
865
+ print(
866
+ f"vscode-init: target directory does not exist: {target}",
867
+ file=sys.stderr,
868
+ )
869
+ return 1
870
+
871
+ port = args.port if args.port is not None else DEFAULT_PORT
872
+
873
+ try:
874
+ result = run_vscode_init(
875
+ target,
876
+ port=port,
877
+ profile=args.profile,
878
+ with_envrc=args.with_envrc,
879
+ dry_run=args.dry_run,
880
+ force=args.force,
881
+ )
882
+ except FileNotFoundError as exc:
883
+ # Raised only when target vanishes between the pre-check above
884
+ # and the module's own check — unlikely in practice, but map
885
+ # it to the same shape as the pre-check for consistency.
886
+ print(f"vscode-init: {exc}", file=sys.stderr)
887
+ return 1
888
+
889
+ print(format_result(result, dry_run=args.dry_run, port=port))
890
+ return exit_code_for(result)
891
+
892
+
768
893
  def _run_check_env(arg_value: str) -> int:
769
894
  """v1.6.3: filesystem / git security checks for `.env`.
770
895
 
@@ -394,7 +394,7 @@ class ProviderConfig(BaseModel):
394
394
  default=False,
395
395
  description="If true, only used when ALLOW_PAID=true (plan.md §2.3).",
396
396
  )
397
- timeout_s: float = Field(default=30.0, ge=1.0, le=600.0)
397
+ timeout_s: float = Field(default=30.0, ge=1.0, le=86400.0)
398
398
 
399
399
  # v2.6 language-tax track: path to a LOCAL ``tokenizer.json`` for this
400
400
  # provider's model, used to measure the CJK over-count vs the char/4
@@ -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.9.4
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
@@ -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=7zxt6riKPTpHGiDms9SVaPbe-n4ytfCrhIVtuPII-oU,30173
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
@@ -17,6 +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=fAcxJmwGXX8u0ylW0GHGACe_Co9VL6wdAKNFw7-ulwg,19952
20
21
  coderouter/adapters/__init__.py,sha256=7dIDSZ-FE_0iSqLSDc_lK1idRdLTKcM2hP9tCJipgPI,463
21
22
  coderouter/adapters/anthropic_native.py,sha256=CT9Hitun-c3z83YHT2zZXgwZpY3_t6eRnOfAmNud2aw,23610
22
23
  coderouter/adapters/base.py,sha256=ykKMaiIVVGr23oFFXilK2iP_YhI8-CtsEiJyFXaC9bE,10396
@@ -26,7 +27,7 @@ coderouter/config/__init__.py,sha256=FODEn74fN-qZnt4INPSHswqhOlEgpL6-_onxsitSx8g
26
27
  coderouter/config/capability_registry.py,sha256=RZgzHF54dXy0CCDocRCz2ee21qXGbbgTSKTkRI9iLL4,16936
27
28
  coderouter/config/env_file.py,sha256=CoMK27fuAXm-NtoLzXb8yN2E-wDFjHQuFwiIlmgTBQw,10356
28
29
  coderouter/config/loader.py,sha256=FUEe8m4Tnmj_aul0vSctD8vKvNW-oLRoMRbTpSKqSmc,4077
29
- coderouter/config/schemas.py,sha256=wHaOqbehppjtL-01eLh8DNmm0A3rZXdJ-cNbBcS2yM8,120984
30
+ coderouter/config/schemas.py,sha256=X2LeonDTTIkesbqzmmo3QYcD1dwmjGiCalMyS5X-A1o,120986
30
31
  coderouter/data/__init__.py,sha256=uNyfD9jaCvTWsBAWtaw1Fr25OSxzv3psGMfBjT1z0Cc,328
31
32
  coderouter/data/model-capabilities.yaml,sha256=S9jt6SC6-3s2-icZ_n-a14iEMnc2yB1C2R6q-N_tZWQ,19309
32
33
  coderouter/guards/__init__.py,sha256=5qliYBqygvVPneej7nx0uSjxDKsz7t8VzvrDgVBJlvU,1170
@@ -70,8 +71,8 @@ coderouter/translation/__init__.py,sha256=PYXN7XVEwpG1uC8RLy6fvnGbzEZhhrEuUapH8I
70
71
  coderouter/translation/anthropic.py,sha256=uEScq-icLn6ZXFWaxsKROv7feaq3zD0ZogjbTJ3Y7gQ,12578
71
72
  coderouter/translation/convert.py,sha256=VV4kpu4dvLmGBmJfQnCLY5ryy_AnQcV2NuwjqRtaR8Q,52633
72
73
  coderouter/translation/tool_repair.py,sha256=DGOKArM53yfVbIfiAveoul1vGq9NEKmjagf43Qh4IkM,57630
73
- coderouter_cli-2.9.4.dist-info/METADATA,sha256=E7EiSxxEYfkchQLvqlvdQzADFsnwbReGwi0Patjfk8I,15996
74
- coderouter_cli-2.9.4.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
75
- coderouter_cli-2.9.4.dist-info/entry_points.txt,sha256=-dnLfD1YZ2WjH2zSdNCvlO65wYltM9bsHt9Fhg3yGss,51
76
- coderouter_cli-2.9.4.dist-info/licenses/LICENSE,sha256=wkEzoR86jFw33jvfOHjULqmkGEfxTFMgMaJnpR8mPRw,1065
77
- coderouter_cli-2.9.4.dist-info/RECORD,,
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,,