agent-toggle 0.16.1__tar.gz → 0.16.2__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.16.1 → agent_toggle-0.16.2}/PKG-INFO +6 -6
  2. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/README.md +5 -5
  3. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/__init__.py +1 -1
  4. agent_toggle-0.16.2/agent_toggle/update_check.py +209 -0
  5. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/update_prompt.py +5 -5
  6. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle.egg-info/PKG-INFO +6 -6
  7. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_cli.py +7 -7
  8. agent_toggle-0.16.2/tests/test_update_check.py +240 -0
  9. agent_toggle-0.16.1/agent_toggle/update_check.py +0 -361
  10. agent_toggle-0.16.1/tests/test_update_check.py +0 -423
  11. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/LICENSE +0 -0
  12. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/__main__.py +0 -0
  13. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/backends/__init__.py +0 -0
  14. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/backends/flag_json.py +0 -0
  15. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/backends/mcp_json.py +0 -0
  16. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/backends/mcp_toml.py +0 -0
  17. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/backends/plugin_cli.py +0 -0
  18. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/cli.py +0 -0
  19. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/companions.py +0 -0
  20. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/completions.py +0 -0
  21. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/config.py +0 -0
  22. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/cost.py +0 -0
  23. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/doctor.py +0 -0
  24. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/fs.py +0 -0
  25. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/harnesses.py +0 -0
  26. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/help.py +0 -0
  27. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/i18n.py +0 -0
  28. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/locales/__init__.py +0 -0
  29. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/locales/zh_tw_cli.py +0 -0
  30. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/locales/zh_tw_common.py +0 -0
  31. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/locales/zh_tw_config.py +0 -0
  32. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/locales/zh_tw_picker.py +0 -0
  33. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/mechanisms.py +0 -0
  34. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/ops.py +0 -0
  35. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/output.py +0 -0
  36. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/profiles.py +0 -0
  37. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/settings.py +0 -0
  38. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/shims/claude.md.tmpl +0 -0
  39. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/shims/generic.md.tmpl +0 -0
  40. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/spinner.py +0 -0
  41. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/store.py +0 -0
  42. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/toml_check.py +0 -0
  43. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/ui/__init__.py +0 -0
  44. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/ui/config_menu.py +0 -0
  45. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/ui/logo.py +0 -0
  46. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/ui/menu.py +0 -0
  47. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/ui/model.py +0 -0
  48. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/ui/picker.py +0 -0
  49. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/ui/theme.py +0 -0
  50. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/undo.py +0 -0
  51. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle.egg-info/SOURCES.txt +0 -0
  52. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle.egg-info/dependency_links.txt +0 -0
  53. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle.egg-info/entry_points.txt +0 -0
  54. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle.egg-info/requires.txt +0 -0
  55. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle.egg-info/top_level.txt +0 -0
  56. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/pyproject.toml +0 -0
  57. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/setup.cfg +0 -0
  58. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_alias.py +0 -0
  59. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_all_type.py +0 -0
  60. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_backends.py +0 -0
  61. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_cli_surface.py +0 -0
  62. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_color.py +0 -0
  63. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_companions.py +0 -0
  64. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_completions.py +0 -0
  65. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_config.py +0 -0
  66. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_config_menu.py +0 -0
  67. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_conformance.py +0 -0
  68. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_containment.py +0 -0
  69. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_cost.py +0 -0
  70. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_crash.py +0 -0
  71. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_doctor.py +0 -0
  72. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_encoding.py +0 -0
  73. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_fs.py +0 -0
  74. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_harnesses.py +0 -0
  75. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_help.py +0 -0
  76. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_i18n.py +0 -0
  77. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_install.py +0 -0
  78. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_logo.py +0 -0
  79. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_mechanisms.py +0 -0
  80. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_menu.py +0 -0
  81. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_mods.py +0 -0
  82. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_picker.py +0 -0
  83. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_platform.py +0 -0
  84. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_profiles.py +0 -0
  85. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_project.py +0 -0
  86. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_safety.py +0 -0
  87. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_same_name.py +0 -0
  88. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_secret_audit.py +0 -0
  89. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_settings.py +0 -0
  90. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_store.py +0 -0
  91. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_theme.py +0 -0
  92. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_undo.py +0 -0
  93. {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_update_prompt.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-toggle
3
- Version: 0.16.1
3
+ Version: 0.16.2
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
@@ -498,7 +498,7 @@ invalid value in either is ignored with one warning on stderr.
498
498
 
499
499
  ## Update check
500
500
 
501
- On every invocation (unless disabled) agent-toggle checks PyPI for a newer release. It starts
501
+ On every invocation (unless disabled) agent-toggle checks the repository's release tags for a newer release. It starts
502
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
503
  `update` and `completion` skip it.
504
504
 
@@ -513,9 +513,9 @@ before the command and is offered after it, on every exit path; it overlaps the
513
513
  then ` agent-toggle update`.
514
514
  - It never changes the exit code or stdout, so `--json` output stays one document.
515
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`
516
+ The lookup is update-kit's: one `git ls-remote --tags --refs` of the repository on a background
517
+ thread, cached for 10 minutes in `~/.update-kit/state.json`. Only the version you chose to skip is
518
+ kept in `~/.agent-toggle/update-check.json`. Nothing is sent beyond a normal git request. See `SECURITY.md`
519
519
  for the hardening. To turn it off, set `AGENT_TOGGLE_UPDATE_CHECK=0` or the `update_check` setting
520
520
  to off (the settings menu's "Check for updates").
521
521
 
@@ -931,7 +931,7 @@ bookkeeping.
931
931
  | `parked/<sha8>/` | items parked by `--project` (`<sha8>` = first 8 hex of the SHA-1 of the resolved project dir) |
932
932
  | `profiles/` | `<name>.json` profiles (dir `0700`, files `0600`) |
933
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) |
934
+ | `update-check.json` | the [update check](#update-check)'s skipped version (mode `0600`, atomic write) |
935
935
 
936
936
  A killed run loses at most the one item in flight. If that was a flag write or a
937
937
  dir move (with its companions), its `pending` record (the full entry, a flag's
@@ -464,7 +464,7 @@ invalid value in either is ignored with one warning on stderr.
464
464
 
465
465
  ## Update check
466
466
 
467
- On every invocation (unless disabled) agent-toggle checks PyPI for a newer release. It starts
467
+ On every invocation (unless disabled) agent-toggle checks the repository's release tags for a newer release. It starts
468
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
469
  `update` and `completion` skip it.
470
470
 
@@ -479,9 +479,9 @@ before the command and is offered after it, on every exit path; it overlaps the
479
479
  then ` agent-toggle update`.
480
480
  - It never changes the exit code or stdout, so `--json` output stays one document.
481
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`
482
+ The lookup is update-kit's: one `git ls-remote --tags --refs` of the repository on a background
483
+ thread, cached for 10 minutes in `~/.update-kit/state.json`. Only the version you chose to skip is
484
+ kept in `~/.agent-toggle/update-check.json`. Nothing is sent beyond a normal git request. See `SECURITY.md`
485
485
  for the hardening. To turn it off, set `AGENT_TOGGLE_UPDATE_CHECK=0` or the `update_check` setting
486
486
  to off (the settings menu's "Check for updates").
487
487
 
@@ -897,7 +897,7 @@ bookkeeping.
897
897
  | `parked/<sha8>/` | items parked by `--project` (`<sha8>` = first 8 hex of the SHA-1 of the resolved project dir) |
898
898
  | `profiles/` | `<name>.json` profiles (dir `0700`, files `0600`) |
899
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) |
900
+ | `update-check.json` | the [update check](#update-check)'s skipped version (mode `0600`, atomic write) |
901
901
 
902
902
  A killed run loses at most the one item in flight. If that was a flag write or a
903
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.16.1"
3
+ __version__ = "0.16.2"
@@ -0,0 +1,209 @@
1
+ """Update check on top of update-kit, plus the "skip this version" memory.
2
+
3
+ from agent_toggle.update_check import check
4
+ found = check("1.0.0", cache_path=Path.home() / ".agent-toggle" / "update-check.json")
5
+ if found:
6
+ print(f"{found.latest} is available (you have {found.current})")
7
+
8
+ The lookup itself -- ``git ls-remote`` of the release tags, the 10-minute cache in
9
+ ``~/.update-kit/state.json``, the timeout -- is :func:`update_kit.check`; ``latest`` is
10
+ the tag as it returns it (``v1.2.0``). Only the skipped version lives in *cache_path*.
11
+
12
+ To overlap the lookup with the command's own work instead of paying for
13
+ it at exit, start it early and collect the result at the end:
14
+
15
+ started = start("1.0.0", cache_path=...)
16
+ ... # the command's own work
17
+ found = collect(started)
18
+
19
+ To ask the user what to do instead of only hinting, hand :func:`offer` the
20
+ UI as a callback -- it owns what each answer does, the UI only picks one:
21
+
22
+ offer(started, ask, cache_path=..., upgrade=["uv", "tool", "upgrade", "agent-toggle"])
23
+
24
+ where ``ask(found)`` returns ``UPDATE_NOW``, ``SKIP`` or ``SKIP_VERSION``.
25
+ When the upgrade depends on the version or takes several steps, pass a
26
+ callable instead: ``upgrade=lambda found: [[...], [...]]`` runs each command
27
+ in order and stops at the first one that fails.
28
+ ``SKIP_VERSION`` is remembered in *cache_path*; a newer release asks again.
29
+
30
+ Nothing here is allowed to raise to the caller: a missing cache, a bad
31
+ payload, or a dead network all become ``None``.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import json
37
+ import os
38
+ import re
39
+ import subprocess
40
+ import threading
41
+ from collections.abc import Callable, Sequence
42
+ from dataclasses import dataclass, field
43
+ from pathlib import Path
44
+
45
+ __all__ = [
46
+ "DEFAULT_TIMEOUT",
47
+ "REPO",
48
+ "SKIP",
49
+ "SKIP_VERSION",
50
+ "UPDATE_NOW",
51
+ "UPGRADE_FAILED",
52
+ "Started",
53
+ "UpdateAvailable",
54
+ "check",
55
+ "collect",
56
+ "offer",
57
+ "skip",
58
+ "start",
59
+ ]
60
+
61
+ REPO = "weskao/agent-toggle"
62
+ #: How long :func:`collect` waits for the background lookup before giving up on it.
63
+ DEFAULT_TIMEOUT = 0.8
64
+
65
+ #: Answers an ``ask`` callback returns to :func:`offer`.
66
+ UPDATE_NOW = "update-now"
67
+ SKIP = "skip"
68
+ SKIP_VERSION = "skip-version"
69
+ #: What :func:`offer` returns when UPDATE_NOW's command could not run or failed.
70
+ UPGRADE_FAILED = "upgrade-failed"
71
+
72
+ #: What a skipped version may look like before it is trusted: it is read from a file.
73
+ _VERSION_RE = re.compile(r"v?\d{1,6}(\.\d{1,6}){1,3}([a-zA-Z0-9.+-]{0,20})?")
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class UpdateAvailable:
78
+ current: str
79
+ latest: str
80
+
81
+
82
+ def check(current: str, *, cache_path: Path) -> UpdateAvailable | None:
83
+ """Return an update when the latest release is newer than *current* and not the
84
+ version skipped in *cache_path*, else None. Never raises."""
85
+ try:
86
+ import update_kit # lazy: a checkout run or a stale env lacks it
87
+
88
+ latest = update_kit.check(repo=REPO, current=current)
89
+ if not isinstance(latest, str) or _bare(latest) == _bare(_load_cache(cache_path).get("skipped")):
90
+ return None
91
+ return UpdateAvailable(current=current, latest=latest)
92
+ except Exception: # noqa: BLE001 - a hint must never fail the command
93
+ return None
94
+
95
+
96
+ @dataclass
97
+ class Started:
98
+ """A background check in flight; pass to :func:`collect` to get its result."""
99
+
100
+ thread: threading.Thread
101
+ _result: list = field(default_factory=list)
102
+
103
+
104
+ def start(current: str, *, cache_path: Path) -> Started:
105
+ """Run :func:`check` on a daemon thread so it overlaps the caller's own work."""
106
+ result: list = []
107
+
108
+ def run() -> None:
109
+ result.append(check(current, cache_path=cache_path))
110
+
111
+ thread = threading.Thread(target=run, name="agent-toggle-update-check", daemon=True)
112
+ thread.start()
113
+ return Started(thread=thread, _result=result)
114
+
115
+
116
+ def collect(started: Started | None, *, timeout: float = DEFAULT_TIMEOUT) -> UpdateAvailable | None:
117
+ """Wait for a :func:`start`-ed check and return its result. Never raises."""
118
+ if started is None:
119
+ return None
120
+ try:
121
+ # Usually already done: it ran alongside the caller's own work.
122
+ started.thread.join(timeout)
123
+ return started._result[0] if started._result else None
124
+ except Exception: # noqa: BLE001 - a hint must never fail the command
125
+ return None
126
+
127
+
128
+ def skip(cache_path: Path, version: str) -> None:
129
+ """Stop reporting *version*; a newer release is reported again. Never raises."""
130
+ data = _load_cache(cache_path)
131
+ data["skipped"] = version
132
+ _write_cache(cache_path, data)
133
+
134
+
135
+ def offer(
136
+ started: Started | None,
137
+ ask: Callable[[UpdateAvailable], str],
138
+ *,
139
+ cache_path: Path,
140
+ upgrade: Sequence[str] | Callable[[UpdateAvailable], Sequence[Sequence[str]]],
141
+ run: Callable[..., object] = subprocess.run,
142
+ ) -> str | None:
143
+ """Collect *started*, let *ask* pick an answer, then act on it.
144
+
145
+ *upgrade* is one command, or a callable building the commands from the
146
+ found release, run in order. Returns the answer acted on
147
+ (``UPGRADE_FAILED`` when a command is missing or exits nonzero — the
148
+ rest are then skipped), or None when there was nothing to offer. Ctrl+C
149
+ inside *ask* is ``SKIP``. Never raises.
150
+ """
151
+ try:
152
+ found = collect(started)
153
+ if found is None:
154
+ return None
155
+ try:
156
+ answer = ask(found)
157
+ except KeyboardInterrupt:
158
+ return SKIP
159
+ if answer == SKIP_VERSION:
160
+ skip(cache_path, found.latest)
161
+ elif answer == UPDATE_NOW:
162
+ steps = upgrade(found) if callable(upgrade) else [upgrade]
163
+ for step in steps:
164
+ try:
165
+ done = run(list(step), check=False)
166
+ except OSError:
167
+ return UPGRADE_FAILED
168
+ if getattr(done, "returncode", 1) != 0:
169
+ return UPGRADE_FAILED
170
+ return answer
171
+ except Exception: # noqa: BLE001 - an update offer must never fail the command
172
+ return None
173
+
174
+
175
+ def _bare(version: object) -> str | None:
176
+ """*version* without a leading ``v`` (an old cache holds PyPI's ``0.10.0``, now tags)."""
177
+ return version.removeprefix("v") if isinstance(version, str) else None
178
+
179
+
180
+ def _load_cache(path: Path) -> dict:
181
+ try:
182
+ data = json.loads(path.read_text(encoding="utf-8"))
183
+ except (OSError, ValueError, RecursionError): # RecursionError: a deeply nested file
184
+ return {}
185
+ if not isinstance(data, dict):
186
+ return {}
187
+ # Only a valid skipped version survives: a hostile file can't smuggle anything
188
+ # (e.g. a 100k-deep dict that json.dumps can't write back), and old
189
+ # checked_at/latest keys are dropped on the next write.
190
+ skipped = data.get("skipped")
191
+ return {"skipped": skipped} if isinstance(skipped, str) and _VERSION_RE.fullmatch(skipped) else {}
192
+
193
+
194
+ def _write_cache(path: Path, data: dict) -> None:
195
+ # tmp + os.replace never follows a symlink at *path*; 0600 from creation.
196
+ tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
197
+ try:
198
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
199
+ tmp.unlink(missing_ok=True)
200
+ fd = os.open(tmp, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
201
+ try:
202
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
203
+ fh.write(json.dumps(data) + "\n")
204
+ os.replace(tmp, path)
205
+ except BaseException:
206
+ tmp.unlink(missing_ok=True)
207
+ raise
208
+ except (OSError, RecursionError, ValueError):
209
+ return
@@ -28,7 +28,7 @@ from .output import use_color
28
28
  from .ui import theme
29
29
 
30
30
  DIST = "agent-toggle"
31
- REPO = "weskao/agent-toggle"
31
+ REPO = update_check.REPO
32
32
  PLUGIN = "agent-toggle@agent-toggle"
33
33
  UPDATE = [DIST, "update"]
34
34
  RELEASE_NOTES = "https://github.com/weskao/agent-toggle/releases/tag/v%s"
@@ -93,12 +93,12 @@ def cache_path() -> Path:
93
93
 
94
94
 
95
95
  def start() -> update_check.Started | None:
96
- """Kick off the PyPI check on a daemon thread, unless the `update_check` setting
97
- (env AGENT_TOGGLE_UPDATE_CHECK wins) is off. Never raises."""
96
+ """Kick off the release check (update_kit) on a daemon thread, unless the `update_check`
97
+ setting (env AGENT_TOGGLE_UPDATE_CHECK wins) is off. Never raises."""
98
98
  try:
99
99
  if not settings.get("update_check"):
100
100
  return None
101
- return update_check.start(DIST, __version__, cache_path=cache_path())
101
+ return update_check.start(__version__, cache_path=cache_path())
102
102
  except Exception: # noqa: BLE001 - a hint must never fail the command
103
103
  return None
104
104
 
@@ -112,7 +112,7 @@ def is_interactive() -> bool:
112
112
 
113
113
  def _available(found: update_check.UpdateAvailable) -> str:
114
114
  return t("update.available", "agent-toggle %s is available (you have %s)",
115
- _clean(found.latest), _clean(found.current))
115
+ _clean(found.latest).removeprefix("v"), _clean(found.current))
116
116
 
117
117
 
118
118
  def hint(found: update_check.UpdateAvailable) -> str:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-toggle
3
- Version: 0.16.1
3
+ Version: 0.16.2
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
@@ -498,7 +498,7 @@ invalid value in either is ignored with one warning on stderr.
498
498
 
499
499
  ## Update check
500
500
 
501
- On every invocation (unless disabled) agent-toggle checks PyPI for a newer release. It starts
501
+ On every invocation (unless disabled) agent-toggle checks the repository's release tags for a newer release. It starts
502
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
503
  `update` and `completion` skip it.
504
504
 
@@ -513,9 +513,9 @@ before the command and is offered after it, on every exit path; it overlaps the
513
513
  then ` agent-toggle update`.
514
514
  - It never changes the exit code or stdout, so `--json` output stays one document.
515
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`
516
+ The lookup is update-kit's: one `git ls-remote --tags --refs` of the repository on a background
517
+ thread, cached for 10 minutes in `~/.update-kit/state.json`. Only the version you chose to skip is
518
+ kept in `~/.agent-toggle/update-check.json`. Nothing is sent beyond a normal git request. See `SECURITY.md`
519
519
  for the hardening. To turn it off, set `AGENT_TOGGLE_UPDATE_CHECK=0` or the `update_check` setting
520
520
  to off (the settings menu's "Check for updates").
521
521
 
@@ -931,7 +931,7 @@ bookkeeping.
931
931
  | `parked/<sha8>/` | items parked by `--project` (`<sha8>` = first 8 hex of the SHA-1 of the resolved project dir) |
932
932
  | `profiles/` | `<name>.json` profiles (dir `0700`, files `0600`) |
933
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) |
934
+ | `update-check.json` | the [update check](#update-check)'s skipped version (mode `0600`, atomic write) |
935
935
 
936
936
  A killed run loses at most the one item in flight. If that was a flag write or a
937
937
  dir move (with its companions), its `pending` record (the full entry, a flag's
@@ -159,13 +159,13 @@ class SandboxInvariantTest(SandboxCase):
159
159
  self.assertEqual(Path(agent_toggle.__file__).resolve().parent, REPO / "agent_toggle")
160
160
 
161
161
 
162
- PYPI_99 = json.dumps({"info": {"version": "99.0.0"}})
162
+ TAG_99 = "v99.0.0" # update_kit.check returns the release tag
163
163
  HINT = f"agent-toggle 99.0.0 is available (you have {__version__})"
164
164
 
165
165
 
166
166
  class UpdateCheckTest(CliCase):
167
167
  """The update check: started before dispatch, offered in main()'s finally; and `update` /
168
- `--update`. No network: fetch_pypi and update_kit.update are patched;
168
+ `--update`. No network: update_kit.check and update_kit.update are patched;
169
169
  AGENT_TOGGLE_UPDATE_CHECK=0 (tests/base.py) is lifted here only."""
170
170
 
171
171
  def setUp(self) -> None:
@@ -174,9 +174,9 @@ class UpdateCheckTest(CliCase):
174
174
  env.start()
175
175
  self.addCleanup(env.stop)
176
176
  os.environ.pop("AGENT_TOGGLE_UPDATE_CHECK", None)
177
- for target, kw in ((update_check, {"fetch_pypi": mock.Mock(return_value=PYPI_99)}),
178
- (update_prompt, {"is_interactive": mock.Mock(return_value=False)}),
179
- (update_kit, {"update": mock.Mock(return_value=0)}), # never install
177
+ for target, kw in ((update_prompt, {"is_interactive": mock.Mock(return_value=False)}),
178
+ (update_kit, {"check": mock.Mock(return_value=TAG_99),
179
+ "update": mock.Mock(return_value=0)}), # never install
180
180
  (fs, {"WIN": False})): # the panel/upgrade flow; Windows has its own test
181
181
  patch = mock.patch.multiple(target, **kw)
182
182
  patch.start()
@@ -193,7 +193,7 @@ class UpdateCheckTest(CliCase):
193
193
  self.assertNotIn("99.0.0", self.run_cli("status")[2])
194
194
  settings.set("update_check", False)
195
195
  self.assertNotIn("99.0.0", self.run_cli("status")[2])
196
- update_check.fetch_pypi.assert_not_called()
196
+ update_kit.check.assert_not_called()
197
197
 
198
198
  def test_json_stdout_is_one_document_and_never_prompts(self) -> None:
199
199
  update_prompt.is_interactive.return_value = True
@@ -291,7 +291,7 @@ class UpdateCheckTest(CliCase):
291
291
  self.assertNotIn("99.0.0", self.run_cli("update")[2])
292
292
  self.assertNotIn("99.0.0", self.run_cli("completion", "bash")[2])
293
293
  ask.assert_not_called()
294
- update_check.fetch_pypi.assert_not_called()
294
+ update_kit.check.assert_not_called()
295
295
 
296
296
  def test_update_and_dash_dash_update_exit_with_update_kits_code(self) -> None:
297
297
  for argv in (("update",), ("--update",), ("--json", "--update")):
@@ -0,0 +1,240 @@
1
+ """Update hint on top of update_kit.check.
2
+
3
+ Never hits the network: every test mocks ``update_kit.check`` (which returns the newer
4
+ release tag, or None) and injects ``run``. The lookup, its TTL and its cache are
5
+ update-kit's; what these cover is the skip-version memory, the background start/collect
6
+ and the offer answers.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import shutil
13
+ import tempfile
14
+ import threading
15
+ import time
16
+ import unittest
17
+ from pathlib import Path
18
+ from unittest import mock
19
+
20
+ import update_kit
21
+
22
+ from agent_toggle import update_check
23
+
24
+
25
+ class _Done:
26
+ def __init__(self, returncode: int) -> None:
27
+ self.returncode = returncode
28
+
29
+
30
+ class UpdateCheckCase(unittest.TestCase):
31
+ def setUp(self) -> None:
32
+ self.tmp = Path(tempfile.mkdtemp(prefix="agent-toggle-update-"))
33
+ self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True)
34
+ self.cache = self.tmp / "update-check.json"
35
+
36
+ def kit(self, latest="v0.10.0", **kw):
37
+ """Patch update_kit.check to answer *latest* (or raise via side_effect=...)."""
38
+ patch = mock.patch.object(update_kit, "check", return_value=latest, **kw)
39
+ self.addCleanup(patch.stop)
40
+ return patch.start()
41
+
42
+ def check(self, latest="v0.10.0", current="0.9.1", **kw):
43
+ self.kit(latest, **kw)
44
+ return update_check.check(current, cache_path=self.cache)
45
+
46
+ def seed(self, **data) -> None:
47
+ self.cache.write_text(json.dumps(data), encoding="utf-8")
48
+
49
+
50
+ class CheckTest(UpdateCheckCase):
51
+ def test_a_newer_tag_is_reported_as_update_kit_returns_it(self):
52
+ found = self.check("v0.10.0")
53
+ self.assertEqual(found, update_check.UpdateAvailable(current="0.9.1", latest="v0.10.0"))
54
+
55
+ def test_the_lookup_is_update_kits_for_this_repo(self):
56
+ kit = self.kit()
57
+ update_check.check("0.9.1", cache_path=self.cache)
58
+ kit.assert_called_once_with(repo="weskao/agent-toggle", current="0.9.1")
59
+
60
+ def test_nothing_newer_stays_silent(self):
61
+ self.assertIsNone(self.check(None))
62
+
63
+ def test_a_failing_lookup_stays_silent(self):
64
+ self.assertIsNone(self.check(side_effect=OSError("offline")))
65
+
66
+ def test_a_missing_update_kit_stays_silent(self):
67
+ with mock.patch.dict("sys.modules", {"update_kit": None}):
68
+ self.assertIsNone(update_check.check("0.9.1", cache_path=self.cache))
69
+
70
+
71
+ class StartCollectTest(UpdateCheckCase):
72
+ def start(self, latest="v0.10.0", **kw):
73
+ self.kit(latest, **kw)
74
+ return update_check.start("0.9.1", cache_path=self.cache)
75
+
76
+ def test_start_and_collect_finds_an_update(self):
77
+ self.assertEqual(update_check.collect(self.start()).latest, "v0.10.0")
78
+
79
+ def test_start_and_collect_stays_silent_when_current(self):
80
+ self.assertIsNone(update_check.collect(self.start(None)))
81
+
82
+ def test_start_overlaps_with_the_callers_own_work(self):
83
+ release = threading.Event()
84
+
85
+ def slow(**_kw):
86
+ release.wait(timeout=1)
87
+ return "v0.10.0"
88
+
89
+ started = self.start(side_effect=slow)
90
+ time.sleep(0.05)
91
+ self.assertTrue(started.thread.is_alive())
92
+ release.set()
93
+ self.assertEqual(update_check.collect(started).latest, "v0.10.0")
94
+
95
+ def test_collect_with_no_started_check_is_silent(self):
96
+ self.assertIsNone(update_check.collect(None))
97
+
98
+
99
+ class SkipTest(UpdateCheckCase):
100
+ def test_a_skipped_version_stays_silent(self):
101
+ update_check.skip(self.cache, "v0.10.0")
102
+ self.assertIsNone(self.check("v0.10.0"))
103
+
104
+ def test_a_skip_made_before_tags_still_matches(self):
105
+ self.seed(skipped="0.10.0") # what the PyPI-era check stored
106
+ self.assertIsNone(self.check("v0.10.0"))
107
+
108
+ def test_a_release_newer_than_the_skipped_one_is_reported(self):
109
+ update_check.skip(self.cache, "v0.10.0")
110
+ self.assertEqual(self.check("v0.11.0").latest, "v0.11.0")
111
+
112
+ def test_skip_keeps_only_the_skipped_version(self):
113
+ self.seed(checked_at=5, latest="0.9.2", extra={"a": [1]})
114
+ update_check.skip(self.cache, "v0.10.0")
115
+ self.assertEqual(json.loads(self.cache.read_text(encoding="utf-8")), {"skipped": "v0.10.0"})
116
+
117
+
118
+ class SecurityTest(UpdateCheckCase):
119
+ def test_a_hostile_skipped_value_is_ignored(self):
120
+ self.seed(skipped="evil\x1b[2J", latest=["1.2.3"])
121
+ self.assertEqual(update_check._load_cache(self.cache), {})
122
+
123
+ def test_the_prompt_prints_no_escape_for_a_hostile_version(self):
124
+ import io
125
+ from contextlib import redirect_stderr
126
+
127
+ from agent_toggle import update_prompt
128
+ buf = io.StringIO()
129
+ with redirect_stderr(buf):
130
+ update_prompt.hint(update_check.UpdateAvailable(current="1.0", latest="9.9\x1b]52;c;x\x07"))
131
+ self.assertNotIn("\x1b", buf.getvalue())
132
+
133
+ def test_the_prompt_shows_the_tag_without_its_v(self):
134
+ from agent_toggle import update_prompt
135
+ text = update_prompt._available(update_check.UpdateAvailable(current="1.0", latest="v2.0.0"))
136
+ self.assertEqual(text, "agent-toggle 2.0.0 is available (you have 1.0)")
137
+
138
+ def test_a_deeply_nested_cache_is_corrupt_and_rewritten_small(self):
139
+ depth = 100_000 # parses on 3.14; json.dumps of the merged data used to raise
140
+ self.cache.write_text('{"a":' * depth + "1" + "}" * depth, encoding="utf-8")
141
+ update_check.skip(self.cache, "v0.10.0")
142
+ self.assertEqual(json.loads(self.cache.read_text(encoding="utf-8")), {"skipped": "v0.10.0"})
143
+
144
+ def test_the_cache_write_replaces_a_symlink_and_is_0600(self):
145
+ target = self.tmp / "victim"
146
+ target.write_text("keep", encoding="utf-8")
147
+ self.cache.symlink_to(target)
148
+ update_check.skip(self.cache, "v0.10.0")
149
+ self.assertFalse(self.cache.is_symlink())
150
+ self.assertEqual(target.read_text(encoding="utf-8"), "keep")
151
+ if os.name != "nt": # Windows has no POSIX modes
152
+ self.assertEqual(self.cache.stat().st_mode & 0o777, 0o600)
153
+
154
+
155
+ class OfferTest(UpdateCheckCase):
156
+ def offer(self, answer, *, latest="v0.10.0", run=None, upgrade=None):
157
+ asked: list = []
158
+
159
+ def ask(found):
160
+ asked.append(found)
161
+ if isinstance(answer, BaseException):
162
+ raise answer
163
+ return answer
164
+
165
+ self.kit(latest)
166
+ started = update_check.start("0.9.1", cache_path=self.cache)
167
+ result = update_check.offer(
168
+ started, ask, cache_path=self.cache,
169
+ upgrade=upgrade or ["uv", "tool", "upgrade", "agent-toggle"],
170
+ run=run or (lambda *_a, **_k: self.fail("upgrade must not run")))
171
+ return result, asked
172
+
173
+ def test_offer_does_not_ask_when_nothing_is_newer(self):
174
+ result, asked = self.offer(update_check.UPDATE_NOW, latest=None)
175
+ self.assertIsNone(result)
176
+ self.assertEqual(asked, [])
177
+
178
+ def test_offer_asks_with_the_found_release(self):
179
+ _result, asked = self.offer(update_check.SKIP)
180
+ self.assertEqual(asked, [update_check.UpdateAvailable(current="0.9.1", latest="v0.10.0")])
181
+
182
+ def test_offer_update_now_runs_the_upgrade_command(self):
183
+ ran: list = []
184
+ result, _ = self.offer(update_check.UPDATE_NOW,
185
+ run=lambda cmd, **_k: ran.append(cmd) or _Done(0))
186
+ self.assertEqual(result, update_check.UPDATE_NOW)
187
+ self.assertEqual(ran, [["uv", "tool", "upgrade", "agent-toggle"]])
188
+
189
+ def test_offer_reports_an_upgrade_that_exits_nonzero(self):
190
+ result, _ = self.offer(update_check.UPDATE_NOW, run=lambda *_a, **_k: _Done(2))
191
+ self.assertEqual(result, update_check.UPGRADE_FAILED)
192
+
193
+ def test_offer_reports_a_missing_upgrade_tool(self):
194
+ def missing(*_a, **_k):
195
+ raise FileNotFoundError("uv")
196
+
197
+ result, _ = self.offer(update_check.UPDATE_NOW, run=missing)
198
+ self.assertEqual(result, update_check.UPGRADE_FAILED)
199
+
200
+ def test_offer_skip_leaves_the_next_run_asking(self):
201
+ result, _ = self.offer(update_check.SKIP)
202
+ self.assertEqual(result, update_check.SKIP)
203
+ self.assertIsNotNone(self.check("v0.10.0"))
204
+
205
+ def test_offer_skip_version_silences_that_version_until_a_newer_one(self):
206
+ result, _ = self.offer(update_check.SKIP_VERSION)
207
+ self.assertEqual(result, update_check.SKIP_VERSION)
208
+ self.assertIsNone(self.check("v0.10.0"))
209
+ self.assertEqual(self.check("v0.11.0").latest, "v0.11.0")
210
+
211
+ def test_offer_never_raises_when_the_ui_does(self):
212
+ result, _ = self.offer(RuntimeError("broken terminal"))
213
+ self.assertIsNone(result)
214
+
215
+ def test_offer_reads_ctrl_c_in_the_ui_as_skip(self):
216
+ result, _ = self.offer(KeyboardInterrupt())
217
+ self.assertEqual(result, update_check.SKIP)
218
+
219
+ def steps(self, returncodes):
220
+ ran: list = []
221
+ codes = iter(returncodes)
222
+ result, _ = self.offer(
223
+ update_check.UPDATE_NOW,
224
+ upgrade=lambda found: [["install", found.latest], ["reload"]],
225
+ run=lambda cmd, **_k: ran.append(cmd) or _Done(next(codes)))
226
+ return result, ran
227
+
228
+ def test_offer_builds_upgrade_steps_from_the_found_release(self):
229
+ result, ran = self.steps([0, 0])
230
+ self.assertEqual(result, update_check.UPDATE_NOW)
231
+ self.assertEqual(ran, [["install", "v0.10.0"], ["reload"]])
232
+
233
+ def test_offer_stops_at_the_first_failed_step(self):
234
+ result, ran = self.steps([1])
235
+ self.assertEqual(result, update_check.UPGRADE_FAILED)
236
+ self.assertEqual(ran, [["install", "v0.10.0"]])
237
+
238
+
239
+ if __name__ == "__main__":
240
+ unittest.main()