agent-toggle 0.5.2__tar.gz → 0.6.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.
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/PKG-INFO +8 -6
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/README.md +7 -5
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle/__init__.py +1 -1
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle/config.py +2 -1
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle/cost.py +20 -8
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle/locales/zh_tw_cli.py +3 -3
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle/locales/zh_tw_config.py +3 -2
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle/locales/zh_tw_picker.py +2 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle/profiles.py +4 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle/ui/config_menu.py +7 -5
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle/ui/model.py +11 -5
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle/ui/picker.py +47 -6
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle/update_prompt.py +3 -3
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle.egg-info/PKG-INFO +8 -6
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/tests/test_alias.py +3 -2
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/tests/test_cli.py +1 -1
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/tests/test_cli_surface.py +1 -1
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/tests/test_config.py +10 -2
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/tests/test_config_menu.py +22 -5
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/tests/test_cost.py +3 -2
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/tests/test_crash.py +4 -1
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/tests/test_doctor.py +1 -1
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/tests/test_install.py +1 -1
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/tests/test_picker.py +62 -6
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/tests/test_project.py +3 -3
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/tests/test_theme.py +1 -1
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/tests/test_update_check.py +3 -1
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/LICENSE +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle/__main__.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle/backends/__init__.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle/backends/flag_json.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle/backends/mcp_json.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle/backends/mcp_toml.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle/backends/plugin_cli.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle/cli.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle/companions.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle/doctor.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle/fs.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle/harnesses.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle/help.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle/i18n.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle/locales/__init__.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle/locales/zh_tw_common.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle/mechanisms.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle/ops.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle/output.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle/settings.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle/shims/claude.md.tmpl +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle/shims/generic.md.tmpl +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle/spinner.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle/store.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle/toml_check.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle/ui/__init__.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle/ui/menu.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle/ui/theme.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle/undo.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle/update_check.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle.egg-info/SOURCES.txt +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle.egg-info/dependency_links.txt +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle.egg-info/entry_points.txt +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle.egg-info/requires.txt +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/agent_toggle.egg-info/top_level.txt +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/pyproject.toml +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/setup.cfg +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/tests/test_backends.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/tests/test_color.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/tests/test_companions.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/tests/test_completions.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/tests/test_conformance.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/tests/test_containment.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/tests/test_encoding.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/tests/test_fs.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/tests/test_harnesses.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/tests/test_help.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/tests/test_i18n.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/tests/test_mechanisms.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/tests/test_menu.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/tests/test_platform.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/tests/test_profiles.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/tests/test_safety.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/tests/test_secret_audit.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/tests/test_settings.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/tests/test_store.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.2}/tests/test_undo.py +0 -0
- {agent_toggle-0.5.2 → agent_toggle-0.6.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.
|
|
3
|
+
Version: 0.6.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
|
|
@@ -159,7 +159,7 @@ agent-toggle <command> [args] # or: python3 agent_toggle.py <command> [
|
|
|
159
159
|
| `enable --all` | put back **every** disabled item (`--harness H` narrows it, `--project <dir>` takes only that project's) |
|
|
160
160
|
| `undo` | reverse the last logged batch (`--dry-run` shows the plan) |
|
|
161
161
|
| `profile save\|apply\|diff\|list` | named sets of live items; see [Profiles](#profiles) |
|
|
162
|
-
| `config` | settings menu (curses, else a numbered list); `config --json` lists every setting; see [Settings & config menu](#settings--config-menu) |
|
|
162
|
+
| `config` | settings menu (curses, else a numbered list); `config --json` lists every setting and the package version; see [Settings & config menu](#settings--config-menu) |
|
|
163
163
|
| `config test\|sync-ci` | send one Telegram test message / set the GitHub CI secrets; needs the `[telegram]` extra; see [CI notifications](#ci-notifications) |
|
|
164
164
|
| `help [command]` | styled help; `help ui` = `--help ui` = `ui --help`; see [Help](#help) |
|
|
165
165
|
| `doctor` | check each harness layout and `state.json` against disk; exit `1` only on an `error` row; on a terminal, asks y/n per fix it can run itself |
|
|
@@ -274,7 +274,8 @@ What is on screen:
|
|
|
274
274
|
- **Cost column**: estimated startup tokens (chars / 4, about +-25 %) with a colored bar. A parked
|
|
275
275
|
row shows `(N)` in brackets, what restoring it would load. Plugins appear as rows too (via
|
|
276
276
|
`claude plugin list --json`; skipped under `ui --dry-run`, which never shells out).
|
|
277
|
-
- **Detail pane** at 100 columns or wider:
|
|
277
|
+
- **Detail pane** at 100 columns or wider: the file's frontmatter `description` (skills,
|
|
278
|
+
agents, commands, rules), then harness, type, state, staged, path, cost, since,
|
|
278
279
|
mechanism, and what it is shared with.
|
|
279
280
|
- **Search bar** (🔍; plain `/` on terminals that cannot draw emoji: the Linux console, the legacy
|
|
280
281
|
Windows console, non-UTF-8 locales) under the tabs, always visible.
|
|
@@ -337,11 +338,12 @@ The chrome (title, tabs, footer, help, detail labels) is translated when `langua
|
|
|
337
338
|
|
|
338
339
|
```sh
|
|
339
340
|
agent-toggle config # or: agent-toggle --config
|
|
340
|
-
agent-toggle config --json # every setting with its value and source; the token is masked
|
|
341
|
+
agent-toggle config --json # every setting with its value and source, plus the package version; the token is masked
|
|
341
342
|
```
|
|
342
343
|
|
|
343
|
-
`config` opens the settings menu: curses on a terminal, a numbered list otherwise.
|
|
344
|
-
is saved the moment you make it. On a numbered list,
|
|
344
|
+
`config` opens the settings menu: curses on a terminal, a numbered list otherwise. The title
|
|
345
|
+
shows the package version. Each change is saved the moment you make it. On a numbered list,
|
|
346
|
+
end of input, `q` or an empty line exits `0`
|
|
345
347
|
(safe in CI; note it reads piped digits, so an open stdin pipe waits).
|
|
346
348
|
|
|
347
349
|
| group | rows |
|
|
@@ -127,7 +127,7 @@ agent-toggle <command> [args] # or: python3 agent_toggle.py <command> [
|
|
|
127
127
|
| `enable --all` | put back **every** disabled item (`--harness H` narrows it, `--project <dir>` takes only that project's) |
|
|
128
128
|
| `undo` | reverse the last logged batch (`--dry-run` shows the plan) |
|
|
129
129
|
| `profile save\|apply\|diff\|list` | named sets of live items; see [Profiles](#profiles) |
|
|
130
|
-
| `config` | settings menu (curses, else a numbered list); `config --json` lists every setting; see [Settings & config menu](#settings--config-menu) |
|
|
130
|
+
| `config` | settings menu (curses, else a numbered list); `config --json` lists every setting and the package version; see [Settings & config menu](#settings--config-menu) |
|
|
131
131
|
| `config test\|sync-ci` | send one Telegram test message / set the GitHub CI secrets; needs the `[telegram]` extra; see [CI notifications](#ci-notifications) |
|
|
132
132
|
| `help [command]` | styled help; `help ui` = `--help ui` = `ui --help`; see [Help](#help) |
|
|
133
133
|
| `doctor` | check each harness layout and `state.json` against disk; exit `1` only on an `error` row; on a terminal, asks y/n per fix it can run itself |
|
|
@@ -242,7 +242,8 @@ What is on screen:
|
|
|
242
242
|
- **Cost column**: estimated startup tokens (chars / 4, about +-25 %) with a colored bar. A parked
|
|
243
243
|
row shows `(N)` in brackets, what restoring it would load. Plugins appear as rows too (via
|
|
244
244
|
`claude plugin list --json`; skipped under `ui --dry-run`, which never shells out).
|
|
245
|
-
- **Detail pane** at 100 columns or wider:
|
|
245
|
+
- **Detail pane** at 100 columns or wider: the file's frontmatter `description` (skills,
|
|
246
|
+
agents, commands, rules), then harness, type, state, staged, path, cost, since,
|
|
246
247
|
mechanism, and what it is shared with.
|
|
247
248
|
- **Search bar** (🔍; plain `/` on terminals that cannot draw emoji: the Linux console, the legacy
|
|
248
249
|
Windows console, non-UTF-8 locales) under the tabs, always visible.
|
|
@@ -305,11 +306,12 @@ The chrome (title, tabs, footer, help, detail labels) is translated when `langua
|
|
|
305
306
|
|
|
306
307
|
```sh
|
|
307
308
|
agent-toggle config # or: agent-toggle --config
|
|
308
|
-
agent-toggle config --json # every setting with its value and source; the token is masked
|
|
309
|
+
agent-toggle config --json # every setting with its value and source, plus the package version; the token is masked
|
|
309
310
|
```
|
|
310
311
|
|
|
311
|
-
`config` opens the settings menu: curses on a terminal, a numbered list otherwise.
|
|
312
|
-
is saved the moment you make it. On a numbered list,
|
|
312
|
+
`config` opens the settings menu: curses on a terminal, a numbered list otherwise. The title
|
|
313
|
+
shows the package version. Each change is saved the moment you make it. On a numbered list,
|
|
314
|
+
end of input, `q` or an empty line exits `0`
|
|
313
315
|
(safe in CI; note it reads piped digits, so an open stdin pipe waits).
|
|
314
316
|
|
|
315
317
|
| group | rows |
|
|
@@ -13,7 +13,7 @@ from __future__ import annotations
|
|
|
13
13
|
import shutil
|
|
14
14
|
import subprocess
|
|
15
15
|
|
|
16
|
-
from . import settings
|
|
16
|
+
from . import __version__, settings
|
|
17
17
|
from .output import CliError, Result, die
|
|
18
18
|
|
|
19
19
|
SERVICE = "agent-toggle"
|
|
@@ -80,6 +80,7 @@ def show(tk, store, out: Result) -> None:
|
|
|
80
80
|
text = ("on" if value else "off") if isinstance(value, bool) else str(value)
|
|
81
81
|
out.row(None, "setting", key, "show", "ok", text, value=value,
|
|
82
82
|
source=settings.source(key))
|
|
83
|
+
out.row(None, "info", "version", "show", "ok", __version__, value=__version__)
|
|
83
84
|
|
|
84
85
|
|
|
85
86
|
def need(tk, store) -> tuple[str, str]:
|
|
@@ -70,6 +70,7 @@ class Item:
|
|
|
70
70
|
chars: int | None
|
|
71
71
|
basis: str
|
|
72
72
|
shared_with: tuple[str, ...] = ()
|
|
73
|
+
description: str = "" # frontmatter description, when the file has one
|
|
73
74
|
|
|
74
75
|
|
|
75
76
|
def tokens_of(chars: int) -> int:
|
|
@@ -102,24 +103,32 @@ def parse_frontmatter(head: str) -> dict[str, str]:
|
|
|
102
103
|
|
|
103
104
|
|
|
104
105
|
def file_estimate(type_: str, name: str, path: Path | None) -> Estimate:
|
|
105
|
-
"""skill / agent / command / rule from one file (a skill dir -> its SKILL.md).
|
|
106
|
+
"""skill / agent / command / rule from one file (a skill dir -> its SKILL.md).
|
|
107
|
+
|
|
108
|
+
Returns `(tokens, chars, basis, description)`. Other estimate helpers stay
|
|
109
|
+
3-tuples; `_fill` only reads the description when a 4th element is present.
|
|
110
|
+
"""
|
|
106
111
|
if path is None:
|
|
107
|
-
return 0, None, "file not found"
|
|
112
|
+
return 0, None, "file not found", ""
|
|
108
113
|
if type_ == "skill" and path.is_dir():
|
|
109
114
|
path = path / "SKILL.md"
|
|
110
115
|
try:
|
|
111
116
|
if type_ == "rule":
|
|
112
|
-
|
|
113
|
-
|
|
117
|
+
head = read_head(path, RULE_CAP)
|
|
118
|
+
chars = len(head)
|
|
119
|
+
return (tokens_of(chars), chars, f"whole file {chars} chars",
|
|
120
|
+
parse_frontmatter(head).get("description", ""))
|
|
114
121
|
fm = parse_frontmatter(read_head(path, HEAD_CAP))
|
|
115
122
|
except OSError as e:
|
|
116
|
-
return 0, None, f"unreadable: {e.strerror or e}"
|
|
123
|
+
return 0, None, f"unreadable: {e.strerror or e}", ""
|
|
117
124
|
vals = {f: fm.get(f, "") for f in FIELDS[type_]}
|
|
118
125
|
if not vals.get("name"):
|
|
119
126
|
vals["name"] = name
|
|
120
127
|
chars = sum(map(len, vals.values()))
|
|
121
128
|
parts = "+".join(f for f, v in vals.items() if v)
|
|
122
|
-
return tokens_of(chars), chars,
|
|
129
|
+
return (tokens_of(chars), chars,
|
|
130
|
+
f"{parts} {chars} chars" + ("" if fm else " (no frontmatter)"),
|
|
131
|
+
fm.get("description", ""))
|
|
123
132
|
|
|
124
133
|
|
|
125
134
|
def mcp_estimate(harness: str, tools: int | None) -> Estimate:
|
|
@@ -222,6 +231,8 @@ def plugin_estimate(root: Path) -> Estimate:
|
|
|
222
231
|
def _fill(item: Item, est: Estimate) -> None:
|
|
223
232
|
item.tokens, item.would_save = (est[0], 0) if item.enabled else (0, est[0])
|
|
224
233
|
item.chars, item.basis = est[1], est[2]
|
|
234
|
+
if len(est) > 3: # file rows carry the frontmatter description
|
|
235
|
+
item.description = est[3]
|
|
225
236
|
|
|
226
237
|
|
|
227
238
|
def inventory(state: dict, table: dict, warn: Callable[[str], None] = lambda m: None,
|
|
@@ -303,8 +314,9 @@ def inventory(state: dict, table: dict, warn: Callable[[str], None] = lambda m:
|
|
|
303
314
|
n = full_plugin_id(n, listed) or n # ambiguous: keep it as parked
|
|
304
315
|
else:
|
|
305
316
|
parked = e.get("parked_at")
|
|
306
|
-
|
|
307
|
-
|
|
317
|
+
|
|
318
|
+
def est(t=t, n=n, p=parked):
|
|
319
|
+
return file_estimate(t, n, Path(p) if isinstance(p, str) and p else None)
|
|
308
320
|
shared = e.get("shared_with")
|
|
309
321
|
add(h, t, n, False, est,
|
|
310
322
|
[x for x in shared if isinstance(x, str)] if isinstance(shared, list) else ())
|
|
@@ -72,9 +72,9 @@ CATALOG: dict[str, str] = {
|
|
|
72
72
|
"help.options": "選項",
|
|
73
73
|
# update prompt
|
|
74
74
|
"update.available": "agent-toggle %s 已推出(目前版本 %s)",
|
|
75
|
-
"update.keys": "↑↓ 選擇 · ⏎ 確認 · q 略過",
|
|
76
|
-
"update.keys_ascii": "上/下 選擇,Enter 確認,q 略過",
|
|
77
|
-
"update.choose": "請選擇 1-3(Enter = 1,q = 略過):",
|
|
75
|
+
"update.keys": "↑↓ 選擇 · ⏎ 確認 · q/Ctrl+C 略過",
|
|
76
|
+
"update.keys_ascii": "上/下 選擇,Enter 確認,q/Ctrl+C 略過",
|
|
77
|
+
"update.choose": "請選擇 1-3(Enter = 1,q/Ctrl+C = 略過):",
|
|
78
78
|
"update.now": "立即更新",
|
|
79
79
|
"update.skip": "略過",
|
|
80
80
|
"update.skip_detail": "下次執行再問",
|
|
@@ -46,7 +46,7 @@ CATALOG: dict[str, str] = {
|
|
|
46
46
|
"config.interrupted": "已中斷",
|
|
47
47
|
"config.key_change": "變更",
|
|
48
48
|
"config.key_move": "移動",
|
|
49
|
-
"config.key_quit": "離開",
|
|
49
|
+
"config.key_quit": "離開 · 自動儲存",
|
|
50
50
|
"config.key_reset": "重設",
|
|
51
51
|
"config.key_reset_all": "全部重設",
|
|
52
52
|
"config.key_run": "變更/執行",
|
|
@@ -66,7 +66,7 @@ CATALOG: dict[str, str] = {
|
|
|
66
66
|
"config.picker_harness": "預設工具篩選",
|
|
67
67
|
"config.picker_sort": "預設排序",
|
|
68
68
|
"config.picker_type": "預設類型篩選",
|
|
69
|
-
"config.prompt": "選擇要變更的設定(1-%s
|
|
69
|
+
"config.prompt": "選擇要變更的設定(1-%s):",
|
|
70
70
|
"config.prompt_chat_id": "Telegram 聊天 ID(Enter 保留,- 清除):",
|
|
71
71
|
"config.prompt_token": "Telegram 機器人權杖(隱藏輸入;Enter 保留,- 清除):",
|
|
72
72
|
"config.reset_all_done": "所有設定已重設為預設值",
|
|
@@ -75,6 +75,7 @@ CATALOG: dict[str, str] = {
|
|
|
75
75
|
"config.save_failed": "無法儲存:%s",
|
|
76
76
|
"config.saved": "已儲存",
|
|
77
77
|
"config.saves": "變更即時儲存",
|
|
78
|
+
"config.status.leave": "q/Ctrl+C 離開 · 自動儲存",
|
|
78
79
|
"config.send_test": "傳送測試訊息",
|
|
79
80
|
"config.shims": "安裝 shim",
|
|
80
81
|
"config.sync_ci": "同步 CI 密鑰",
|
|
@@ -27,10 +27,12 @@ CATALOG: dict[str, str] = {
|
|
|
27
27
|
"picker.empty": "沒有符合的項目 -- Ctrl-U 清除篩選,Left/Right 換 harness,t 換類型",
|
|
28
28
|
"picker.status.shown": "顯示 %s / %s",
|
|
29
29
|
"picker.status.dry_run": "試跑:Enter 只顯示計畫",
|
|
30
|
+
"picker.status.leave": "Ctrl+C 離開 · 自動儲存",
|
|
30
31
|
"picker.badge.shared": "+共用",
|
|
31
32
|
# detail pane
|
|
32
33
|
"picker.state.live": "啟用",
|
|
33
34
|
"picker.state.parked": "停用",
|
|
35
|
+
"picker.d.description": "說明",
|
|
34
36
|
"picker.d.harness": "harness",
|
|
35
37
|
"picker.d.type": "類型",
|
|
36
38
|
"picker.d.state": "狀態",
|
|
@@ -39,6 +39,10 @@ def _stored_path(name: str) -> Path:
|
|
|
39
39
|
validate_name(name)
|
|
40
40
|
if ":" in name or name.split(".")[0].upper() in WIN_RESERVED or name.endswith("."):
|
|
41
41
|
die(f"invalid profile name {name!r}", 2)
|
|
42
|
+
try:
|
|
43
|
+
name.encode("utf-8") # a lone surrogate is a legal NTFS file name
|
|
44
|
+
except UnicodeEncodeError:
|
|
45
|
+
die(f"invalid profile name {name!r}", 2)
|
|
42
46
|
return fs.profiles_dir() / f"{name}.json"
|
|
43
47
|
|
|
44
48
|
|
|
@@ -326,7 +326,7 @@ def numbered(ctx: Ctx, stdin=None, stdout=None) -> int:
|
|
|
326
326
|
while True:
|
|
327
327
|
ctx.refresh()
|
|
328
328
|
lw = label_width(40)
|
|
329
|
-
say([(t(
|
|
329
|
+
say([(f"{t('config.title', 'agent-toggle config')} v{__version__}", "title")])
|
|
330
330
|
n = 0
|
|
331
331
|
for row in ROWS:
|
|
332
332
|
if row.kind == "heading":
|
|
@@ -338,7 +338,8 @@ def numbered(ctx: Ctx, stdin=None, stdout=None) -> int:
|
|
|
338
338
|
say([(f" {n:>2}. {label}{' ' * (lw - cell_width(label))} ", "text"),
|
|
339
339
|
*value_segments(ctx, row, g)])
|
|
340
340
|
say([])
|
|
341
|
-
|
|
341
|
+
say([(t("config.status.leave", "q/Ctrl+C to leave · auto-save"), "muted")])
|
|
342
|
+
stdout.write(theme.encodable(t("config.prompt", "Pick a setting to change (1-%s): ",
|
|
342
343
|
len(ITEMS)), stdout))
|
|
343
344
|
stdout.flush()
|
|
344
345
|
choice = _read(stdin)
|
|
@@ -411,8 +412,8 @@ def screen(ctx: Ctx, cur: int, top: int, h: int, w: int, g: theme.Glyphs,
|
|
|
411
412
|
msg: Segment = ("", "text"), editing: str | None = None) -> list[list[Segment]]:
|
|
412
413
|
"""The whole menu as one segment list per screen line (pure: no curses)."""
|
|
413
414
|
body = max(1, h - 5)
|
|
414
|
-
lines = [theme.title_bar(t(
|
|
415
|
-
|
|
415
|
+
lines = [theme.title_bar(f"{t('config.title', 'agent-toggle config')} v{__version__}",
|
|
416
|
+
t("config.saves", "saves as you go"), w, g),
|
|
416
417
|
_rule(g.tl, g.tr, w, g, t("config.more", "%s more", top) if top else "")]
|
|
417
418
|
lw = label_width(max(8, (w - 2) // 2 - 4))
|
|
418
419
|
for i in range(top, min(len(ROWS), top + body)):
|
|
@@ -427,7 +428,8 @@ def screen(ctx: Ctx, cur: int, top: int, h: int, w: int, g: theme.Glyphs,
|
|
|
427
428
|
lines.append(theme.key_chips([
|
|
428
429
|
(arrows[0], t("config.key_move", "move")), (arrows[1], t("config.key_change", "change")),
|
|
429
430
|
(arrows[2], t("config.key_run", "change/run")), ("r", t("config.key_reset", "reset")),
|
|
430
|
-
("R", t("config.key_reset_all", "reset all")),
|
|
431
|
+
("R", t("config.key_reset_all", "reset all")),
|
|
432
|
+
("q/Ctrl+C", t("config.key_quit", "to leave · auto-save"))], w - 1, g))
|
|
431
433
|
return lines
|
|
432
434
|
|
|
433
435
|
|
|
@@ -17,17 +17,19 @@ SORTS = ("name", "cost")
|
|
|
17
17
|
|
|
18
18
|
class Row:
|
|
19
19
|
__slots__ = ("harness", "type", "name", "enabled", "staged", "tokens", "would_save",
|
|
20
|
-
"shared", "path", "since", "mechanism", "basis")
|
|
20
|
+
"shared", "path", "since", "mechanism", "basis", "description")
|
|
21
21
|
|
|
22
22
|
def __init__(self, harness: str, type_: str, name: str, enabled: bool,
|
|
23
23
|
tokens: int = 0, would_save: int = 0, shared: tuple[str, ...] = (),
|
|
24
|
-
path: str = "", since: str = "", mechanism: str = "", basis: str = ""
|
|
24
|
+
path: str = "", since: str = "", mechanism: str = "", basis: str = "",
|
|
25
|
+
description: str = ""):
|
|
25
26
|
self.harness, self.type, self.name = harness, type_, name
|
|
26
27
|
self.enabled = enabled
|
|
27
28
|
self.staged = enabled # what the user has ticked so far
|
|
28
29
|
self.tokens, self.would_save, self.shared = tokens, would_save, shared
|
|
29
30
|
# detail-pane facts; "" = unknown (a plugin has no path, a live row no `since`)
|
|
30
31
|
self.path, self.since, self.mechanism, self.basis = path, since, mechanism, basis
|
|
32
|
+
self.description = description
|
|
31
33
|
|
|
32
34
|
@property
|
|
33
35
|
def changed(self) -> bool:
|
|
@@ -68,7 +70,8 @@ def collect(state: dict, harnesses: dict, warn=lambda m: None,
|
|
|
68
70
|
else:
|
|
69
71
|
path = e.get("parked_at") or e.get("backup") or ""
|
|
70
72
|
rows.append(Row(i.harness, i.type, i.name, i.enabled, i.tokens, i.would_save,
|
|
71
|
-
i.shared_with, str(path), str(e.get("at") or ""), str(mech), i.basis
|
|
73
|
+
i.shared_with, str(path), str(e.get("at") or ""), str(mech), i.basis,
|
|
74
|
+
i.description))
|
|
72
75
|
rows.sort(key=lambda r: (r.harness, r.type, r.name))
|
|
73
76
|
return rows
|
|
74
77
|
|
|
@@ -121,8 +124,11 @@ def fmt_tokens(n: int) -> str:
|
|
|
121
124
|
|
|
122
125
|
def short_path(path: str) -> str:
|
|
123
126
|
"""`path` with the home dir spelled `~`."""
|
|
124
|
-
|
|
125
|
-
|
|
127
|
+
try:
|
|
128
|
+
rel = Path(path).relative_to(fs.home())
|
|
129
|
+
except ValueError:
|
|
130
|
+
return path
|
|
131
|
+
return "~/" + rel.as_posix() if rel.parts else "~"
|
|
126
132
|
|
|
127
133
|
|
|
128
134
|
def _hay(r: Row) -> str:
|
|
@@ -20,8 +20,8 @@ import sys
|
|
|
20
20
|
from pathlib import Path
|
|
21
21
|
|
|
22
22
|
from .. import __version__
|
|
23
|
-
from ..spinner import spinner
|
|
24
23
|
from ..i18n import t
|
|
24
|
+
from ..spinner import spinner
|
|
25
25
|
from . import theme
|
|
26
26
|
from .model import (
|
|
27
27
|
SORTS,
|
|
@@ -34,9 +34,9 @@ from .model import (
|
|
|
34
34
|
profile_command,
|
|
35
35
|
profile_listing,
|
|
36
36
|
short_path,
|
|
37
|
-
type_label,
|
|
38
37
|
strict,
|
|
39
38
|
subsequence,
|
|
39
|
+
type_label,
|
|
40
40
|
visible,
|
|
41
41
|
)
|
|
42
42
|
|
|
@@ -95,6 +95,39 @@ def pad(text: str, cells: int) -> str:
|
|
|
95
95
|
return text + " " * max(0, cells - theme.cell_width(text))
|
|
96
96
|
|
|
97
97
|
|
|
98
|
+
def wrap_cells(text: str, width: int) -> list[str]:
|
|
99
|
+
"""Wrap `text` on spaces into lines of at most `width` terminal cells.
|
|
100
|
+
|
|
101
|
+
A word wider than `width` is cut. Path fields still slice by character
|
|
102
|
+
because a path is one token."""
|
|
103
|
+
width = max(1, width)
|
|
104
|
+
lines: list[str] = []
|
|
105
|
+
cur = ""
|
|
106
|
+
for word in text.split():
|
|
107
|
+
if not cur:
|
|
108
|
+
cur = word
|
|
109
|
+
elif theme.cell_width(f"{cur} {word}") <= width:
|
|
110
|
+
cur = f"{cur} {word}"
|
|
111
|
+
else:
|
|
112
|
+
lines.append(cur)
|
|
113
|
+
cur = word
|
|
114
|
+
if cur:
|
|
115
|
+
lines.append(cur)
|
|
116
|
+
out: list[str] = []
|
|
117
|
+
for line in lines or [""]:
|
|
118
|
+
while theme.cell_width(line) > width:
|
|
119
|
+
cut = theme.truncate(line, width)
|
|
120
|
+
if not cut or cut == line: # a single cell wider than `width`
|
|
121
|
+
out.append(line)
|
|
122
|
+
line = ""
|
|
123
|
+
break
|
|
124
|
+
out.append(cut)
|
|
125
|
+
line = line[len(cut):]
|
|
126
|
+
if line:
|
|
127
|
+
out.append(line)
|
|
128
|
+
return out or [""]
|
|
129
|
+
|
|
130
|
+
|
|
98
131
|
def _sep(g: theme.Glyphs) -> str:
|
|
99
132
|
return " · " if g.unicode else " | "
|
|
100
133
|
|
|
@@ -170,7 +203,7 @@ def heading(type_: str, count: int, width: int, g: theme.Glyphs) -> list[theme.S
|
|
|
170
203
|
|
|
171
204
|
def detail_lines(r: Row, width: int, g: theme.Glyphs) -> list[list[theme.Segment]]:
|
|
172
205
|
"""The detail pane for the cursor row: one segment list per screen line."""
|
|
173
|
-
lab = 11
|
|
206
|
+
lab = 12 # "description" is 11 cells; leave a gap
|
|
174
207
|
val = max(1, width - lab)
|
|
175
208
|
out = [[(theme.truncate(r.name, width, g.ellipsis), "title")], []]
|
|
176
209
|
|
|
@@ -178,6 +211,13 @@ def detail_lines(r: Row, width: int, g: theme.Glyphs) -> list[list[theme.Segment
|
|
|
178
211
|
for n in range(0, max(1, len(text)), val): # a long path wraps
|
|
179
212
|
out.append([(pad(label if n == 0 else "", lab), "muted"), (text[n:n + val], role)])
|
|
180
213
|
|
|
214
|
+
if r.description: # frontmatter prose, not a path
|
|
215
|
+
chunks = wrap_cells(r.description, val)
|
|
216
|
+
label = t("picker.d.description", "description")
|
|
217
|
+
for n, chunk in enumerate(chunks):
|
|
218
|
+
out.append([(pad(label if n == 0 else "", lab), "muted"), (chunk, "text")])
|
|
219
|
+
out.append([])
|
|
220
|
+
|
|
181
221
|
state = t("picker.state.live", "live") if r.enabled else t("picker.state.parked", "parked")
|
|
182
222
|
field(t("picker.d.harness", "harness"), r.harness, theme.harness_role(r.harness))
|
|
183
223
|
field(t("picker.d.type", "type"), r.type, "type")
|
|
@@ -250,7 +290,7 @@ def draw(win, v: View, shown: list[Row], pal: theme.Palette, g: theme.Glyphs,
|
|
|
250
290
|
if end + theme.cell_width(right) + 2 <= w:
|
|
251
291
|
put(2, [(right, "muted")], w - theme.cell_width(right))
|
|
252
292
|
|
|
253
|
-
body = max(0, h - BODY_TOP -
|
|
293
|
+
body = max(0, h - BODY_TOP - 3) # 0 on a tiny window: footer only
|
|
254
294
|
v.page = max(1, body) # draw owns the page size and scroll
|
|
255
295
|
dw = min(48, w * 2 // 5) if w >= DETAIL_MIN else 0
|
|
256
296
|
lw = w - dw - (1 if dw else 0)
|
|
@@ -288,14 +328,15 @@ def draw(win, v: View, shown: list[Row], pal: theme.Palette, g: theme.Glyphs,
|
|
|
288
328
|
lw)
|
|
289
329
|
|
|
290
330
|
if v.note: # last profile result, until the next key
|
|
291
|
-
put(h -
|
|
331
|
+
put(h - 3, [(" " + v.note, "error" if v.note.startswith("error") else "accent")])
|
|
292
332
|
else:
|
|
293
333
|
status = t("picker.status.shown", "%s of %s shown", len(shown), len(v.rows))
|
|
294
334
|
if shown:
|
|
295
335
|
status += f"{_sep(g)}{v.cur + 1}/{len(shown)}"
|
|
296
336
|
if dry_run:
|
|
297
337
|
status += _sep(g) + t("picker.status.dry_run", "dry run: Enter only shows the plan")
|
|
298
|
-
put(h -
|
|
338
|
+
put(h - 3, [(" " + status, "muted")])
|
|
339
|
+
put(h - 2, [(" " + t("picker.status.leave", "Ctrl+C to leave · auto-save"), "muted")])
|
|
299
340
|
put(h - 1, [(" ", "text"), *theme.key_chips(chips(v, g), w - 2, g)])
|
|
300
341
|
win.refresh()
|
|
301
342
|
|
|
@@ -87,8 +87,8 @@ def _lines(found: update_check.UpdateAvailable, selected: int, color: bool,
|
|
|
87
87
|
label += " " * (width - theme.cell_width(name))
|
|
88
88
|
lines.append(" " + theme.ansi("cursor" if on else "text", label, color)
|
|
89
89
|
+ " " + theme.ansi("muted", detail, color))
|
|
90
|
-
keys = (t("update.keys", "↑↓ select · ⏎ confirm · q skip") if g.unicode
|
|
91
|
-
else t("update.keys_ascii", "up/down select, enter confirm, q skip"))
|
|
90
|
+
keys = (t("update.keys", "↑↓ select · ⏎ confirm · q/Ctrl+C skip") if g.unicode
|
|
91
|
+
else t("update.keys_ascii", "up/down select, enter confirm, q/Ctrl+C skip"))
|
|
92
92
|
lines += [" " + t("update.notes", "Release notes: %s", RELEASE_NOTES % _clean(found.latest).removeprefix("v")),
|
|
93
93
|
" " + theme.ansi("muted", keys, color)]
|
|
94
94
|
return lines
|
|
@@ -137,7 +137,7 @@ def _posix_keys(fd: int) -> Iterator[str]:
|
|
|
137
137
|
|
|
138
138
|
def _typed_keys(stream, out) -> Iterator[str]:
|
|
139
139
|
"""The numbered fallback: one typed line per answer ('' = the highlighted row)."""
|
|
140
|
-
out.write(t("update.choose", "Choose 1-3 (Enter = 1, q = skip): "))
|
|
140
|
+
out.write(t("update.choose", "Choose 1-3 (Enter = 1, q/Ctrl+C = skip): "))
|
|
141
141
|
out.flush()
|
|
142
142
|
line = stream.readline()
|
|
143
143
|
if not line:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agent-toggle
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.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
|
|
@@ -159,7 +159,7 @@ agent-toggle <command> [args] # or: python3 agent_toggle.py <command> [
|
|
|
159
159
|
| `enable --all` | put back **every** disabled item (`--harness H` narrows it, `--project <dir>` takes only that project's) |
|
|
160
160
|
| `undo` | reverse the last logged batch (`--dry-run` shows the plan) |
|
|
161
161
|
| `profile save\|apply\|diff\|list` | named sets of live items; see [Profiles](#profiles) |
|
|
162
|
-
| `config` | settings menu (curses, else a numbered list); `config --json` lists every setting; see [Settings & config menu](#settings--config-menu) |
|
|
162
|
+
| `config` | settings menu (curses, else a numbered list); `config --json` lists every setting and the package version; see [Settings & config menu](#settings--config-menu) |
|
|
163
163
|
| `config test\|sync-ci` | send one Telegram test message / set the GitHub CI secrets; needs the `[telegram]` extra; see [CI notifications](#ci-notifications) |
|
|
164
164
|
| `help [command]` | styled help; `help ui` = `--help ui` = `ui --help`; see [Help](#help) |
|
|
165
165
|
| `doctor` | check each harness layout and `state.json` against disk; exit `1` only on an `error` row; on a terminal, asks y/n per fix it can run itself |
|
|
@@ -274,7 +274,8 @@ What is on screen:
|
|
|
274
274
|
- **Cost column**: estimated startup tokens (chars / 4, about +-25 %) with a colored bar. A parked
|
|
275
275
|
row shows `(N)` in brackets, what restoring it would load. Plugins appear as rows too (via
|
|
276
276
|
`claude plugin list --json`; skipped under `ui --dry-run`, which never shells out).
|
|
277
|
-
- **Detail pane** at 100 columns or wider:
|
|
277
|
+
- **Detail pane** at 100 columns or wider: the file's frontmatter `description` (skills,
|
|
278
|
+
agents, commands, rules), then harness, type, state, staged, path, cost, since,
|
|
278
279
|
mechanism, and what it is shared with.
|
|
279
280
|
- **Search bar** (🔍; plain `/` on terminals that cannot draw emoji: the Linux console, the legacy
|
|
280
281
|
Windows console, non-UTF-8 locales) under the tabs, always visible.
|
|
@@ -337,11 +338,12 @@ The chrome (title, tabs, footer, help, detail labels) is translated when `langua
|
|
|
337
338
|
|
|
338
339
|
```sh
|
|
339
340
|
agent-toggle config # or: agent-toggle --config
|
|
340
|
-
agent-toggle config --json # every setting with its value and source; the token is masked
|
|
341
|
+
agent-toggle config --json # every setting with its value and source, plus the package version; the token is masked
|
|
341
342
|
```
|
|
342
343
|
|
|
343
|
-
`config` opens the settings menu: curses on a terminal, a numbered list otherwise.
|
|
344
|
-
is saved the moment you make it. On a numbered list,
|
|
344
|
+
`config` opens the settings menu: curses on a terminal, a numbered list otherwise. The title
|
|
345
|
+
shows the package version. Each change is saved the moment you make it. On a numbered list,
|
|
346
|
+
end of input, `q` or an empty line exits `0`
|
|
345
347
|
(safe in CI; note it reads piped digits, so an open stdin pipe waits).
|
|
346
348
|
|
|
347
349
|
| group | rows |
|
|
@@ -176,14 +176,15 @@ class OpencodeTableTest(AliasBase):
|
|
|
176
176
|
|
|
177
177
|
def test_skill_dirs_default_and_additive_and_survive_bad_json(self) -> None:
|
|
178
178
|
claude, agents = str(self.tmp / ".claude/skills"), str(self.tmp / ".agents/skills")
|
|
179
|
+
absdir = str(self.tmp / "abs" / "dir")
|
|
179
180
|
default = ("skills", "skill", claude, agents) # T1: OpenCode 2.0.22 scans all four
|
|
180
181
|
self.assertEqual(self.dirs(), default)
|
|
181
182
|
self.write_cfg("{ not json")
|
|
182
183
|
self.assertEqual(self.dirs(), default)
|
|
183
184
|
self.write_cfg(json.dumps({"skills": {"paths": [
|
|
184
|
-
"~/.codex/skills", "rel", "~other/x", 7, "~/.codex/skills",
|
|
185
|
+
"~/.codex/skills", "rel", "~other/x", 7, "~/.codex/skills", absdir]}}))
|
|
185
186
|
# redirects first (install-shims target), own dirs stay scanned, relative skipped
|
|
186
|
-
self.assertEqual(self.dirs(), (str(self.tmp / ".codex/skills"),
|
|
187
|
+
self.assertEqual(self.dirs(), (str(self.tmp / ".codex/skills"), absdir, *default))
|
|
187
188
|
(self.oc / "opencode.json").write_bytes(b"\xff\xfe{") # not UTF-8: still no crash
|
|
188
189
|
self.assertEqual(self.dirs(), default)
|
|
189
190
|
shutil.rmtree(self.oc) # not installed: no other harness's dirs
|
|
@@ -255,7 +255,7 @@ class UpdateCheckTest(CliCase):
|
|
|
255
255
|
for part in ("✨ " + HINT, "› 1) Update now", "uv tool upgrade agent-toggle",
|
|
256
256
|
"ask again next run", "3) Skip until next version",
|
|
257
257
|
"Release notes: https://github.com/weskao/agent-toggle/releases/tag/v99.0.0",
|
|
258
|
-
"↑↓ select · ⏎ confirm · q skip"):
|
|
258
|
+
"↑↓ select · ⏎ confirm · q/Ctrl+C skip"):
|
|
259
259
|
self.assertIn(part, text)
|
|
260
260
|
|
|
261
261
|
|
|
@@ -307,7 +307,7 @@ class SpellingTest(CliCase):
|
|
|
307
307
|
# v0.3.0-v0.5.0 shipped with __version__ still at 0.2.0; ahead is fine (bumped, unreleased)
|
|
308
308
|
changelog = Path(__file__).resolve().parents[1] / "CHANGELOG.md"
|
|
309
309
|
top = next(line for line in changelog.read_text(encoding="utf-8").splitlines()
|
|
310
|
-
if line.startswith("## ["))
|
|
310
|
+
if line.startswith("## [") and "Unreleased" not in line)
|
|
311
311
|
released = top[4:top.index("]")]
|
|
312
312
|
as_tuple = lambda v: tuple(int(p) for p in v.split(".")) # noqa: E731
|
|
313
313
|
self.assertGreaterEqual(as_tuple(__version__), as_tuple(released), top)
|
|
@@ -13,7 +13,7 @@ from unittest import mock
|
|
|
13
13
|
|
|
14
14
|
from test_cli_surface import CliCase
|
|
15
15
|
|
|
16
|
-
from agent_toggle import config, settings
|
|
16
|
+
from agent_toggle import __version__, config, settings
|
|
17
17
|
from agent_toggle.ui import config_menu
|
|
18
18
|
|
|
19
19
|
|
|
@@ -130,11 +130,19 @@ class ConfigTest(CliCase):
|
|
|
130
130
|
self.assertEqual(rc, 0)
|
|
131
131
|
names = [r["name"] for r in env["results"] if r["type"] == "setting"]
|
|
132
132
|
self.assertEqual(names, [k for k in settings.DEFAULTS if k != config.CHAT_KEY])
|
|
133
|
-
sources = {r["name"]: r["source"] for r in env["results"][
|
|
133
|
+
sources = {r["name"]: r["source"] for r in env["results"] if r["type"] == "setting"}
|
|
134
134
|
# tests/base.py sets AGENT_TOGGLE_UPDATE_CHECK=0 for the whole run
|
|
135
135
|
self.assertEqual(sources.pop("update_check"), "env")
|
|
136
136
|
self.assertEqual(set(sources.values()), {"default"})
|
|
137
137
|
|
|
138
|
+
def test_json_includes_the_package_version(self) -> None:
|
|
139
|
+
rc, env = self.run_json("config")
|
|
140
|
+
self.assertEqual(rc, 0)
|
|
141
|
+
row = next(r for r in env["results"] if r["name"] == "version")
|
|
142
|
+
self.assertEqual((row["type"], row["action"], row["status"], row["detail"]),
|
|
143
|
+
("info", "show", "ok", __version__))
|
|
144
|
+
self.assertEqual(row["value"], __version__)
|
|
145
|
+
|
|
138
146
|
def test_test_message_needs_both_values_and_reports_delivery(self) -> None:
|
|
139
147
|
self.assertEqual(self.run_cli("config", "test")[0], 2)
|
|
140
148
|
self.interactive("-100123")
|
|
@@ -106,11 +106,20 @@ class CursesMenuTest(MenuCase):
|
|
|
106
106
|
text = win.text()
|
|
107
107
|
for want in ("agent-toggle config", f"v{__version__}", "saves as you go", "General", "Harnesses",
|
|
108
108
|
"Picker", "Notifications", "Tools", "Check for updates", "Sync CI secrets",
|
|
109
|
-
"› Check for updates", "╭", "╰", "↑↓ move", "R reset all",
|
|
109
|
+
"› Check for updates", "╭", "╰", "↑↓ move", "R reset all",
|
|
110
110
|
"found ~/.claude", "not on this machine"):
|
|
111
111
|
self.assertIn(want, text)
|
|
112
|
-
|
|
113
|
-
|
|
112
|
+
self.assertNotIn("q quit", text)
|
|
113
|
+
keys = next(ln for ln in text.splitlines() if "↑↓ move" in ln)
|
|
114
|
+
self.assertIn("q/Ctrl+C to leave · auto-save", keys)
|
|
115
|
+
help_line = next(ln for ln in text.splitlines() if "look for a newer agent-toggle" in ln)
|
|
116
|
+
self.assertNotIn("q/Ctrl+C", help_line)
|
|
117
|
+
|
|
118
|
+
def test_title_puts_version_next_to_the_name(self) -> None:
|
|
119
|
+
win, _ = self.menu(["q"])
|
|
120
|
+
first = win.text().splitlines()[0]
|
|
121
|
+
self.assertIn(f"agent-toggle config v{__version__}", first)
|
|
122
|
+
self.assertLess(first.index(f"v{__version__}"), first.index("saves as you go"))
|
|
114
123
|
|
|
115
124
|
def test_toggle_update_check_saves_at_once(self) -> None:
|
|
116
125
|
win, _ = self.menu(["\n", "q"])
|
|
@@ -171,8 +180,11 @@ class CursesMenuTest(MenuCase):
|
|
|
171
180
|
self.assertEqual(i18n.LANGUAGE, "zh-TW")
|
|
172
181
|
self.assertEqual(self.file()["language"], "zh-TW")
|
|
173
182
|
last = win.text()
|
|
174
|
-
for want in ("繁體中文", "一般", "檢查更新", "變更即時儲存"
|
|
183
|
+
for want in ("繁體中文", "一般", "檢查更新", "變更即時儲存"):
|
|
175
184
|
self.assertIn(want, last)
|
|
185
|
+
keys = next(ln for ln in last.splitlines() if "↑↓ 移動" in ln)
|
|
186
|
+
self.assertIn("q/Ctrl+C 離開 · 自動儲存", keys)
|
|
187
|
+
self.assertNotIn("q 離開", last)
|
|
176
188
|
self.assertNotIn("Check for updates", last)
|
|
177
189
|
|
|
178
190
|
def test_env_override_is_tagged(self) -> None:
|
|
@@ -306,10 +318,15 @@ class NumberedTest(MenuCase):
|
|
|
306
318
|
rc = cm.numbered(self.ctx(telegram), io.StringIO(text), out)
|
|
307
319
|
return rc, out.getvalue()
|
|
308
320
|
|
|
321
|
+
def test_title_shows_the_package_version(self) -> None:
|
|
322
|
+
_, out = self.run_numbered("q\n")
|
|
323
|
+
self.assertIn(f"agent-toggle config v{__version__}", out.splitlines()[0])
|
|
324
|
+
|
|
309
325
|
def test_digits_toggle_and_cycle_then_eof_quits(self) -> None:
|
|
310
326
|
rc, out = self.run_numbered(f"1\n{number('Color')}\n")
|
|
311
327
|
self.assertEqual(rc, 0)
|
|
312
|
-
self.assertIn(f"Pick a setting to change (1-{len(cm.ITEMS)}
|
|
328
|
+
self.assertIn(f"Pick a setting to change (1-{len(cm.ITEMS)}): ", out)
|
|
329
|
+
self.assertIn("q/Ctrl+C to leave · auto-save", out)
|
|
313
330
|
self.assertIn(" 1. Check for updates", out)
|
|
314
331
|
self.assertIs(settings.get("update_check"), False)
|
|
315
332
|
self.assertEqual(settings.get("color"), "always")
|
|
@@ -26,14 +26,15 @@ class EstimatorTest(CliCase):
|
|
|
26
26
|
|
|
27
27
|
def test_skill_counts_name_and_description_not_the_body(self) -> None:
|
|
28
28
|
tokens, chars, basis = self.est(
|
|
29
|
-
"skill", "skills/s/SKILL.md", FM.format(n="abcd", d="x" * 96, body="B" * 50000))
|
|
29
|
+
"skill", "skills/s/SKILL.md", FM.format(n="abcd", d="x" * 96, body="B" * 50000))[:3]
|
|
30
30
|
self.assertEqual((tokens, chars), (25, 100))
|
|
31
31
|
self.assertIn("name+description 100 chars", basis)
|
|
32
32
|
|
|
33
33
|
def test_folded_description_and_missing_frontmatter(self) -> None:
|
|
34
34
|
folded = "---\nname: f\ndescription: >\n one two\n three\n---\n"
|
|
35
35
|
self.assertEqual(self.est("skill", "skills/f/SKILL.md", folded)[1], len("f") + len("one two three"))
|
|
36
|
-
tokens, chars, basis = self.est(
|
|
36
|
+
tokens, chars, basis = self.est(
|
|
37
|
+
"skill", "skills/g/SKILL.md", "# no frontmatter", name="gname")[:3]
|
|
37
38
|
self.assertEqual(chars, len("gname"))
|
|
38
39
|
self.assertIn("no frontmatter", basis)
|
|
39
40
|
|
|
@@ -10,8 +10,10 @@ import contextlib
|
|
|
10
10
|
import errno
|
|
11
11
|
import json
|
|
12
12
|
import os
|
|
13
|
+
import shlex
|
|
13
14
|
import shutil
|
|
14
15
|
import stat
|
|
16
|
+
import unittest
|
|
15
17
|
from pathlib import Path
|
|
16
18
|
from unittest import mock
|
|
17
19
|
|
|
@@ -131,6 +133,7 @@ class FlagCrashTest(CrashCase):
|
|
|
131
133
|
self.assertEqual(self.file.read_bytes(), self.before)
|
|
132
134
|
self.assertEqual(self.raw_state(), {"version": 3, "disabled": {}})
|
|
133
135
|
|
|
136
|
+
@unittest.skipIf(os.name == "nt", "lock liveness is POSIX-only (fs._holder_alive)")
|
|
134
137
|
def test_status_does_not_call_a_live_runs_op_interrupted(self) -> None:
|
|
135
138
|
with self.killed("agent_toggle.mechanisms.set_flag", side_effect=self.write_then_die):
|
|
136
139
|
self.run_cli("disable", *self.ARGS)
|
|
@@ -252,7 +255,7 @@ class MoveCrashTest(CrashCase):
|
|
|
252
255
|
rc, env = self.run_json("doctor")
|
|
253
256
|
stuck = [r for r in env["results"] if r.get("pending") == "stuck"]
|
|
254
257
|
self.assertEqual(len(stuck), 1, env)
|
|
255
|
-
self.assertIn(f"rm -rf {self.pskill.resolve()}", stuck[0]["detail"])
|
|
258
|
+
self.assertIn(f"rm -rf {shlex.quote(str(self.pskill.resolve()))}", stuck[0]["detail"])
|
|
256
259
|
self.p("disable", "agent", "demo-agent") # an unrelated change
|
|
257
260
|
self.assertIn(self.key, self.pending()) # never resolved by deleting
|
|
258
261
|
self.assertEqual(file_bytes(self.pskill), self.files)
|
|
@@ -363,7 +363,7 @@ class FixTest(DoctorCase):
|
|
|
363
363
|
self.write("skills-disabled/solo/SKILL.md")
|
|
364
364
|
before = snapshot(self.tmp)
|
|
365
365
|
asked, _ = self.fixes(answer=False)
|
|
366
|
-
self.assertEqual(len(asked), 2, asked)
|
|
366
|
+
self.assertEqual(len(asked), 1 if os.name == "nt" else 2, asked) # no loose-mode fix on Windows
|
|
367
367
|
self.assertEqual(snapshot(self.tmp), before)
|
|
368
368
|
|
|
369
369
|
@unittest.skipIf(os.name == "nt", "no POSIX modes")
|
|
@@ -87,7 +87,7 @@ class InstallShimsCase(SandboxCase):
|
|
|
87
87
|
self.assertTrue(shim(oc).is_file())
|
|
88
88
|
shutil.rmtree(oc / "skills")
|
|
89
89
|
(oc / "opencode.json").write_text(
|
|
90
|
-
json.dumps({"skills": ["
|
|
90
|
+
json.dumps({"skills": [str(self.tmp / "no" / "such" / "parent" / "skills")]}), encoding="utf-8")
|
|
91
91
|
self.assertEqual(self.run_cli("--harness", "opencode")[0], 0)
|
|
92
92
|
self.assertTrue(shim(oc).is_file())
|
|
93
93
|
|
|
@@ -107,6 +107,28 @@ class PickerTest(SandboxCase):
|
|
|
107
107
|
# name (3) + description (396) = 399 chars -> 100 tokens
|
|
108
108
|
self.assertEqual(rows["big"].tokens, 100)
|
|
109
109
|
|
|
110
|
+
def test_collect_carries_the_frontmatter_description(self) -> None:
|
|
111
|
+
self.write("skills/solo/SKILL.md",
|
|
112
|
+
"---\nname: solo\ndescription: park a skill without deleting it\n---\nbody\n")
|
|
113
|
+
self.write("commands/go.md", "---\ndescription: run the go helper\n---\n")
|
|
114
|
+
self.write("agents/helper.md", "---\nname: helper\ndescription: >\n one two\n three\n---\n")
|
|
115
|
+
self.write("skills/bare/SKILL.md", "# no frontmatter\n")
|
|
116
|
+
self.write("rules/note.md", "---\ndescription: always on note\n---\nbody\n")
|
|
117
|
+
park = self.tmp / "parked-skill"
|
|
118
|
+
park.mkdir()
|
|
119
|
+
(park / "SKILL.md").write_text(
|
|
120
|
+
"---\ndescription: parked copy of the skill\n---\n", encoding="utf-8")
|
|
121
|
+
state = {"disabled": {"claude:skill:parked-skill": {
|
|
122
|
+
"harness": "claude", "type": "skill", "name": "parked-skill",
|
|
123
|
+
"parked_at": str(park)}}}
|
|
124
|
+
rows = {r.name: r for r in model.collect(state, build(self.tmp))}
|
|
125
|
+
self.assertEqual(rows["solo"].description, "park a skill without deleting it")
|
|
126
|
+
self.assertEqual(rows["go"].description, "run the go helper")
|
|
127
|
+
self.assertEqual(rows["helper"].description, "one two three")
|
|
128
|
+
self.assertEqual(rows["bare"].description, "")
|
|
129
|
+
self.assertEqual(rows["note"].description, "always on note")
|
|
130
|
+
self.assertEqual(rows["parked-skill"].description, "parked copy of the skill")
|
|
131
|
+
|
|
110
132
|
def test_visible_filters_and_sorts(self) -> None:
|
|
111
133
|
rows = [model.Row("claude", "skill", "a", True, 5),
|
|
112
134
|
model.Row("codex", "skill", "b", True, 50, shared=("opencode",)),
|
|
@@ -378,7 +400,7 @@ class KeysTest(ScreenCase):
|
|
|
378
400
|
C = picker.curses
|
|
379
401
|
_, win = self.run_keys([C.KEY_NPAGE, "\x1b"], rows=rows, size=(12, 80))
|
|
380
402
|
cursor = next(line for line in win.screen if line.startswith("›"))
|
|
381
|
-
self.assertIn("
|
|
403
|
+
self.assertIn("s06", cursor) # 6-line body: one page down
|
|
382
404
|
self.assertNotIn("s00", win.text())
|
|
383
405
|
out, win = self.run_keys([C.KEY_END, " ", "\n"], rows=rows, size=(12, 80))
|
|
384
406
|
self.assertEqual([r.name for r in out], ["last"])
|
|
@@ -401,7 +423,7 @@ class KeysTest(ScreenCase):
|
|
|
401
423
|
def test_a_tiny_window_keeps_status_and_keys(self) -> None:
|
|
402
424
|
out, win = self.run_keys([" ", "\n"], size=(5, 40))
|
|
403
425
|
self.assertEqual([r.name for r in out], ["alpha"])
|
|
404
|
-
self.assertIn("of 5 shown", win.frames[0][-
|
|
426
|
+
self.assertIn("of 5 shown", win.frames[0][-3])
|
|
405
427
|
self.assertIn("Space toggle", win.frames[0][-1])
|
|
406
428
|
|
|
407
429
|
def test_resize_redraws_at_the_new_size(self) -> None:
|
|
@@ -420,6 +442,38 @@ class LayoutTest(ScreenCase):
|
|
|
420
442
|
self.assertIn("5 resources · ~6.3k tok live", win.frames[0][0])
|
|
421
443
|
self.assertIn("1 staged (-1.2k tok)", win.screen[0])
|
|
422
444
|
|
|
445
|
+
def test_status_line_says_ctrl_c_leaves_and_auto_saves(self) -> None:
|
|
446
|
+
_, win = self.run_keys(["\x1b"])
|
|
447
|
+
self.assertEqual(win.screen[-3].strip(), "5 of 5 shown · 1/5")
|
|
448
|
+
self.assertEqual(win.screen[-2].strip(), "Ctrl+C to leave · auto-save")
|
|
449
|
+
|
|
450
|
+
def test_detail_pane_shows_the_description(self) -> None:
|
|
451
|
+
text = "park a skill without deleting it"
|
|
452
|
+
rows = [model.Row("claude", "skill", "solo", True, description=text)]
|
|
453
|
+
_, win = self.run_keys(["\x1b"], rows=rows, size=(24, 120))
|
|
454
|
+
pane = "\n".join(line[72:] for line in win.screen)
|
|
455
|
+
self.assertIn("description", pane)
|
|
456
|
+
self.assertIn(text, pane)
|
|
457
|
+
bare = [model.Row("claude", "mcp", "srv", True)]
|
|
458
|
+
_, win = self.run_keys(["\x1b"], rows=bare, size=(24, 120))
|
|
459
|
+
self.assertNotIn("description", "\n".join(line[72:] for line in win.screen))
|
|
460
|
+
|
|
461
|
+
def test_detail_pane_wraps_the_description_on_words(self) -> None:
|
|
462
|
+
r = model.Row("claude", "skill", "solo", True, description="alpha beta gamma delta")
|
|
463
|
+
lines = picker.detail_lines(r, 23, theme.ASCII)
|
|
464
|
+
body = ["".join(s for s, role in line if role == "text").strip() for line in lines]
|
|
465
|
+
self.assertIn("alpha beta", body)
|
|
466
|
+
self.assertIn("gamma delta", body)
|
|
467
|
+
|
|
468
|
+
def test_detail_pane_description_is_translated(self) -> None:
|
|
469
|
+
self.addCleanup(i18n.set_language, i18n.LANGUAGE)
|
|
470
|
+
i18n.set_language("zh-TW")
|
|
471
|
+
rows = [model.Row("claude", "skill", "solo", True, description="park a skill")]
|
|
472
|
+
_, win = self.run_keys(["\x1b"], rows=rows, size=(24, 120))
|
|
473
|
+
pane = "\n".join(line[72:] for line in win.screen)
|
|
474
|
+
self.assertIn("說明", pane)
|
|
475
|
+
self.assertIn("park a skill", pane)
|
|
476
|
+
|
|
423
477
|
def test_detail_pane_shows_at_wide_widths_only(self) -> None:
|
|
424
478
|
down = picker.curses.KEY_DOWN
|
|
425
479
|
_, win = self.run_keys([down, "\x1b"], size=(24, 120))
|
|
@@ -451,7 +505,7 @@ class LayoutTest(ScreenCase):
|
|
|
451
505
|
def test_empty_result_says_how_to_get_back(self) -> None:
|
|
452
506
|
_, win = self.run_keys(["/", "q", "q", "\x1b"])
|
|
453
507
|
self.assertIn("nothing matches", win.text())
|
|
454
|
-
self.assertIn("0 of 5 shown", win.screen[-
|
|
508
|
+
self.assertIn("0 of 5 shown", win.screen[-3])
|
|
455
509
|
# with the detail pane, the message is cut at the list's edge, not drawn under the pane
|
|
456
510
|
_, win = self.run_keys(["/", "q", "q", "\x1b"], size=(24, 120))
|
|
457
511
|
line = win.screen[3]
|
|
@@ -466,7 +520,7 @@ class LayoutTest(ScreenCase):
|
|
|
466
520
|
|
|
467
521
|
def test_dry_run_is_announced(self) -> None:
|
|
468
522
|
_, win = self.run_keys(["\x1b"], dry_run=True)
|
|
469
|
-
self.assertIn("dry run", win.screen[-
|
|
523
|
+
self.assertIn("dry run", win.screen[-3])
|
|
470
524
|
|
|
471
525
|
def test_mono_draw_uses_attributes_only(self) -> None:
|
|
472
526
|
_, win = self.run_keys(["\x1b"], size=(24, 90))
|
|
@@ -475,7 +529,8 @@ class LayoutTest(ScreenCase):
|
|
|
475
529
|
self.assertTrue(win.attrs[y, 2] & C.A_BOLD) # live glyph: bold
|
|
476
530
|
self.assertTrue(win.attrs[y, 6] & C.A_REVERSE) # cursor bar
|
|
477
531
|
beta = win.find("beta")
|
|
478
|
-
|
|
532
|
+
if C.A_DIM: # PDCurses defines it as 0
|
|
533
|
+
self.assertTrue(win.attrs[beta, 2] & C.A_DIM) # parked glyph: dim
|
|
479
534
|
|
|
480
535
|
def test_color_draw_uses_pairs_and_no_colors_falls_back(self) -> None:
|
|
481
536
|
C = picker.curses
|
|
@@ -510,6 +565,7 @@ class LayoutTest(ScreenCase):
|
|
|
510
565
|
_, win = self.run_keys(["\x1b"], size=(24, 100))
|
|
511
566
|
self.assertIn("技能", win.text())
|
|
512
567
|
self.assertIn("全部", win.screen[1])
|
|
568
|
+
self.assertIn("Ctrl+C 離開 · 自動儲存", win.screen[-2])
|
|
513
569
|
|
|
514
570
|
|
|
515
571
|
class SettingsTest(ScreenCase):
|
|
@@ -655,7 +711,7 @@ class ProfileKeyTest(SandboxCase):
|
|
|
655
711
|
everything = "\n".join(win.text(i) for i in range(len(win.frames)))
|
|
656
712
|
self.assertIn("profile> work", everything)
|
|
657
713
|
self.assertIn("1) work", everything)
|
|
658
|
-
self.assertIn("profile work: 1 staged", win.screen[-
|
|
714
|
+
self.assertIn("profile work: 1 staged", win.screen[-3])
|
|
659
715
|
# Esc at the prompt stages nothing (even a full valid name) and the picker carries on
|
|
660
716
|
self.rows[0].staged = True
|
|
661
717
|
self.assertEqual(picker.loop(FakeWin(["p", *"work", "\x1b", "\n"], size=(12, 70)),
|
|
@@ -602,7 +602,7 @@ class ProjectMcpTest(ProjectCase):
|
|
|
602
602
|
def test_an_old_whole_file_backup_still_restores_exactly(self) -> None:
|
|
603
603
|
from agent_toggle.backends import mcp_json
|
|
604
604
|
original = json.dumps({"mcpServers": self.SERVERS}, indent=4) + "\n"
|
|
605
|
-
self.mcpfile.
|
|
605
|
+
self.mcpfile.write_bytes(original.encode()) # bytes: write_text would make CRLF on Windows
|
|
606
606
|
self.p("disable", "mcp", "example-mcp")
|
|
607
607
|
bp = Path(self.state()[self.mkey]["backup"])
|
|
608
608
|
# what the pre-G15 disable wrote: the file re-dumped, and both whole texts
|
|
@@ -611,9 +611,9 @@ class ProjectMcpTest(ProjectCase):
|
|
|
611
611
|
old = json.dumps({"project_json": {"entry": self.SERVERS["example-mcp"],
|
|
612
612
|
"before": original, "after": old_after}})
|
|
613
613
|
bp.write_text(old, encoding="utf-8")
|
|
614
|
-
self.mcpfile.
|
|
614
|
+
self.mcpfile.write_bytes(old_after.encode())
|
|
615
615
|
self.assertEqual(self.p("enable", "mcp", "example-mcp")[0], 0)
|
|
616
|
-
self.assertEqual(self.mcpfile.
|
|
616
|
+
self.assertEqual(self.mcpfile.read_bytes().decode(), original)
|
|
617
617
|
self.assertEqual(bp.read_text(encoding="utf-8"), old) # never rewritten
|
|
618
618
|
# file changed meanwhile: the old merge path, as before
|
|
619
619
|
self.p("disable", "mcp", "example-mcp")
|
|
@@ -89,7 +89,7 @@ class ImportTest(unittest.TestCase):
|
|
|
89
89
|
|
|
90
90
|
class GlyphTest(unittest.TestCase):
|
|
91
91
|
def test_unicode_when_the_stream_can_encode_it(self):
|
|
92
|
-
self.assertIs(theme.get_glyphs(Enc("utf-8"), {"TERM": "xterm"}), UNICODE)
|
|
92
|
+
self.assertIs(theme.get_glyphs(Enc("utf-8"), {"TERM": "xterm", "WT_SESSION": "1"}), UNICODE)
|
|
93
93
|
|
|
94
94
|
def test_ascii_when_the_encoding_cannot(self):
|
|
95
95
|
self.assertIs(theme.get_glyphs(Enc("cp1252"), {"TERM": "xterm"}), ASCII)
|
|
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import ast
|
|
11
11
|
import json
|
|
12
|
+
import os
|
|
12
13
|
import shutil
|
|
13
14
|
import tempfile
|
|
14
15
|
import threading
|
|
@@ -233,7 +234,8 @@ class SecurityTest(UpdateCheckCase):
|
|
|
233
234
|
self.check()
|
|
234
235
|
self.assertFalse(self.cache.is_symlink())
|
|
235
236
|
self.assertEqual(target.read_text(encoding="utf-8"), "keep")
|
|
236
|
-
|
|
237
|
+
if os.name != "nt": # Windows has no POSIX modes
|
|
238
|
+
self.assertEqual(self.cache.stat().st_mode & 0o777, 0o600)
|
|
237
239
|
|
|
238
240
|
def test_fetch_reads_at_most_a_megabyte(self):
|
|
239
241
|
seen: list = []
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|