agent-toggle 0.15.0__tar.gz → 0.16.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/PKG-INFO +36 -14
  2. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/README.md +34 -12
  3. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/__init__.py +1 -1
  4. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/cli.py +9 -10
  5. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/help.py +4 -3
  6. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/locales/zh_tw_cli.py +14 -1
  7. agent_toggle-0.16.0/agent_toggle/update_check.py +361 -0
  8. agent_toggle-0.16.0/agent_toggle/update_prompt.py +260 -0
  9. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle.egg-info/PKG-INFO +36 -14
  10. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle.egg-info/SOURCES.txt +2 -0
  11. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle.egg-info/requires.txt +1 -1
  12. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/pyproject.toml +1 -1
  13. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_cli.py +112 -33
  14. agent_toggle-0.16.0/tests/test_update_check.py +423 -0
  15. agent_toggle-0.16.0/tests/test_update_prompt.py +162 -0
  16. agent_toggle-0.15.0/agent_toggle/update_prompt.py +0 -80
  17. agent_toggle-0.15.0/tests/test_update_prompt.py +0 -104
  18. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/LICENSE +0 -0
  19. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/__main__.py +0 -0
  20. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/backends/__init__.py +0 -0
  21. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/backends/flag_json.py +0 -0
  22. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/backends/mcp_json.py +0 -0
  23. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/backends/mcp_toml.py +0 -0
  24. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/backends/plugin_cli.py +0 -0
  25. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/companions.py +0 -0
  26. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/completions.py +0 -0
  27. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/config.py +0 -0
  28. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/cost.py +0 -0
  29. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/doctor.py +0 -0
  30. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/fs.py +0 -0
  31. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/harnesses.py +0 -0
  32. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/i18n.py +0 -0
  33. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/locales/__init__.py +0 -0
  34. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/locales/zh_tw_common.py +0 -0
  35. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/locales/zh_tw_config.py +0 -0
  36. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/locales/zh_tw_picker.py +0 -0
  37. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/mechanisms.py +0 -0
  38. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/ops.py +0 -0
  39. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/output.py +0 -0
  40. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/profiles.py +0 -0
  41. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/settings.py +0 -0
  42. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/shims/claude.md.tmpl +0 -0
  43. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/shims/generic.md.tmpl +0 -0
  44. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/spinner.py +0 -0
  45. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/store.py +0 -0
  46. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/toml_check.py +0 -0
  47. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/ui/__init__.py +0 -0
  48. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/ui/config_menu.py +0 -0
  49. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/ui/logo.py +0 -0
  50. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/ui/menu.py +0 -0
  51. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/ui/model.py +0 -0
  52. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/ui/picker.py +0 -0
  53. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/ui/theme.py +0 -0
  54. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/undo.py +0 -0
  55. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle.egg-info/dependency_links.txt +0 -0
  56. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle.egg-info/entry_points.txt +0 -0
  57. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle.egg-info/top_level.txt +0 -0
  58. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/setup.cfg +0 -0
  59. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_alias.py +0 -0
  60. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_all_type.py +0 -0
  61. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_backends.py +0 -0
  62. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_cli_surface.py +0 -0
  63. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_color.py +0 -0
  64. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_companions.py +0 -0
  65. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_completions.py +0 -0
  66. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_config.py +0 -0
  67. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_config_menu.py +0 -0
  68. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_conformance.py +0 -0
  69. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_containment.py +0 -0
  70. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_cost.py +0 -0
  71. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_crash.py +0 -0
  72. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_doctor.py +0 -0
  73. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_encoding.py +0 -0
  74. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_fs.py +0 -0
  75. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_harnesses.py +0 -0
  76. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_help.py +0 -0
  77. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_i18n.py +0 -0
  78. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_install.py +0 -0
  79. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_logo.py +0 -0
  80. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_mechanisms.py +0 -0
  81. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_menu.py +0 -0
  82. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_mods.py +0 -0
  83. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_picker.py +0 -0
  84. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_platform.py +0 -0
  85. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_profiles.py +0 -0
  86. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_project.py +0 -0
  87. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_safety.py +0 -0
  88. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_same_name.py +0 -0
  89. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_secret_audit.py +0 -0
  90. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_settings.py +0 -0
  91. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_store.py +0 -0
  92. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_theme.py +0 -0
  93. {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_undo.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-toggle
3
- Version: 0.15.0
3
+ Version: 0.16.0
4
4
  Summary: Temporarily disable / re-enable AI-agent skills, agents, commands, rules, plugins and MCP servers across harnesses (Claude Code, Codex, Grok, OpenCode, OpenClaw, Copilot, Vibe, Devin, Antigravity)
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/weskao/agent-toggle
@@ -25,7 +25,7 @@ Requires-Python: >=3.10
25
25
  Description-Content-Type: text/markdown
26
26
  License-File: LICENSE
27
27
  Requires-Dist: telegram-kit<0.3,>=0.2.2
28
- Requires-Dist: update-kit<0.2,>=0.1
28
+ Requires-Dist: update-kit<0.3,>=0.2.1
29
29
  Provides-Extra: windows
30
30
  Requires-Dist: windows-curses; sys_platform == "win32" and extra == "windows"
31
31
  Provides-Extra: telegram
@@ -41,6 +41,10 @@ rules, plugins (mods included), MCP servers — across Claude Code, Codex, Grok
41
41
  OpenClaw, Copilot, Vibe, Devin and Antigravity — and show what each one costs at
42
42
  session start.
43
43
 
44
+ It ships in two forms: a **CLI** (`agent-toggle`, all harnesses) and a **Claude Code mod**
45
+ (the `/agent-toggle` pane, claude harness only; see [Claude Code pane mod](#claude-code-pane-mod)).
46
+ Both drive the same toggle engine and state.
47
+
44
48
  **Nothing is ever deleted.** Everything is parked and recorded, and `enable`
45
49
  puts it back where it came from.
46
50
 
@@ -203,7 +207,7 @@ agent-toggle <command> [args] # or: python3 agent_toggle.py <command> [
203
207
  | `status` | health check: harnesses found, types each supports, parked counts, gitignore, untracked parked items, stale live twins, shared dirs, and state entries whose parked item is gone (a `stale` row with the fix command; read-only, still exit `0`) |
204
208
  | `list [type]` | what is currently disabled (`--project <dir>` filters to one project) |
205
209
  | `cost [--type T]` | estimated startup tokens per item, biggest first (read-only; `--harness H` filters; `--project <dir>` prices a repo's `.claude/` and `.mcp.json` instead of user scope) |
206
- | `update` (also `--update`) | install the newest release tag (`uv tool install --force --no-sources git+<repo>@<tag>`, or `pipx install --force` for a pipx install), then, with the new version, update the Claude Code plugin (when installed) and the shims. Never asks. Exit `0` updated or already current, `1` the check or install failed (offline, rate limit), `2` a dev install (use `git pull`) or an install it cannot manage (the manual command is printed). Messages go to stderr; `--json` has no JSON for it. The `/agent-toggle` pane command has no `update`: run it in a terminal |
210
+ | `update` (also `--update`) | install the newest release tag (`uv tool install --force --no-sources git+<repo>@<tag>`, or `pipx install --force` for a pipx install), then, with the new version, update the Claude Code plugin (when installed) and the shims. Never asks. Always the newest tag: a dev checkout, a branch or an unmanaged install is replaced by it. Exit `0` updated or already current, `1` the check or install failed (offline, rate limit), `2` neither uv nor pipx is found (the manual command is printed). Messages go to stderr; `--json` has no JSON for it. The `/agent-toggle` pane command has no `update`: run it in a terminal |
207
211
  | `install-shims` | write the skill shim into every installed harness that is on in the settings (an off one is skipped unless named with `--harness`); refuses to overwrite a file it did not write (`--dry-run` shows the plan); reports stale legacy `<dir>-disabled/` lines in a harness-home `.gitignore` (`--gitignore` removes them, `--no-gitignore` skips the check); on a terminal, then asks y/n for each problem `doctor` can fix (see [Fixing](#fixing)) |
208
212
  | `disable <type> <name>...` | park one or more items (`--dry-run` shows the plan; `--project <dir>` for a repo's own `.claude/` and `.mcp.json`) |
209
213
  | `enable <type> <name>...` | put them back (`--dry-run` shows the plan; `--project <dir>` likewise) |
@@ -488,16 +492,41 @@ invalid value in either is ignored with one warning on stderr.
488
492
  - `default_harness` is what `disable` / `enable` act on without `--harness`, even when that
489
493
  harness is switched off (with one warning on stderr);
490
494
  `disable` / `enable --project` without `--harness` still default to `claude`.
491
- - `language` translates the config menu, help, update messages and picker chrome; command output stays English.
495
+ - `language` translates the config menu, help, update prompt and picker chrome; command output stays English.
492
496
  - telegram-kit is a regular dependency now, so reinstalling keeps the Telegram settings working;
493
497
  `agent-toggle[telegram]` still resolves.
494
498
 
499
+ ## Update check
500
+
501
+ On every invocation (unless disabled) agent-toggle checks PyPI for a newer release. It starts
502
+ before the command and is offered after it, on every exit path; it overlaps the command and waits at most 0.8 s afterwards.
503
+ `update` and `completion` skip it.
504
+
505
+ - **On a terminal** (stdin and stderr are TTYs, no `--json`): a panel on stderr, "agent-toggle X
506
+ is available (you have Y)", with `Update now`, `Skip` and `Skip until next version`, a release
507
+ notes link, and `↑` `↓` `Enter` `q`. `Update now` runs [`agent-toggle update`](#update) (the
508
+ newest release tag, then the Claude Code plugin and the shims). A failure gives a yellow warning
509
+ naming the command to run yourself.
510
+ - **Windows**: a running `agent-toggle.exe` cannot be replaced, so there is no `Update now`; the
511
+ command is printed instead (as below), to run once agent-toggle has exited.
512
+ - **Off a terminal** (or `--json`): a stderr line, `agent-toggle X is available (you have Y)`,
513
+ then ` agent-toggle update`.
514
+ - It never changes the exit code or stdout, so `--json` output stays one document.
515
+
516
+ It is one `GET https://pypi.org/pypi/agent-toggle/json` on a background thread with a 0.8 s
517
+ timeout, cached for 10 minutes in `~/.agent-toggle/update-check.json`. Nothing is sent beyond a
518
+ normal HTTP request with the User-Agent `agent-toggle-update-check`. See `SECURITY.md`
519
+ for the hardening. To turn it off, set `AGENT_TOGGLE_UPDATE_CHECK=0` or the `update_check` setting
520
+ to off (the settings menu's "Check for updates").
521
+
495
522
  ## Update
496
523
 
497
524
  `agent-toggle update` (or `agent-toggle --update`) is a self-update through
498
525
  update-kit (a small stdlib-only helper library): it asks the repository for its newest `vX.Y.Z`
499
- tag (`git ls-remote --tags`), and when that is newer than the running version it installs it with
526
+ tag (`git ls-remote --tags`) and always installs that tag with
500
527
  `uv tool install --force --no-sources git+<repo>@<tag>` (`pipx install --force` for a pipx install).
528
+ A dev checkout, a branch or commit install, or an unmanaged install is replaced by the tag too
529
+ (with uv, else pipx); only an install made from exactly that tag is already up to date.
501
530
  It then runs, with the new binary and in order: when the Claude Code plugin is installed,
502
531
  `claude plugin marketplace update agent-toggle` and `claude plugin update agent-toggle@agent-toggle`,
503
532
  then `agent-toggle install-shims`. A failing step prints a warning and makes the exit code `1`.
@@ -507,19 +536,11 @@ It never prompts and writes to stderr only.
507
536
  |---|---|
508
537
  | `0` | updated, or already up to date |
509
538
  | `1` | could not check (offline, rate limit) or the install failed |
510
- | `2` | a dev install (`git pull` instead), or an install kind it cannot manage (the manual command is printed) |
539
+ | `2` | neither uv nor pipx is found (the manual command is printed) |
511
540
 
512
541
  On Windows a running `agent-toggle.exe` cannot be replaced, so the install runs detached after
513
542
  agent-toggle exits (log in `%USERPROFILE%\.update-kit\`).
514
543
 
515
- **The hint.** After a command, on an interactive terminal only (stderr is a TTY, never under
516
- `--json`, pipes, launchd or timers), agent-toggle prints one stderr line when a newer release
517
- exists, `agent-toggle vX.Y.Z is available (you have A) — run `agent-toggle update``. The check is
518
- `git ls-remote` with a 1.5 s timeout, cached for 24 hours (failures too) in
519
- `~/.update-kit/state.json`. It never changes the exit code or stdout. To turn it off, set
520
- `AGENT_TOGGLE_UPDATE_CHECK=0` or the `update_check` setting to off (the settings menu's
521
- "Check for updates").
522
-
523
544
  ## Help
524
545
 
525
546
  ```sh
@@ -910,6 +931,7 @@ bookkeeping.
910
931
  | `parked/<sha8>/` | items parked by `--project` (`<sha8>` = first 8 hex of the SHA-1 of the resolved project dir) |
911
932
  | `profiles/` | `<name>.json` profiles (dir `0700`, files `0600`) |
912
933
  | `config.json` | [settings](#settings--config-menu) and the Telegram chat id (mode `0600`, atomic write, unknown keys kept) |
934
+ | `update-check.json` | the [update check](#update-check)'s 10-minute cache (mode `0600`, atomic write) |
913
935
 
914
936
  A killed run loses at most the one item in flight. If that was a flag write or a
915
937
  dir move (with its companions), its `pending` record (the full entry, a flag's
@@ -7,6 +7,10 @@ rules, plugins (mods included), MCP servers — across Claude Code, Codex, Grok
7
7
  OpenClaw, Copilot, Vibe, Devin and Antigravity — and show what each one costs at
8
8
  session start.
9
9
 
10
+ It ships in two forms: a **CLI** (`agent-toggle`, all harnesses) and a **Claude Code mod**
11
+ (the `/agent-toggle` pane, claude harness only; see [Claude Code pane mod](#claude-code-pane-mod)).
12
+ Both drive the same toggle engine and state.
13
+
10
14
  **Nothing is ever deleted.** Everything is parked and recorded, and `enable`
11
15
  puts it back where it came from.
12
16
 
@@ -169,7 +173,7 @@ agent-toggle <command> [args] # or: python3 agent_toggle.py <command> [
169
173
  | `status` | health check: harnesses found, types each supports, parked counts, gitignore, untracked parked items, stale live twins, shared dirs, and state entries whose parked item is gone (a `stale` row with the fix command; read-only, still exit `0`) |
170
174
  | `list [type]` | what is currently disabled (`--project <dir>` filters to one project) |
171
175
  | `cost [--type T]` | estimated startup tokens per item, biggest first (read-only; `--harness H` filters; `--project <dir>` prices a repo's `.claude/` and `.mcp.json` instead of user scope) |
172
- | `update` (also `--update`) | install the newest release tag (`uv tool install --force --no-sources git+<repo>@<tag>`, or `pipx install --force` for a pipx install), then, with the new version, update the Claude Code plugin (when installed) and the shims. Never asks. Exit `0` updated or already current, `1` the check or install failed (offline, rate limit), `2` a dev install (use `git pull`) or an install it cannot manage (the manual command is printed). Messages go to stderr; `--json` has no JSON for it. The `/agent-toggle` pane command has no `update`: run it in a terminal |
176
+ | `update` (also `--update`) | install the newest release tag (`uv tool install --force --no-sources git+<repo>@<tag>`, or `pipx install --force` for a pipx install), then, with the new version, update the Claude Code plugin (when installed) and the shims. Never asks. Always the newest tag: a dev checkout, a branch or an unmanaged install is replaced by it. Exit `0` updated or already current, `1` the check or install failed (offline, rate limit), `2` neither uv nor pipx is found (the manual command is printed). Messages go to stderr; `--json` has no JSON for it. The `/agent-toggle` pane command has no `update`: run it in a terminal |
173
177
  | `install-shims` | write the skill shim into every installed harness that is on in the settings (an off one is skipped unless named with `--harness`); refuses to overwrite a file it did not write (`--dry-run` shows the plan); reports stale legacy `<dir>-disabled/` lines in a harness-home `.gitignore` (`--gitignore` removes them, `--no-gitignore` skips the check); on a terminal, then asks y/n for each problem `doctor` can fix (see [Fixing](#fixing)) |
174
178
  | `disable <type> <name>...` | park one or more items (`--dry-run` shows the plan; `--project <dir>` for a repo's own `.claude/` and `.mcp.json`) |
175
179
  | `enable <type> <name>...` | put them back (`--dry-run` shows the plan; `--project <dir>` likewise) |
@@ -454,16 +458,41 @@ invalid value in either is ignored with one warning on stderr.
454
458
  - `default_harness` is what `disable` / `enable` act on without `--harness`, even when that
455
459
  harness is switched off (with one warning on stderr);
456
460
  `disable` / `enable --project` without `--harness` still default to `claude`.
457
- - `language` translates the config menu, help, update messages and picker chrome; command output stays English.
461
+ - `language` translates the config menu, help, update prompt and picker chrome; command output stays English.
458
462
  - telegram-kit is a regular dependency now, so reinstalling keeps the Telegram settings working;
459
463
  `agent-toggle[telegram]` still resolves.
460
464
 
465
+ ## Update check
466
+
467
+ On every invocation (unless disabled) agent-toggle checks PyPI for a newer release. It starts
468
+ before the command and is offered after it, on every exit path; it overlaps the command and waits at most 0.8 s afterwards.
469
+ `update` and `completion` skip it.
470
+
471
+ - **On a terminal** (stdin and stderr are TTYs, no `--json`): a panel on stderr, "agent-toggle X
472
+ is available (you have Y)", with `Update now`, `Skip` and `Skip until next version`, a release
473
+ notes link, and `↑` `↓` `Enter` `q`. `Update now` runs [`agent-toggle update`](#update) (the
474
+ newest release tag, then the Claude Code plugin and the shims). A failure gives a yellow warning
475
+ naming the command to run yourself.
476
+ - **Windows**: a running `agent-toggle.exe` cannot be replaced, so there is no `Update now`; the
477
+ command is printed instead (as below), to run once agent-toggle has exited.
478
+ - **Off a terminal** (or `--json`): a stderr line, `agent-toggle X is available (you have Y)`,
479
+ then ` agent-toggle update`.
480
+ - It never changes the exit code or stdout, so `--json` output stays one document.
481
+
482
+ It is one `GET https://pypi.org/pypi/agent-toggle/json` on a background thread with a 0.8 s
483
+ timeout, cached for 10 minutes in `~/.agent-toggle/update-check.json`. Nothing is sent beyond a
484
+ normal HTTP request with the User-Agent `agent-toggle-update-check`. See `SECURITY.md`
485
+ for the hardening. To turn it off, set `AGENT_TOGGLE_UPDATE_CHECK=0` or the `update_check` setting
486
+ to off (the settings menu's "Check for updates").
487
+
461
488
  ## Update
462
489
 
463
490
  `agent-toggle update` (or `agent-toggle --update`) is a self-update through
464
491
  update-kit (a small stdlib-only helper library): it asks the repository for its newest `vX.Y.Z`
465
- tag (`git ls-remote --tags`), and when that is newer than the running version it installs it with
492
+ tag (`git ls-remote --tags`) and always installs that tag with
466
493
  `uv tool install --force --no-sources git+<repo>@<tag>` (`pipx install --force` for a pipx install).
494
+ A dev checkout, a branch or commit install, or an unmanaged install is replaced by the tag too
495
+ (with uv, else pipx); only an install made from exactly that tag is already up to date.
467
496
  It then runs, with the new binary and in order: when the Claude Code plugin is installed,
468
497
  `claude plugin marketplace update agent-toggle` and `claude plugin update agent-toggle@agent-toggle`,
469
498
  then `agent-toggle install-shims`. A failing step prints a warning and makes the exit code `1`.
@@ -473,19 +502,11 @@ It never prompts and writes to stderr only.
473
502
  |---|---|
474
503
  | `0` | updated, or already up to date |
475
504
  | `1` | could not check (offline, rate limit) or the install failed |
476
- | `2` | a dev install (`git pull` instead), or an install kind it cannot manage (the manual command is printed) |
505
+ | `2` | neither uv nor pipx is found (the manual command is printed) |
477
506
 
478
507
  On Windows a running `agent-toggle.exe` cannot be replaced, so the install runs detached after
479
508
  agent-toggle exits (log in `%USERPROFILE%\.update-kit\`).
480
509
 
481
- **The hint.** After a command, on an interactive terminal only (stderr is a TTY, never under
482
- `--json`, pipes, launchd or timers), agent-toggle prints one stderr line when a newer release
483
- exists, `agent-toggle vX.Y.Z is available (you have A) — run `agent-toggle update``. The check is
484
- `git ls-remote` with a 1.5 s timeout, cached for 24 hours (failures too) in
485
- `~/.update-kit/state.json`. It never changes the exit code or stdout. To turn it off, set
486
- `AGENT_TOGGLE_UPDATE_CHECK=0` or the `update_check` setting to off (the settings menu's
487
- "Check for updates").
488
-
489
510
  ## Help
490
511
 
491
512
  ```sh
@@ -876,6 +897,7 @@ bookkeeping.
876
897
  | `parked/<sha8>/` | items parked by `--project` (`<sha8>` = first 8 hex of the SHA-1 of the resolved project dir) |
877
898
  | `profiles/` | `<name>.json` profiles (dir `0700`, files `0600`) |
878
899
  | `config.json` | [settings](#settings--config-menu) and the Telegram chat id (mode `0600`, atomic write, unknown keys kept) |
900
+ | `update-check.json` | the [update check](#update-check)'s 10-minute cache (mode `0600`, atomic write) |
879
901
 
880
902
  A killed run loses at most the one item in flight. If that was a flag write or a
881
903
  dir move (with its companions), its `pending` record (the full entry, a flag's
@@ -1,3 +1,3 @@
1
1
  """agent-toggle: temporarily disable / re-enable AI-agent resources."""
2
2
 
3
- __version__ = "0.15.0"
3
+ __version__ = "0.16.0"
@@ -36,7 +36,7 @@ Usage:
36
36
  failure, 2 usage error, 3 locked, 4 unsupported pair / harness missing.
37
37
  Settings: $HOME/.agent-toggle/config.json (see `config`); env overrides
38
38
  AGENT_TOGGLE_UPDATE_CHECK / _COLOR / _LANG / _DEFAULT_HARNESS.
39
- Update hint: on by default, a terminal's stderr only; AGENT_TOGGLE_UPDATE_CHECK=0 turns it off.
39
+ Update check: on by default, stderr only; AGENT_TOGGLE_UPDATE_CHECK=0 turns it off.
40
40
  """
41
41
  from __future__ import annotations
42
42
 
@@ -723,9 +723,9 @@ def build_parser() -> argparse.ArgumentParser:
723
723
  def main(argv: list[str] | None = None) -> int:
724
724
  """Run one command and return its exit code (the process exit code).
725
725
 
726
- Settings apply first; the update hint is printed in a `finally`, so every way out
727
- (a command, --help/--version, a usage error, Locked, CliError) gets it. It writes
728
- to stderr only and never changes the exit code."""
726
+ Settings apply first; the update check starts before dispatch and is offered in a
727
+ `finally`, so every way out (a command, --help/--version, a usage error, Locked,
728
+ CliError) gets it. It writes to stderr only and never changes the exit code."""
729
729
  argv = normalize_argv(sys.argv[1:] if argv is None else argv)
730
730
  try:
731
731
  language, color = settings.get("language"), settings.get("color")
@@ -734,17 +734,16 @@ def main(argv: list[str] | None = None) -> int:
734
734
  i18n.set_language(language)
735
735
  color = scan_color(argv, color) # --color > env > setting > auto
736
736
  i = _command_index(argv)
737
- hint = True
737
+ # `update` is the update; `completion` may run on every shell start (eval "$(...)")
738
+ started = None if argv[i:i + 1] in (["update"], ["completion"]) else update_prompt.start()
738
739
  try:
739
740
  return _main(argv, color)
740
741
  except KeyboardInterrupt:
741
- hint = False # never ask anything after the user hit Ctrl-C
742
+ started = None # never prompt after the user hit Ctrl-C
742
743
  raise
743
744
  finally:
744
- # `update` is the update; `completion` may run on every shell start (eval "$(...)")
745
- if hint and argv[i:i + 1] not in (["update"], ["completion"]):
746
- with contextlib.suppress(KeyboardInterrupt):
747
- update_prompt.hint(json_mode="--json" in argv)
745
+ with contextlib.suppress(KeyboardInterrupt): # Ctrl-C while waiting on the check
746
+ update_prompt.offer(started, json_mode="--json" in argv, color_mode=color)
748
747
 
749
748
 
750
749
  def _main(argv: list[str], color: str) -> int:
@@ -140,9 +140,10 @@ def commands(default_harness: str = "claude") -> dict[str, Cmd]:
140
140
  t("help.update.summary", "install the newest release; refresh the plugin and shims"),
141
141
  t("help.update.desc", "Install the newest release tag (uv tool or pipx), then, with the "
142
142
  "new version, update the Claude Code plugin (if installed) and "
143
- "reinstall the shims. Never asks; `--update` is the same. A dev "
144
- "install says to git pull (exit 2). Exit 1 when the check or "
145
- "the install fails."),
143
+ "reinstall the shims. Never asks; `--update` is the same. Always "
144
+ "the newest tag: a dev or unmanaged install is replaced by it. "
145
+ "Exit 1 when the check or the install fails, 2 when neither uv "
146
+ "nor pipx is found."),
146
147
  examples=["update"]),
147
148
  "completion": Cmd(
148
149
  "completion bash|zsh|fish|powershell",
@@ -29,7 +29,8 @@ CATALOG: dict[str, str] = {
29
29
  "以及暫存項目已省下多少。",
30
30
  "help.update.summary": "安裝最新版本,並更新外掛與 shim",
31
31
  "help.update.desc": "安裝最新的發行標籤(uv tool / pipx),完成後以新版更新 Claude Code 外掛(若已安裝)"
32
- "並重新安裝 shim。不會提問;`--update` 等同 `update`。開發版安裝請改用 git pull(結束碼 2)。",
32
+ "並重新安裝 shim。不會提問;`--update` 等同 `update`。一律安裝最新標籤:開發版或非受管安裝也會被取代。"
33
+ "檢查或安裝失敗時結束碼 1;找不到 uv 與 pipx 時結束碼 2。",
33
34
  "help.completion.summary": "印出 shell 自動補完腳本",
34
35
  "help.completion.desc": "在 stdout 印出某個 shell 的 Tab 補完腳本;"
35
36
  "存到該 shell 載入補完的位置(見 README「Shell completion」)。",
@@ -85,4 +86,16 @@ CATALOG: dict[str, str] = {
85
86
  "help.opt.help": "顯示說明(`help <command>` 看單一指令)",
86
87
  "help.arguments": "參數",
87
88
  "help.options": "選項",
89
+ # update prompt
90
+ "update.available": "agent-toggle %s 已推出(目前版本 %s)",
91
+ "update.keys": "↑↓ 選擇 · ⏎ 確認 · q/Ctrl+C 略過",
92
+ "update.keys_ascii": "上/下 選擇,Enter 確認,q/Ctrl+C 略過",
93
+ "update.choose": "請選擇 1-3(Enter = 1,q/Ctrl+C = 略過):",
94
+ "update.now": "立即更新",
95
+ "update.skip": "略過",
96
+ "update.skip_detail": "下次執行再問",
97
+ "update.skip_version": "略過到下一個版本",
98
+ "update.skip_version_detail": "有更新的版本時再問",
99
+ "update.notes": "版本說明:%s",
100
+ "update.failed": "更新未完成 — 請自行執行:%s",
88
101
  }
@@ -0,0 +1,361 @@
1
+ """Update check against PyPI or GitHub releases. Stdlib only — copied from
2
+ aicp's ``update_check.py`` essentially verbatim; only the UI (``ask``) differs.
3
+
4
+ from agent_toggle.update_check import check
5
+ found = check(
6
+ "agent-toggle",
7
+ current="1.0.0",
8
+ cache_path=Path.home() / ".agent-toggle" / "update-check.json",
9
+ )
10
+ if found:
11
+ print(f"{found.latest} is available (you have {found.current})")
12
+
13
+ A project released as GitHub tags instead of PyPI passes
14
+ ``fetch=fetch_github`` and its ``owner/repo`` in place of the dist name;
15
+ ``latest`` is then the tag as published (``v1.2.0``).
16
+
17
+ To overlap the request with the command's own work instead of paying for
18
+ it at exit, start it early and collect the result at the end:
19
+
20
+ started = start("agent-toggle", current="1.0.0", cache_path=...)
21
+ ... # the command's own work
22
+ found = collect(started)
23
+
24
+ To ask the user what to do instead of only hinting, hand :func:`offer` the
25
+ UI as a callback — it owns what each answer does, the UI only picks one:
26
+
27
+ offer(started, ask, cache_path=..., upgrade=["uv", "tool", "upgrade", "agent-toggle"])
28
+
29
+ where ``ask(found)`` returns ``UPDATE_NOW``, ``SKIP`` or ``SKIP_VERSION``.
30
+ When the upgrade depends on the version or takes several steps, pass a
31
+ callable instead: ``upgrade=lambda found: [[...], [...]]`` runs each command
32
+ in order and stops at the first one that fails.
33
+ ``SKIP_VERSION`` is remembered in the cache; a newer release asks again.
34
+
35
+ Nothing here is allowed to raise to the caller: a missing cache, a bad
36
+ payload, or a dead network all become ``None``.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import json
42
+ import os
43
+ import re
44
+ import subprocess
45
+ import threading
46
+ import time
47
+ import urllib.error
48
+ from collections.abc import Callable, Sequence
49
+ from dataclasses import dataclass, field
50
+ from pathlib import Path
51
+
52
+ __all__ = [
53
+ "DEFAULT_TIMEOUT",
54
+ "DEFAULT_TTL",
55
+ "SKIP",
56
+ "SKIP_VERSION",
57
+ "UPDATE_NOW",
58
+ "UPGRADE_FAILED",
59
+ "Started",
60
+ "UpdateAvailable",
61
+ "check",
62
+ "collect",
63
+ "fetch_github",
64
+ "fetch_pypi",
65
+ "offer",
66
+ "skip",
67
+ "start",
68
+ ]
69
+
70
+ #: Short because a project can cut several releases in one day; stays far
71
+ #: under PyPI's rate limits even checked every command.
72
+ DEFAULT_TTL = 600
73
+ DEFAULT_TIMEOUT = 0.8
74
+
75
+ #: Answers an ``ask`` callback returns to :func:`offer`.
76
+ UPDATE_NOW = "update-now"
77
+ SKIP = "skip"
78
+ SKIP_VERSION = "skip-version"
79
+ #: What :func:`offer` returns when UPDATE_NOW's command could not run or failed.
80
+ UPGRADE_FAILED = "upgrade-failed"
81
+
82
+
83
+ #: What a version may look like before it is trusted: it gets printed to a terminal.
84
+ _VERSION_RE = re.compile(r"v?\d{1,6}(\.\d{1,6}){1,3}([a-zA-Z0-9.+-]{0,20})?")
85
+ _MAX_BODY = 1_000_000
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class UpdateAvailable:
90
+ current: str
91
+ latest: str
92
+
93
+
94
+ def _get(request, timeout: float) -> str:
95
+ """GET over an opener that follows https redirects only; body capped at 1 MB."""
96
+ import urllib.request # lazy: ~12 ms saved on every run where the check is off
97
+
98
+ class _HttpsOnly(urllib.request.HTTPRedirectHandler):
99
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
100
+ if not newurl.lower().startswith("https://"):
101
+ raise urllib.error.URLError(f"refusing non-https redirect: {newurl[:80]!r}")
102
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
103
+
104
+ send = urllib.request.build_opener(_HttpsOnly).open # network, not file I/O
105
+ with send(request, timeout=timeout) as response:
106
+ return response.read(_MAX_BODY).decode("utf-8")
107
+
108
+
109
+ def fetch_pypi(dist_name: str, timeout: float) -> str:
110
+ """GET ``https://pypi.org/pypi/<dist>/json`` and return the raw body."""
111
+ import urllib.request
112
+
113
+ request = urllib.request.Request(
114
+ f"https://pypi.org/pypi/{dist_name}/json",
115
+ headers={
116
+ "Accept": "application/json",
117
+ "User-Agent": f"{dist_name}-update-check",
118
+ },
119
+ )
120
+ return _get(request, timeout)
121
+
122
+
123
+ def fetch_github(repo: str, timeout: float) -> str:
124
+ """GET ``https://api.github.com/repos/<owner/repo>/releases/latest``; raw body."""
125
+ import urllib.request
126
+
127
+ request = urllib.request.Request(
128
+ f"https://api.github.com/repos/{repo}/releases/latest",
129
+ headers={
130
+ "Accept": "application/vnd.github+json",
131
+ "User-Agent": f"{repo.rsplit('/', 1)[-1]}-update-check",
132
+ },
133
+ )
134
+ return _get(request, timeout)
135
+
136
+
137
+ def check(
138
+ dist_name: str,
139
+ current: str,
140
+ *,
141
+ cache_path: Path,
142
+ ttl_seconds: int = DEFAULT_TTL,
143
+ timeout: float = DEFAULT_TIMEOUT,
144
+ now: float | None = None,
145
+ fetch: Callable[[str, float], str] | None = None,
146
+ ignore_skip: bool = False,
147
+ ) -> UpdateAvailable | None:
148
+ """Return an update when the latest release is newer than *current*, else None.
149
+ *ignore_skip* reports a "skip this version" release too (an explicit ask)."""
150
+ current_parts = _numeric_tuple(current)
151
+ if current_parts is None:
152
+ return None
153
+ stamp = _now(now)
154
+ cached = _load_cache(cache_path)
155
+ latest = _valid_version(cached.get("latest"))
156
+ checked_at = cached.get("checked_at")
157
+ try:
158
+ age = stamp - float(checked_at) if isinstance(checked_at, (int, float)) else -1.0
159
+ except OverflowError: # a huge int in a hand-edited cache
160
+ age = -1.0
161
+ fresh = 0 <= age < ttl_seconds # a future, inf or nan stamp is stale
162
+ if not fresh:
163
+ try:
164
+ fetched = _latest_from_body((fetch or fetch_pypi)(dist_name, timeout))
165
+ except (OSError, urllib.error.URLError, TimeoutError, ValueError):
166
+ fetched = None
167
+ # A failed fetch is cached too (keeping the old answer), so being
168
+ # offline costs one timeout per TTL, not one per command.
169
+ latest = fetched or latest
170
+ _save_cache(cache_path, stamp, latest)
171
+ if not isinstance(latest, str) or (not ignore_skip and latest == _valid_version(cached.get("skipped"))):
172
+ return None
173
+ latest_parts = _numeric_tuple(latest)
174
+ if latest_parts is None or latest_parts <= current_parts:
175
+ return None
176
+ return UpdateAvailable(current=current, latest=latest)
177
+
178
+
179
+ @dataclass
180
+ class Started:
181
+ """A background check in flight; pass to :func:`collect` to get its result."""
182
+
183
+ thread: threading.Thread
184
+ _result: list = field(default_factory=list)
185
+
186
+
187
+ def start(
188
+ dist_name: str,
189
+ current: str,
190
+ *,
191
+ cache_path: Path,
192
+ ttl_seconds: int = DEFAULT_TTL,
193
+ timeout: float = DEFAULT_TIMEOUT,
194
+ fetch: Callable[[str, float], str] | None = None,
195
+ ) -> Started:
196
+ """Run :func:`check` on a daemon thread so it overlaps the caller's own work."""
197
+ result: list = []
198
+
199
+ def run() -> None:
200
+ try:
201
+ result.append(
202
+ check(
203
+ dist_name,
204
+ current,
205
+ cache_path=cache_path,
206
+ ttl_seconds=ttl_seconds,
207
+ timeout=timeout,
208
+ fetch=fetch,
209
+ )
210
+ )
211
+ except Exception: # noqa: BLE001, S110 - a background check must never raise
212
+ pass
213
+
214
+ thread = threading.Thread(target=run, name=f"{dist_name}-update-check", daemon=True)
215
+ thread.start()
216
+ return Started(thread=thread, _result=result)
217
+
218
+
219
+ def collect(started: Started | None, *, timeout: float = DEFAULT_TIMEOUT) -> UpdateAvailable | None:
220
+ """Wait for a :func:`start`-ed check and return its result. Never raises."""
221
+ if started is None:
222
+ return None
223
+ try:
224
+ # Usually already done: it ran alongside the caller's own work.
225
+ started.thread.join(timeout)
226
+ return started._result[0] if started._result else None
227
+ except Exception: # noqa: BLE001 - a hint must never fail the command
228
+ return None
229
+
230
+
231
+ def skip(cache_path: Path, version: str) -> None:
232
+ """Stop reporting *version*; a newer release is reported again. Never raises."""
233
+ data = _load_cache(cache_path)
234
+ data["skipped"] = version
235
+ _write_cache(cache_path, data)
236
+
237
+
238
+ def offer(
239
+ started: Started | None,
240
+ ask: Callable[[UpdateAvailable], str],
241
+ *,
242
+ cache_path: Path,
243
+ upgrade: Sequence[str] | Callable[[UpdateAvailable], Sequence[Sequence[str]]],
244
+ run: Callable[..., object] = subprocess.run,
245
+ ) -> str | None:
246
+ """Collect *started*, let *ask* pick an answer, then act on it.
247
+
248
+ *upgrade* is one command, or a callable building the commands from the
249
+ found release, run in order. Returns the answer acted on
250
+ (``UPGRADE_FAILED`` when a command is missing or exits nonzero — the
251
+ rest are then skipped), or None when there was nothing to offer. Ctrl+C
252
+ inside *ask* is ``SKIP``. Never raises.
253
+ """
254
+ try:
255
+ found = collect(started)
256
+ if found is None:
257
+ return None
258
+ try:
259
+ answer = ask(found)
260
+ except KeyboardInterrupt:
261
+ return SKIP
262
+ if answer == SKIP_VERSION:
263
+ skip(cache_path, found.latest)
264
+ elif answer == UPDATE_NOW:
265
+ steps = upgrade(found) if callable(upgrade) else [upgrade]
266
+ for step in steps:
267
+ try:
268
+ done = run(list(step), check=False)
269
+ except OSError:
270
+ return UPGRADE_FAILED
271
+ if getattr(done, "returncode", 1) != 0:
272
+ return UPGRADE_FAILED
273
+ return answer
274
+ except Exception: # noqa: BLE001 - an update offer must never fail the command
275
+ return None
276
+
277
+
278
+ def _now(now: float | None) -> float:
279
+ return time.time() if now is None else now
280
+
281
+
282
+ def _numeric_tuple(version: str) -> tuple[int, ...] | None:
283
+ """Leading dotted integers. ``0.10.0`` > ``0.9.1``; ``v1.2`` is ``1.2``. Needs at least X.Y."""
284
+ parts: list[int] = []
285
+ for chunk in version.strip().lstrip("vV").split("."):
286
+ digits = ""
287
+ for char in chunk:
288
+ if char in "0123456789":
289
+ digits += char
290
+ else:
291
+ break
292
+ if not digits or len(digits) > 6:
293
+ break
294
+ parts.append(int(digits))
295
+ if len(parts) < 2:
296
+ return None
297
+ return tuple(parts)
298
+
299
+
300
+ def _valid_version(value: object) -> str | None:
301
+ """*value* if it is a plausible version string (no control characters), else None."""
302
+ return value if isinstance(value, str) and _VERSION_RE.fullmatch(value) else None
303
+
304
+
305
+ def _latest_from_body(body: str) -> str | None:
306
+ """PyPI's ``info.version``, or a GitHub release's ``tag_name``."""
307
+ data = json.loads(body)
308
+ if not isinstance(data, dict):
309
+ return None
310
+ tag = data.get("tag_name")
311
+ if isinstance(tag, str) and tag:
312
+ return _valid_version(tag)
313
+ info = data.get("info")
314
+ if not isinstance(info, dict):
315
+ return None
316
+ version = info.get("version")
317
+ return _valid_version(version)
318
+
319
+
320
+ def _load_cache(path: Path) -> dict:
321
+ try:
322
+ data = json.loads(path.read_text(encoding="utf-8"))
323
+ except (OSError, ValueError, RecursionError): # RecursionError: a deeply nested file
324
+ return {}
325
+ if not isinstance(data, dict):
326
+ return {}
327
+ # Only the known keys with valid scalar types survive: a hostile file can't
328
+ # smuggle anything (e.g. a 100k-deep dict that json.dumps can't write back).
329
+ clean: dict = {}
330
+ checked_at = data.get("checked_at")
331
+ if isinstance(checked_at, (int, float)) and not isinstance(checked_at, bool):
332
+ clean["checked_at"] = checked_at
333
+ for key in ("latest", "skipped"):
334
+ version = _valid_version(data.get(key))
335
+ if version is not None:
336
+ clean[key] = version
337
+ return clean
338
+
339
+
340
+ def _save_cache(path: Path, checked_at: float, latest: str | None) -> None:
341
+ data = _load_cache(path)
342
+ data.update(checked_at=checked_at, latest=latest)
343
+ _write_cache(path, data)
344
+
345
+
346
+ def _write_cache(path: Path, data: dict) -> None:
347
+ # tmp + os.replace never follows a symlink at *path*; 0600 from creation.
348
+ tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
349
+ try:
350
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
351
+ tmp.unlink(missing_ok=True)
352
+ fd = os.open(tmp, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
353
+ try:
354
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
355
+ fh.write(json.dumps(data) + "\n")
356
+ os.replace(tmp, path)
357
+ except BaseException:
358
+ tmp.unlink(missing_ok=True)
359
+ raise
360
+ except (OSError, RecursionError, ValueError):
361
+ return