agent-toggle 0.16.0__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.0 → agent_toggle-0.16.2}/PKG-INFO +7 -7
  2. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/README.md +5 -5
  3. {agent_toggle-0.16.0 → 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.0 → agent_toggle-0.16.2}/agent_toggle/update_prompt.py +5 -5
  6. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle.egg-info/PKG-INFO +7 -7
  7. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle.egg-info/requires.txt +1 -1
  8. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/pyproject.toml +1 -1
  9. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/tests/test_cli.py +7 -7
  10. agent_toggle-0.16.2/tests/test_update_check.py +240 -0
  11. agent_toggle-0.16.0/agent_toggle/update_check.py +0 -361
  12. agent_toggle-0.16.0/tests/test_update_check.py +0 -423
  13. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/LICENSE +0 -0
  14. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle/__main__.py +0 -0
  15. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle/backends/__init__.py +0 -0
  16. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle/backends/flag_json.py +0 -0
  17. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle/backends/mcp_json.py +0 -0
  18. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle/backends/mcp_toml.py +0 -0
  19. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle/backends/plugin_cli.py +0 -0
  20. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle/cli.py +0 -0
  21. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle/companions.py +0 -0
  22. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle/completions.py +0 -0
  23. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle/config.py +0 -0
  24. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle/cost.py +0 -0
  25. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle/doctor.py +0 -0
  26. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle/fs.py +0 -0
  27. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle/harnesses.py +0 -0
  28. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle/help.py +0 -0
  29. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle/i18n.py +0 -0
  30. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle/locales/__init__.py +0 -0
  31. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle/locales/zh_tw_cli.py +0 -0
  32. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle/locales/zh_tw_common.py +0 -0
  33. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle/locales/zh_tw_config.py +0 -0
  34. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle/locales/zh_tw_picker.py +0 -0
  35. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle/mechanisms.py +0 -0
  36. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle/ops.py +0 -0
  37. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle/output.py +0 -0
  38. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle/profiles.py +0 -0
  39. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle/settings.py +0 -0
  40. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle/shims/claude.md.tmpl +0 -0
  41. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle/shims/generic.md.tmpl +0 -0
  42. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle/spinner.py +0 -0
  43. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle/store.py +0 -0
  44. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle/toml_check.py +0 -0
  45. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle/ui/__init__.py +0 -0
  46. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle/ui/config_menu.py +0 -0
  47. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle/ui/logo.py +0 -0
  48. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle/ui/menu.py +0 -0
  49. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle/ui/model.py +0 -0
  50. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle/ui/picker.py +0 -0
  51. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle/ui/theme.py +0 -0
  52. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle/undo.py +0 -0
  53. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle.egg-info/SOURCES.txt +0 -0
  54. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle.egg-info/dependency_links.txt +0 -0
  55. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle.egg-info/entry_points.txt +0 -0
  56. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/agent_toggle.egg-info/top_level.txt +0 -0
  57. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/setup.cfg +0 -0
  58. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/tests/test_alias.py +0 -0
  59. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/tests/test_all_type.py +0 -0
  60. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/tests/test_backends.py +0 -0
  61. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/tests/test_cli_surface.py +0 -0
  62. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/tests/test_color.py +0 -0
  63. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/tests/test_companions.py +0 -0
  64. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/tests/test_completions.py +0 -0
  65. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/tests/test_config.py +0 -0
  66. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/tests/test_config_menu.py +0 -0
  67. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/tests/test_conformance.py +0 -0
  68. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/tests/test_containment.py +0 -0
  69. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/tests/test_cost.py +0 -0
  70. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/tests/test_crash.py +0 -0
  71. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/tests/test_doctor.py +0 -0
  72. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/tests/test_encoding.py +0 -0
  73. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/tests/test_fs.py +0 -0
  74. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/tests/test_harnesses.py +0 -0
  75. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/tests/test_help.py +0 -0
  76. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/tests/test_i18n.py +0 -0
  77. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/tests/test_install.py +0 -0
  78. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/tests/test_logo.py +0 -0
  79. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/tests/test_mechanisms.py +0 -0
  80. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/tests/test_menu.py +0 -0
  81. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/tests/test_mods.py +0 -0
  82. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/tests/test_picker.py +0 -0
  83. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/tests/test_platform.py +0 -0
  84. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/tests/test_profiles.py +0 -0
  85. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/tests/test_project.py +0 -0
  86. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/tests/test_safety.py +0 -0
  87. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/tests/test_same_name.py +0 -0
  88. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/tests/test_secret_audit.py +0 -0
  89. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/tests/test_settings.py +0 -0
  90. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/tests/test_store.py +0 -0
  91. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/tests/test_theme.py +0 -0
  92. {agent_toggle-0.16.0 → agent_toggle-0.16.2}/tests/test_undo.py +0 -0
  93. {agent_toggle-0.16.0 → 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.0
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
@@ -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.3,>=0.2.1
28
+ Requires-Dist: update-kit<0.3,>=0.2.2
29
29
  Provides-Extra: windows
30
30
  Requires-Dist: windows-curses; sys_platform == "win32" and extra == "windows"
31
31
  Provides-Extra: telegram
@@ -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.0"
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.0
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
@@ -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.3,>=0.2.1
28
+ Requires-Dist: update-kit<0.3,>=0.2.2
29
29
  Provides-Extra: windows
30
30
  Requires-Dist: windows-curses; sys_platform == "win32" and extra == "windows"
31
31
  Provides-Extra: telegram
@@ -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
@@ -1,5 +1,5 @@
1
1
  telegram-kit<0.3,>=0.2.2
2
- update-kit<0.3,>=0.2.1
2
+ update-kit<0.3,>=0.2.2
3
3
 
4
4
  [telegram]
5
5
  telegram-kit<0.3,>=0.2.2
@@ -27,7 +27,7 @@ classifiers = [
27
27
  ]
28
28
  requires-python = ">=3.10"
29
29
  # telegram-kit is core (stdlib-only) so a plain reinstall can't drop the Telegram settings
30
- dependencies = ["telegram-kit>=0.2.2,<0.3", "update-kit>=0.2.1,<0.3"]
30
+ dependencies = ["telegram-kit>=0.2.2,<0.3", "update-kit>=0.2.2,<0.3"]
31
31
 
32
32
  [project.urls]
33
33
  Homepage = "https://github.com/weskao/agent-toggle"
@@ -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")):