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.
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/PKG-INFO +36 -14
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/README.md +34 -12
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/__init__.py +1 -1
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/cli.py +9 -10
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/help.py +4 -3
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/locales/zh_tw_cli.py +14 -1
- agent_toggle-0.16.0/agent_toggle/update_check.py +361 -0
- agent_toggle-0.16.0/agent_toggle/update_prompt.py +260 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle.egg-info/PKG-INFO +36 -14
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle.egg-info/SOURCES.txt +2 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle.egg-info/requires.txt +1 -1
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/pyproject.toml +1 -1
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_cli.py +112 -33
- agent_toggle-0.16.0/tests/test_update_check.py +423 -0
- agent_toggle-0.16.0/tests/test_update_prompt.py +162 -0
- agent_toggle-0.15.0/agent_toggle/update_prompt.py +0 -80
- agent_toggle-0.15.0/tests/test_update_prompt.py +0 -104
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/LICENSE +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/__main__.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/backends/__init__.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/backends/flag_json.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/backends/mcp_json.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/backends/mcp_toml.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/backends/plugin_cli.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/companions.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/completions.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/config.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/cost.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/doctor.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/fs.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/harnesses.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/i18n.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/locales/__init__.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/locales/zh_tw_common.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/locales/zh_tw_config.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/locales/zh_tw_picker.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/mechanisms.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/ops.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/output.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/profiles.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/settings.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/shims/claude.md.tmpl +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/shims/generic.md.tmpl +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/spinner.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/store.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/toml_check.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/ui/__init__.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/ui/config_menu.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/ui/logo.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/ui/menu.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/ui/model.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/ui/picker.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/ui/theme.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle/undo.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle.egg-info/dependency_links.txt +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle.egg-info/entry_points.txt +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/agent_toggle.egg-info/top_level.txt +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/setup.cfg +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_alias.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_all_type.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_backends.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_cli_surface.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_color.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_companions.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_completions.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_config.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_config_menu.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_conformance.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_containment.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_cost.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_crash.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_doctor.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_encoding.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_fs.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_harnesses.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_help.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_i18n.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_install.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_logo.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_mechanisms.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_menu.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_mods.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_picker.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_platform.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_profiles.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_project.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_safety.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_same_name.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_secret_audit.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_settings.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_store.py +0 -0
- {agent_toggle-0.15.0 → agent_toggle-0.16.0}/tests/test_theme.py +0 -0
- {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.
|
|
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.
|
|
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`
|
|
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
|
|
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`)
|
|
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` |
|
|
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`
|
|
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
|
|
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`)
|
|
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` |
|
|
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
|
|
@@ -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
|
|
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
|
|
727
|
-
(a command, --help/--version, a usage error, Locked,
|
|
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
|
-
|
|
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
|
-
|
|
742
|
+
started = None # never prompt after the user hit Ctrl-C
|
|
742
743
|
raise
|
|
743
744
|
finally:
|
|
744
|
-
#
|
|
745
|
-
|
|
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.
|
|
144
|
-
"
|
|
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
|
|
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
|