agent-toggle 0.6.2__tar.gz → 0.7.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/PKG-INFO +45 -13
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/README.md +44 -12
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle/__init__.py +1 -1
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle/cli.py +94 -31
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle/doctor.py +8 -4
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle/fs.py +31 -4
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle/help.py +17 -6
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle/locales/zh_tw_cli.py +9 -3
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle/mechanisms.py +99 -25
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle/store.py +8 -5
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle/ui/config_menu.py +3 -1
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle.egg-info/PKG-INFO +45 -13
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/tests/test_alias.py +20 -14
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/tests/test_cli.py +3 -2
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/tests/test_cli_surface.py +1 -1
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/tests/test_color.py +2 -2
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/tests/test_conformance.py +5 -9
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/tests/test_containment.py +2 -2
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/tests/test_crash.py +2 -2
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/tests/test_doctor.py +22 -13
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/tests/test_harnesses.py +1 -1
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/tests/test_install.py +86 -8
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/tests/test_mechanisms.py +7 -5
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/tests/test_store.py +151 -9
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/LICENSE +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle/__main__.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle/backends/__init__.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle/backends/flag_json.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle/backends/mcp_json.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle/backends/mcp_toml.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle/backends/plugin_cli.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle/companions.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle/config.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle/cost.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle/harnesses.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle/i18n.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle/locales/__init__.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle/locales/zh_tw_common.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle/locales/zh_tw_config.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle/locales/zh_tw_picker.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle/ops.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle/output.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle/profiles.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle/settings.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle/shims/claude.md.tmpl +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle/shims/generic.md.tmpl +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle/spinner.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle/toml_check.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle/ui/__init__.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle/ui/menu.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle/ui/model.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle/ui/picker.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle/ui/theme.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle/undo.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle/update_check.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle/update_prompt.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle.egg-info/SOURCES.txt +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle.egg-info/dependency_links.txt +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle.egg-info/entry_points.txt +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle.egg-info/requires.txt +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/agent_toggle.egg-info/top_level.txt +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/pyproject.toml +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/setup.cfg +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/tests/test_backends.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/tests/test_companions.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/tests/test_completions.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/tests/test_config.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/tests/test_config_menu.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/tests/test_cost.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/tests/test_encoding.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/tests/test_fs.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/tests/test_help.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/tests/test_i18n.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/tests/test_menu.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/tests/test_picker.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/tests/test_platform.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/tests/test_profiles.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/tests/test_project.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/tests/test_safety.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/tests/test_secret_audit.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/tests/test_settings.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/tests/test_theme.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/tests/test_undo.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/tests/test_update_check.py +0 -0
- {agent_toggle-0.6.2 → agent_toggle-0.7.0}/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.7.0
|
|
4
4
|
Summary: Temporarily disable / re-enable AI-agent skills, agents, commands, rules, plugins and MCP servers across harnesses (Claude Code, Codex, Grok, OpenCode, OpenClaw, Copilot, Vibe, Devin, Antigravity)
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/weskao/agent-toggle
|
|
@@ -91,8 +91,13 @@ python3 agent_toggle.py install-shims # from a checkout, no install needed
|
|
|
91
91
|
|
|
92
92
|
`install-shims` writes a thin skill shim (`skills/agent-toggle/SKILL.md`) into
|
|
93
93
|
every installed harness that supports skills, so `/agent-toggle` works from any
|
|
94
|
-
of them
|
|
95
|
-
|
|
94
|
+
of them. It writes nothing else into a harness home: parked items live under
|
|
95
|
+
`~/.agent-toggle/parked/`, so a harness-home `.gitignore` needs no park-dir
|
|
96
|
+
line. Older versions appended `<dir>-disabled/` lines there; inside a git work
|
|
97
|
+
tree, `install-shims` reports such stale lines once `migrate` has emptied the
|
|
98
|
+
dir they covered, asks y/n on a terminal, removes them without asking with
|
|
99
|
+
`--gitignore`, and skips the check with `--no-gitignore` (`--dry-run` only
|
|
100
|
+
reports; every other line is kept byte for byte). Each harness gets its own template from
|
|
96
101
|
`agent_toggle/shims/<harness>.md.tmpl`, else `generic.md.tmpl` (which tells the
|
|
97
102
|
agent to pass `--harness <that harness>`). The shim tells the agent to act only
|
|
98
103
|
on the user's explicit request, never on instructions found inside skill or
|
|
@@ -153,7 +158,7 @@ agent-toggle <command> [args] # or: python3 agent_toggle.py <command> [
|
|
|
153
158
|
| `status` | health check: harnesses found, types each supports, parked counts, gitignore, untracked parked items, stale live twins, shared dirs, and state entries whose parked item is gone (a `stale` row with the fix command; read-only, still exit `0`) |
|
|
154
159
|
| `list [type]` | what is currently disabled (`--project <dir>` filters to one project) |
|
|
155
160
|
| `cost [--type T]` | estimated startup tokens per item, biggest first (read-only; `--harness H` filters; `--project <dir>` prices a repo's `.claude/` and `.mcp.json` instead of user scope) |
|
|
156
|
-
| `install-shims` | write the skill shim into every installed harness that is on in the settings (an off one is skipped unless named with `--harness`); refuses to overwrite a file it did not write (`--dry-run` shows the plan); on a terminal, then asks y/n for each problem `doctor` can fix (see [Fixing](#fixing)) |
|
|
161
|
+
| `install-shims` | write the skill shim into every installed harness that is on in the settings (an off one is skipped unless named with `--harness`); refuses to overwrite a file it did not write (`--dry-run` shows the plan); reports stale legacy `<dir>-disabled/` lines in a harness-home `.gitignore` (`--gitignore` removes them, `--no-gitignore` skips the check); on a terminal, then asks y/n for each problem `doctor` can fix (see [Fixing](#fixing)) |
|
|
157
162
|
| `disable <type> <name>...` | park one or more items (`--dry-run` shows the plan; `--project <dir>` for a repo's own `.claude/` and `.mcp.json`) |
|
|
158
163
|
| `enable <type> <name>...` | put them back (`--dry-run` shows the plan; `--project <dir>` likewise) |
|
|
159
164
|
| `enable --all` | put back **every** disabled item (`--harness H` narrows it, `--project <dir>` takes only that project's) |
|
|
@@ -163,12 +168,12 @@ agent-toggle <command> [args] # or: python3 agent_toggle.py <command> [
|
|
|
163
168
|
| `config test\|sync-ci` | send one Telegram test message / set the GitHub CI secrets; needs the `[telegram]` extra; see [CI notifications](#ci-notifications) |
|
|
164
169
|
| `help [command]` | styled help; `help ui` = `--help ui` = `ui --help`; see [Help](#help) |
|
|
165
170
|
| `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 |
|
|
166
|
-
| `migrate` | import an older `~/.claude-toggle/` state |
|
|
171
|
+
| `migrate` | import an older `~/.claude-toggle/` state, and move legacy `<home>/<dir>-disabled/` park dirs into `~/.agent-toggle/parked/user/` (see [Upgrading the park layout](#upgrading-the-park-layout)); safe to re-run |
|
|
167
172
|
|
|
168
173
|
Every command also works with a leading `--` (`agent-toggle --status`, `--help`, `--config`); this README uses the plain form.
|
|
169
174
|
|
|
170
175
|
`<type>` = `skill` / `agent` / `command` / `rule` / `plugin` / `mcp`.
|
|
171
|
-
`rule` is claude-only (`~/.claude/rules/*.md`, parked in
|
|
176
|
+
`rule` is claude-only (`~/.claude/rules/*.md`, parked in `~/.agent-toggle/parked/user/claude/rules/`).
|
|
172
177
|
Disabling a rule prints a warning (also in `--json` `warnings`, dry run included)
|
|
173
178
|
that rules may carry safety constraints.
|
|
174
179
|
|
|
@@ -256,7 +261,7 @@ agent-toggle # same, on a terminal; elsewhere prints help and exits
|
|
|
256
261
|
● zeta claude 5.0k █████ │ type skill
|
|
257
262
|
Agents 1 ─────────────────────────────────────────────────────────────│ state ○ parked
|
|
258
263
|
● gamma claude 50 ▏ +shared │ staged → live
|
|
259
|
-
Commands 1 ───────────────────────────────────────────────────────────│ path ~/.claude/skills
|
|
264
|
+
Commands 1 ───────────────────────────────────────────────────────────│ path ~/.agent-toggle/parked/user/claude/skills/beta
|
|
260
265
|
● delta codex 10 ▏ │ cost 0 now; ~300 tok if restored
|
|
261
266
|
│ since 2026-09-19 10:00:00
|
|
262
267
|
5 of 5 shown · 2/5
|
|
@@ -672,7 +677,7 @@ live, modes no looser than `0600` / `0700`). Rows (`action: doctor`) carry a sta
|
|
|
672
677
|
| `absent` | a dir, config file or key the row expects is not there (an MCP file never created, a missing `mcpServers` key) -- informational, exit `0` |
|
|
673
678
|
| `note` | worth knowing: shared dir, orphan backup, JSONC `openclaw.json` / `opencode.json`, a `--harness` that is not installed |
|
|
674
679
|
| `unverified` | no version could be read, or the row has nothing to check |
|
|
675
|
-
| `warn` | loose file modes; a parked item or companion file with no state entry; a leftover `parked/<sha8>` dir that still holds files (an empty one is ignored); an op a killed run left in flight that the next change settles (`pending: done` / `undone`) |
|
|
680
|
+
| `warn` | loose file modes; a legacy `<dir>-disabled/` park dir in a harness home (fix: `agent-toggle migrate`); a parked item or companion file with no state entry; a leftover `parked/<sha8>` dir that still holds files (an empty one is ignored); an op a killed run left in flight that the next change settles (`pending: done` / `undone`) |
|
|
676
681
|
| `error` | needs fixing: a config that exists but is unparseable or unsupported (`layout changed`), a state entry whose files (companions included) are gone or fail the tamper checks, a companion both live and parked, a flag re-enabled outside the tool, an op a killed run left in flight that needs you (`pending: stuck`, with the exact fix) |
|
|
677
682
|
|
|
678
683
|
Only `error` makes the exit code `1`; each problem row names the command that
|
|
@@ -759,6 +764,7 @@ bookkeeping.
|
|
|
759
764
|
| `log.jsonl` | one line per operation (mode `0600`), see below |
|
|
760
765
|
| `mcp-backups/` | `<harness>__<server>.json`, or `<sha8>__<harness>__<server>.json` for a project `.mcp.json` (mode `0600` -- may hold auth headers) |
|
|
761
766
|
| `companions/` | parked exclusive helper files |
|
|
767
|
+
| `parked/user/<owner>/<dir>/` | user-scope items, e.g. `parked/user/claude/skills/<name>` (`<owner>` = the harness whose home holds the dir; a dir shared by several harnesses parks once, under its owner); an absolute OpenCode `skills.paths` dir parks under `parked/user/opencode/ext-<sha8>/` (`<sha8>` of its resolved path). Nothing is ever parked inside a harness home |
|
|
762
768
|
| `parked/<sha8>/` | items parked by `--project` (`<sha8>` = first 8 hex of the SHA-1 of the resolved project dir) |
|
|
763
769
|
| `profiles/` | `<name>.json` profiles (dir `0700`, files `0600`) |
|
|
764
770
|
| `config.json` | [settings](#settings--config-menu) and the Telegram chat id (mode `0600`, atomic write, unknown keys kept) |
|
|
@@ -784,6 +790,28 @@ project, scope, detail}`. `batch` is one id per run (what `undo` reverses);
|
|
|
784
790
|
local-scope MCP row has `scope: local` and its working directory in `project`).
|
|
785
791
|
Older rows without `batch`/`harness` cannot be undone.
|
|
786
792
|
|
|
793
|
+
### Upgrading the park layout
|
|
794
|
+
|
|
795
|
+
**Breaking:** older versions parked user-scope items in a sibling dir inside
|
|
796
|
+
the harness home (`~/.claude/skills-disabled/`, `~/.codex/prompts-disabled/`,
|
|
797
|
+
...). Claude Code loads `commands/` and `rules/` recursively, so a park dir
|
|
798
|
+
cannot nest inside a live dir either; items now park under
|
|
799
|
+
`~/.agent-toggle/parked/user/`. Run once after upgrading:
|
|
800
|
+
|
|
801
|
+
```sh
|
|
802
|
+
agent-toggle migrate
|
|
803
|
+
```
|
|
804
|
+
|
|
805
|
+
It moves every child of each `<dir>-disabled/` into its new park dir with the
|
|
806
|
+
same crash-safe move `disable` uses, repoints the matching `parked_at` entries
|
|
807
|
+
in `state.json`, and removes the emptied old dir. Items with no state entry
|
|
808
|
+
move too (they stay disabled). A name that already exists at the destination is
|
|
809
|
+
refused and left where it is (exit `1`, the row names it); resolve it and re-run.
|
|
810
|
+
A second run changes nothing and says so. Until then `enable` refuses an item
|
|
811
|
+
still in an old dir with `run: agent-toggle migrate`, and `status` / `doctor`
|
|
812
|
+
list each old dir with the same hint. Afterwards, `install-shims` offers to
|
|
813
|
+
remove the old `<dir>-disabled/` lines from a harness-home `.gitignore`.
|
|
814
|
+
|
|
787
815
|
## Two guardrails, both earned
|
|
788
816
|
|
|
789
817
|
**1. `safe_move()` never lets the source be renamed into the destination.**
|
|
@@ -797,10 +825,13 @@ Order matters too: `mkdir(exist_ok=True)` raises `FileExistsError` when the
|
|
|
797
825
|
path exists as a *file*, so the `is_dir()` check has to come **before** the
|
|
798
826
|
`mkdir` or it is unreachable.
|
|
799
827
|
|
|
800
|
-
**2.
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
828
|
+
**2. A park dir that is not gitignored gets a warning.** Parked items live in
|
|
829
|
+
`~/.agent-toggle/parked/`, outside every harness home, so a harness repo
|
|
830
|
+
(`~/.claude` as a dotfiles repo) never sees them. Only when the park dir itself
|
|
831
|
+
sits inside a git work tree (a tracked `$HOME`) and is not ignored there does
|
|
832
|
+
`disable` warn, once per run, with the `.gitignore` line to add; `status` tags
|
|
833
|
+
each type `[NOT gitignored]` in that case. Without it, every disable would leave
|
|
834
|
+
deletion lines in `git status`.
|
|
804
835
|
|
|
805
836
|
## Paths are resolved before comparison
|
|
806
837
|
|
|
@@ -879,7 +910,8 @@ PyPI: workflow `release.yml`, environment `pypi`. See
|
|
|
879
910
|
|
|
880
911
|
## Cross-machine behaviour
|
|
881
912
|
|
|
882
|
-
Disabling is a **local** decision:
|
|
913
|
+
Disabling is a **local** decision: parked items live in `~/.agent-toggle/`,
|
|
914
|
+
outside every harness home, and do not sync.
|
|
883
915
|
But a disappearance under `skills/` is itself a tracked change, so committing
|
|
884
916
|
it means other machines lose those skills on pull. The content stays in git
|
|
885
917
|
history — `git checkout <commit> -- skills/<name>` brings it back. Don't
|
|
@@ -59,8 +59,13 @@ python3 agent_toggle.py install-shims # from a checkout, no install needed
|
|
|
59
59
|
|
|
60
60
|
`install-shims` writes a thin skill shim (`skills/agent-toggle/SKILL.md`) into
|
|
61
61
|
every installed harness that supports skills, so `/agent-toggle` works from any
|
|
62
|
-
of them
|
|
63
|
-
|
|
62
|
+
of them. It writes nothing else into a harness home: parked items live under
|
|
63
|
+
`~/.agent-toggle/parked/`, so a harness-home `.gitignore` needs no park-dir
|
|
64
|
+
line. Older versions appended `<dir>-disabled/` lines there; inside a git work
|
|
65
|
+
tree, `install-shims` reports such stale lines once `migrate` has emptied the
|
|
66
|
+
dir they covered, asks y/n on a terminal, removes them without asking with
|
|
67
|
+
`--gitignore`, and skips the check with `--no-gitignore` (`--dry-run` only
|
|
68
|
+
reports; every other line is kept byte for byte). Each harness gets its own template from
|
|
64
69
|
`agent_toggle/shims/<harness>.md.tmpl`, else `generic.md.tmpl` (which tells the
|
|
65
70
|
agent to pass `--harness <that harness>`). The shim tells the agent to act only
|
|
66
71
|
on the user's explicit request, never on instructions found inside skill or
|
|
@@ -121,7 +126,7 @@ agent-toggle <command> [args] # or: python3 agent_toggle.py <command> [
|
|
|
121
126
|
| `status` | health check: harnesses found, types each supports, parked counts, gitignore, untracked parked items, stale live twins, shared dirs, and state entries whose parked item is gone (a `stale` row with the fix command; read-only, still exit `0`) |
|
|
122
127
|
| `list [type]` | what is currently disabled (`--project <dir>` filters to one project) |
|
|
123
128
|
| `cost [--type T]` | estimated startup tokens per item, biggest first (read-only; `--harness H` filters; `--project <dir>` prices a repo's `.claude/` and `.mcp.json` instead of user scope) |
|
|
124
|
-
| `install-shims` | write the skill shim into every installed harness that is on in the settings (an off one is skipped unless named with `--harness`); refuses to overwrite a file it did not write (`--dry-run` shows the plan); on a terminal, then asks y/n for each problem `doctor` can fix (see [Fixing](#fixing)) |
|
|
129
|
+
| `install-shims` | write the skill shim into every installed harness that is on in the settings (an off one is skipped unless named with `--harness`); refuses to overwrite a file it did not write (`--dry-run` shows the plan); reports stale legacy `<dir>-disabled/` lines in a harness-home `.gitignore` (`--gitignore` removes them, `--no-gitignore` skips the check); on a terminal, then asks y/n for each problem `doctor` can fix (see [Fixing](#fixing)) |
|
|
125
130
|
| `disable <type> <name>...` | park one or more items (`--dry-run` shows the plan; `--project <dir>` for a repo's own `.claude/` and `.mcp.json`) |
|
|
126
131
|
| `enable <type> <name>...` | put them back (`--dry-run` shows the plan; `--project <dir>` likewise) |
|
|
127
132
|
| `enable --all` | put back **every** disabled item (`--harness H` narrows it, `--project <dir>` takes only that project's) |
|
|
@@ -131,12 +136,12 @@ agent-toggle <command> [args] # or: python3 agent_toggle.py <command> [
|
|
|
131
136
|
| `config test\|sync-ci` | send one Telegram test message / set the GitHub CI secrets; needs the `[telegram]` extra; see [CI notifications](#ci-notifications) |
|
|
132
137
|
| `help [command]` | styled help; `help ui` = `--help ui` = `ui --help`; see [Help](#help) |
|
|
133
138
|
| `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 |
|
|
134
|
-
| `migrate` | import an older `~/.claude-toggle/` state |
|
|
139
|
+
| `migrate` | import an older `~/.claude-toggle/` state, and move legacy `<home>/<dir>-disabled/` park dirs into `~/.agent-toggle/parked/user/` (see [Upgrading the park layout](#upgrading-the-park-layout)); safe to re-run |
|
|
135
140
|
|
|
136
141
|
Every command also works with a leading `--` (`agent-toggle --status`, `--help`, `--config`); this README uses the plain form.
|
|
137
142
|
|
|
138
143
|
`<type>` = `skill` / `agent` / `command` / `rule` / `plugin` / `mcp`.
|
|
139
|
-
`rule` is claude-only (`~/.claude/rules/*.md`, parked in
|
|
144
|
+
`rule` is claude-only (`~/.claude/rules/*.md`, parked in `~/.agent-toggle/parked/user/claude/rules/`).
|
|
140
145
|
Disabling a rule prints a warning (also in `--json` `warnings`, dry run included)
|
|
141
146
|
that rules may carry safety constraints.
|
|
142
147
|
|
|
@@ -224,7 +229,7 @@ agent-toggle # same, on a terminal; elsewhere prints help and exits
|
|
|
224
229
|
● zeta claude 5.0k █████ │ type skill
|
|
225
230
|
Agents 1 ─────────────────────────────────────────────────────────────│ state ○ parked
|
|
226
231
|
● gamma claude 50 ▏ +shared │ staged → live
|
|
227
|
-
Commands 1 ───────────────────────────────────────────────────────────│ path ~/.claude/skills
|
|
232
|
+
Commands 1 ───────────────────────────────────────────────────────────│ path ~/.agent-toggle/parked/user/claude/skills/beta
|
|
228
233
|
● delta codex 10 ▏ │ cost 0 now; ~300 tok if restored
|
|
229
234
|
│ since 2026-09-19 10:00:00
|
|
230
235
|
5 of 5 shown · 2/5
|
|
@@ -640,7 +645,7 @@ live, modes no looser than `0600` / `0700`). Rows (`action: doctor`) carry a sta
|
|
|
640
645
|
| `absent` | a dir, config file or key the row expects is not there (an MCP file never created, a missing `mcpServers` key) -- informational, exit `0` |
|
|
641
646
|
| `note` | worth knowing: shared dir, orphan backup, JSONC `openclaw.json` / `opencode.json`, a `--harness` that is not installed |
|
|
642
647
|
| `unverified` | no version could be read, or the row has nothing to check |
|
|
643
|
-
| `warn` | loose file modes; a parked item or companion file with no state entry; a leftover `parked/<sha8>` dir that still holds files (an empty one is ignored); an op a killed run left in flight that the next change settles (`pending: done` / `undone`) |
|
|
648
|
+
| `warn` | loose file modes; a legacy `<dir>-disabled/` park dir in a harness home (fix: `agent-toggle migrate`); a parked item or companion file with no state entry; a leftover `parked/<sha8>` dir that still holds files (an empty one is ignored); an op a killed run left in flight that the next change settles (`pending: done` / `undone`) |
|
|
644
649
|
| `error` | needs fixing: a config that exists but is unparseable or unsupported (`layout changed`), a state entry whose files (companions included) are gone or fail the tamper checks, a companion both live and parked, a flag re-enabled outside the tool, an op a killed run left in flight that needs you (`pending: stuck`, with the exact fix) |
|
|
645
650
|
|
|
646
651
|
Only `error` makes the exit code `1`; each problem row names the command that
|
|
@@ -727,6 +732,7 @@ bookkeeping.
|
|
|
727
732
|
| `log.jsonl` | one line per operation (mode `0600`), see below |
|
|
728
733
|
| `mcp-backups/` | `<harness>__<server>.json`, or `<sha8>__<harness>__<server>.json` for a project `.mcp.json` (mode `0600` -- may hold auth headers) |
|
|
729
734
|
| `companions/` | parked exclusive helper files |
|
|
735
|
+
| `parked/user/<owner>/<dir>/` | user-scope items, e.g. `parked/user/claude/skills/<name>` (`<owner>` = the harness whose home holds the dir; a dir shared by several harnesses parks once, under its owner); an absolute OpenCode `skills.paths` dir parks under `parked/user/opencode/ext-<sha8>/` (`<sha8>` of its resolved path). Nothing is ever parked inside a harness home |
|
|
730
736
|
| `parked/<sha8>/` | items parked by `--project` (`<sha8>` = first 8 hex of the SHA-1 of the resolved project dir) |
|
|
731
737
|
| `profiles/` | `<name>.json` profiles (dir `0700`, files `0600`) |
|
|
732
738
|
| `config.json` | [settings](#settings--config-menu) and the Telegram chat id (mode `0600`, atomic write, unknown keys kept) |
|
|
@@ -752,6 +758,28 @@ project, scope, detail}`. `batch` is one id per run (what `undo` reverses);
|
|
|
752
758
|
local-scope MCP row has `scope: local` and its working directory in `project`).
|
|
753
759
|
Older rows without `batch`/`harness` cannot be undone.
|
|
754
760
|
|
|
761
|
+
### Upgrading the park layout
|
|
762
|
+
|
|
763
|
+
**Breaking:** older versions parked user-scope items in a sibling dir inside
|
|
764
|
+
the harness home (`~/.claude/skills-disabled/`, `~/.codex/prompts-disabled/`,
|
|
765
|
+
...). Claude Code loads `commands/` and `rules/` recursively, so a park dir
|
|
766
|
+
cannot nest inside a live dir either; items now park under
|
|
767
|
+
`~/.agent-toggle/parked/user/`. Run once after upgrading:
|
|
768
|
+
|
|
769
|
+
```sh
|
|
770
|
+
agent-toggle migrate
|
|
771
|
+
```
|
|
772
|
+
|
|
773
|
+
It moves every child of each `<dir>-disabled/` into its new park dir with the
|
|
774
|
+
same crash-safe move `disable` uses, repoints the matching `parked_at` entries
|
|
775
|
+
in `state.json`, and removes the emptied old dir. Items with no state entry
|
|
776
|
+
move too (they stay disabled). A name that already exists at the destination is
|
|
777
|
+
refused and left where it is (exit `1`, the row names it); resolve it and re-run.
|
|
778
|
+
A second run changes nothing and says so. Until then `enable` refuses an item
|
|
779
|
+
still in an old dir with `run: agent-toggle migrate`, and `status` / `doctor`
|
|
780
|
+
list each old dir with the same hint. Afterwards, `install-shims` offers to
|
|
781
|
+
remove the old `<dir>-disabled/` lines from a harness-home `.gitignore`.
|
|
782
|
+
|
|
755
783
|
## Two guardrails, both earned
|
|
756
784
|
|
|
757
785
|
**1. `safe_move()` never lets the source be renamed into the destination.**
|
|
@@ -765,10 +793,13 @@ Order matters too: `mkdir(exist_ok=True)` raises `FileExistsError` when the
|
|
|
765
793
|
path exists as a *file*, so the `is_dir()` check has to come **before** the
|
|
766
794
|
`mkdir` or it is unreachable.
|
|
767
795
|
|
|
768
|
-
**2.
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
796
|
+
**2. A park dir that is not gitignored gets a warning.** Parked items live in
|
|
797
|
+
`~/.agent-toggle/parked/`, outside every harness home, so a harness repo
|
|
798
|
+
(`~/.claude` as a dotfiles repo) never sees them. Only when the park dir itself
|
|
799
|
+
sits inside a git work tree (a tracked `$HOME`) and is not ignored there does
|
|
800
|
+
`disable` warn, once per run, with the `.gitignore` line to add; `status` tags
|
|
801
|
+
each type `[NOT gitignored]` in that case. Without it, every disable would leave
|
|
802
|
+
deletion lines in `git status`.
|
|
772
803
|
|
|
773
804
|
## Paths are resolved before comparison
|
|
774
805
|
|
|
@@ -847,7 +878,8 @@ PyPI: workflow `release.yml`, environment `pypi`. See
|
|
|
847
878
|
|
|
848
879
|
## Cross-machine behaviour
|
|
849
880
|
|
|
850
|
-
Disabling is a **local** decision:
|
|
881
|
+
Disabling is a **local** decision: parked items live in `~/.agent-toggle/`,
|
|
882
|
+
outside every harness home, and do not sync.
|
|
851
883
|
But a disappearance under `skills/` is itself a tracked change, so committing
|
|
852
884
|
it means other machines lose those skills on pull. The content stays in git
|
|
853
885
|
history — `git checkout <commit> -- skills/<name>` brings it back. Don't
|
|
@@ -23,9 +23,10 @@ Usage:
|
|
|
23
23
|
agent_toggle.py list [<type>] [--project D] # what is currently disabled
|
|
24
24
|
agent_toggle.py status # health check
|
|
25
25
|
agent_toggle.py doctor [--harness H] # drift + state check (y/n fixes on a TTY); exit 1 on problems
|
|
26
|
-
agent_toggle.py migrate # import old
|
|
26
|
+
agent_toggle.py migrate # import old state, move legacy park dirs
|
|
27
27
|
agent_toggle.py profile save|apply|diff|list [name|file] [--out F] [--dry-run]
|
|
28
|
-
agent_toggle.py install-shims [--dry-run]
|
|
28
|
+
agent_toggle.py install-shims [--dry-run] [--gitignore | --no-gitignore]
|
|
29
|
+
# skill shim per harness; stale .gitignore lines
|
|
29
30
|
agent_toggle.py config [--json] # settings menu (curses, else numbered list)
|
|
30
31
|
agent_toggle.py config test|sync-ci # Telegram test message / GitHub CI secrets
|
|
31
32
|
agent_toggle.py help [command] # styled help (also --help, <command> --help)
|
|
@@ -63,9 +64,15 @@ from . import (
|
|
|
63
64
|
)
|
|
64
65
|
from . import help as helptext
|
|
65
66
|
from .backends.plugin_cli import claude_bin
|
|
66
|
-
from .fs import gitignored
|
|
67
67
|
from .harnesses import TYPES, harness_of, harnesses, project_view
|
|
68
|
-
from .mechanisms import
|
|
68
|
+
from .mechanisms import (
|
|
69
|
+
dir_view,
|
|
70
|
+
legacy_parks,
|
|
71
|
+
migrate_parks,
|
|
72
|
+
settle,
|
|
73
|
+
sync_marker_note,
|
|
74
|
+
validate_name,
|
|
75
|
+
)
|
|
69
76
|
from .output import COLOR_MODES, CliError, Result, die, scan_color, use_color
|
|
70
77
|
from .store import load_state, save_state
|
|
71
78
|
from .ui import theme as ui_theme
|
|
@@ -157,10 +164,16 @@ def cmd_status(state: dict, out: Result, only: str | None = None,
|
|
|
157
164
|
out.say(f"legacy {legacy_state_dir} present -- "
|
|
158
165
|
+ ("already imported; safe to delete once verified" if done
|
|
159
166
|
else "run `migrate` to import it"))
|
|
167
|
+
table = harnesses()
|
|
168
|
+
old_parks = [str(old) for old, _, _ in legacy_parks(table, only)]
|
|
169
|
+
for old in old_parks:
|
|
170
|
+
out.say(f"WARNING {old} is a legacy park dir -- run: agent-toggle migrate", warn=True)
|
|
160
171
|
out.row(None, None, None, "status", "ok", "", show=False,
|
|
161
172
|
state_file=str(fs.state_file()), log_file=str(fs.log_file()),
|
|
162
|
-
disabled=len(state["disabled"]), claude_cli=claude, legacy=legacy
|
|
163
|
-
|
|
173
|
+
disabled=len(state["disabled"]), claude_cli=claude, legacy=legacy,
|
|
174
|
+
legacy_parks=old_parks)
|
|
175
|
+
# every user park dir sits under parked_dir(): one check, not one per dir
|
|
176
|
+
ign = fs.park_unignored() is None
|
|
164
177
|
for hname, h in table.items():
|
|
165
178
|
if (only and hname != only) or hname in hidden:
|
|
166
179
|
continue
|
|
@@ -185,7 +198,6 @@ def cmd_status(state: dict, out: Result, only: str | None = None,
|
|
|
185
198
|
items: list[Path] = []
|
|
186
199
|
untracked: list[Path] = []
|
|
187
200
|
twins: list[str] = []
|
|
188
|
-
ign = True
|
|
189
201
|
shared: set[str] = set()
|
|
190
202
|
for sub in h.dirs[t]:
|
|
191
203
|
view = dir_view(table, hname, t, home, sub)
|
|
@@ -204,8 +216,6 @@ def cmd_status(state: dict, out: Result, only: str | None = None,
|
|
|
204
216
|
found = [p for p in parked.rglob("*") if p.is_file()]
|
|
205
217
|
found = [p for p in found if not p.name.startswith(".")] # .DS_Store, as doctor
|
|
206
218
|
items += found
|
|
207
|
-
ign = ign and gitignored(
|
|
208
|
-
parked, home if view.owner == hname else table[view.owner].home)
|
|
209
219
|
u, tw = parked_drift(found, parked, home / sub, tracked)
|
|
210
220
|
untracked += u
|
|
211
221
|
twins += tw
|
|
@@ -345,15 +355,21 @@ def cmd_toggle(args: argparse.Namespace, out: Result) -> None:
|
|
|
345
355
|
|
|
346
356
|
|
|
347
357
|
def cmd_migrate(out: Result) -> None:
|
|
358
|
+
"""Import ~/.claude-toggle state, then move legacy `<live>-disabled` park dirs into
|
|
359
|
+
the central park dir (a killed run is repaired by the next one: see migrate_parks)."""
|
|
348
360
|
buf = io.StringIO() # store.migrate prints; fold it into the result
|
|
349
361
|
with fs.lock():
|
|
350
362
|
state = load_state()
|
|
351
363
|
with contextlib.redirect_stdout(buf):
|
|
352
364
|
store.migrate(state)
|
|
365
|
+
parks = migrate_parks(state)
|
|
353
366
|
save_state(state)
|
|
354
|
-
lines = buf.getvalue().splitlines()
|
|
355
|
-
|
|
356
|
-
out.
|
|
367
|
+
lines = buf.getvalue().splitlines() + (
|
|
368
|
+
parks or ["park dirs: nothing to migrate (no legacy <dir>-disabled/ dirs left)"])
|
|
369
|
+
out.say("\n".join(lines))
|
|
370
|
+
refused = [ln for ln in parks if ln.startswith("refused:")]
|
|
371
|
+
out.row(None, None, None, "migrate", "error" if refused else "ok", "; ".join(lines),
|
|
372
|
+
show=False)
|
|
357
373
|
|
|
358
374
|
|
|
359
375
|
SHIMS = Path(__file__).with_name("shims")
|
|
@@ -405,16 +421,15 @@ def _reads_as(p: Path, text: str) -> bool:
|
|
|
405
421
|
|
|
406
422
|
def cmd_install_shims(args: argparse.Namespace, out: Result) -> None:
|
|
407
423
|
"""Write <home>/skills/agent-toggle/SKILL.md into every installed harness that
|
|
408
|
-
supports skills
|
|
409
|
-
A file there that is not our shim (no
|
|
410
|
-
A harness switched off in settings is
|
|
424
|
+
supports skills, then offer to drop stale legacy park-dir lines from a harness-home
|
|
425
|
+
.gitignore (_stale_ignore_lines). A file there that is not our shim (no
|
|
426
|
+
SHIM_MARKER) is refused, never overwritten. A harness switched off in settings is
|
|
427
|
+
skipped unless named with --harness."""
|
|
411
428
|
table = harnesses()
|
|
412
429
|
on = set(settings.enabled_harnesses())
|
|
413
|
-
park = sorted({f"{sub}-disabled" for h in table.values()
|
|
414
|
-
for subs in h.dirs.values() for sub in subs
|
|
415
|
-
if not Path(sub).is_absolute()}) # skills.paths redirects live elsewhere
|
|
416
430
|
verb = "would install" if args.dry_run else "installed"
|
|
417
431
|
installed = found = 0
|
|
432
|
+
homes = []
|
|
418
433
|
for hname, h in table.items():
|
|
419
434
|
home = h.home
|
|
420
435
|
if args.harness and hname != args.harness:
|
|
@@ -430,6 +445,7 @@ def cmd_install_shims(args: argparse.Namespace, out: Result) -> None:
|
|
|
430
445
|
show=False, home=str(home))
|
|
431
446
|
out.say(f" skipped {hname} (off in settings)")
|
|
432
447
|
continue
|
|
448
|
+
homes.append(h)
|
|
433
449
|
# dirs[0] is OpenCode's first skills.paths redirect when set; if its parent dir
|
|
434
450
|
# is missing, fall back to the first in-home dir instead of creating a tree.
|
|
435
451
|
subs = h.dirs["skill"]
|
|
@@ -473,23 +489,62 @@ def cmd_install_shims(args: argparse.Namespace, out: Result) -> None:
|
|
|
473
489
|
out.say(f" note: {hname} also loads {', '.join(map(str, others))} (a different "
|
|
474
490
|
f"shim); which one wins is unchecked")
|
|
475
491
|
installed += 1
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
ignore = home / ".gitignore"
|
|
479
|
-
if ignore.is_file():
|
|
480
|
-
body = ignore.read_text(encoding="utf-8")
|
|
481
|
-
missing = [f"{d}/" for d in park if f"{d}/" not in body.splitlines()]
|
|
482
|
-
if missing and not args.dry_run:
|
|
483
|
-
with ignore.open("a", encoding="utf-8") as fh:
|
|
484
|
-
fh.write(("" if body.endswith("\n") or not body else "\n")
|
|
485
|
-
+ "".join(f"{m}\n" for m in missing))
|
|
486
|
-
if missing:
|
|
487
|
-
out.say(f" gitignore {ignore}: {' '.join(missing)}")
|
|
492
|
+
for h in homes if not args.no_gitignore else ():
|
|
493
|
+
_stale_ignore_lines(h, args, out)
|
|
488
494
|
if not found:
|
|
489
495
|
die("no harness found", 4)
|
|
490
496
|
out.say(f"{installed} harness(es) {'planned' if args.dry_run else 'installed'}")
|
|
491
497
|
|
|
492
498
|
|
|
499
|
+
def _stale_ignore_lines(h, args: argparse.Namespace, out: Result) -> None:
|
|
500
|
+
"""Older versions of install-shims appended `<sub>-disabled/` to a harness-home .gitignore.
|
|
501
|
+
Parks are central now, so offer to drop exactly those lines (never add any): only in
|
|
502
|
+
a git work tree, and only once `migrate` has emptied the legacy dir a line covers."""
|
|
503
|
+
ignore = h.home / ".gitignore"
|
|
504
|
+
if not ignore.is_file() or fs.git_toplevel(h.home) is None:
|
|
505
|
+
return
|
|
506
|
+
# older versions wrote EVERY harness's park names into each home, so match them all
|
|
507
|
+
legacy = {fs.legacy_park(Path(sub)).as_posix() + "/": fs.legacy_park(h.home / sub)
|
|
508
|
+
for t in harnesses().values() for subs in t.dirs.values() for sub in subs
|
|
509
|
+
if not Path(sub).is_absolute()}
|
|
510
|
+
try:
|
|
511
|
+
lines = ignore.read_bytes().decode("utf-8").splitlines(keepends=True)
|
|
512
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
513
|
+
out.say(f" gitignore {ignore}: unreadable ({e}); stale park-dir lines not checked")
|
|
514
|
+
return
|
|
515
|
+
|
|
516
|
+
def report(status: str, lines_: list[str], what: str) -> None:
|
|
517
|
+
msg = f"{ignore}: {what}"
|
|
518
|
+
out.say(f" gitignore {msg}")
|
|
519
|
+
out.row(h.name, None, None, "install-shims", status, msg, show=False,
|
|
520
|
+
gitignore=str(ignore), stale=lines_)
|
|
521
|
+
|
|
522
|
+
stale = list(dict.fromkeys(ln.rstrip("\r\n") for ln in lines if ln.rstrip("\r\n") in legacy))
|
|
523
|
+
if busy := [s for s in stale if legacy[s].exists() or legacy[s].is_symlink()]:
|
|
524
|
+
report("skipped", busy, f"{' '.join(busy)} still cover(s) a legacy park dir -- "
|
|
525
|
+
f"run `agent-toggle migrate` first, then re-run install-shims")
|
|
526
|
+
if not (stale := [s for s in stale if s not in busy]):
|
|
527
|
+
return
|
|
528
|
+
shown = " ".join(stale)
|
|
529
|
+
if ignore.is_symlink(): # an atomic replace would turn the link into a file
|
|
530
|
+
return report("skipped", stale, f"stale {shown} (a symlink: remove them by hand)")
|
|
531
|
+
if args.dry_run:
|
|
532
|
+
return report("planned", stale, f"would remove stale {shown}")
|
|
533
|
+
if not args.gitignore and not (args.prompt and _interactive(out)):
|
|
534
|
+
return report("skipped", stale, f"stale {shown} (re-run with --gitignore to remove)")
|
|
535
|
+
if not args.gitignore:
|
|
536
|
+
try:
|
|
537
|
+
yes = input(f"remove stale park-dir lines from {ignore}? [y/N] ").strip().lower()
|
|
538
|
+
except EOFError:
|
|
539
|
+
yes = ""
|
|
540
|
+
if yes not in ("y", "yes"):
|
|
541
|
+
return report("skipped", stale, f"kept stale {shown}")
|
|
542
|
+
# every other line, ending included, is kept byte for byte
|
|
543
|
+
keep = "".join(ln for ln in lines if ln.rstrip("\r\n") not in stale)
|
|
544
|
+
fs.atomic_write(ignore, keep, mode=ignore.stat().st_mode & 0o777, newline="")
|
|
545
|
+
report("ok", stale, f"removed stale {shown}")
|
|
546
|
+
|
|
547
|
+
|
|
493
548
|
COMMANDS = ("ui", "pick", "status", "list", "cost", "migrate", "disable", "enable",
|
|
494
549
|
"install-shims", "profile", "undo", "doctor", "config")
|
|
495
550
|
_GLOBAL_FLAGS = ("--json", "-v", "--verbose")
|
|
@@ -604,10 +659,18 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
604
659
|
cp.add_argument("--type", choices=TYPES, help="only this resource type")
|
|
605
660
|
cp.add_argument("--project", metavar="dir",
|
|
606
661
|
help="price this project's <dir>/.claude and <dir>/.mcp.json, not user scope")
|
|
607
|
-
sub.add_parser("migrate", parents=[common],
|
|
662
|
+
sub.add_parser("migrate", parents=[common],
|
|
663
|
+
help="import ~/.claude-toggle state; move legacy park dirs")
|
|
608
664
|
sp = sub.add_parser("install-shims", parents=[common],
|
|
609
665
|
help="write the skill shim into every installed harness")
|
|
610
666
|
sp.add_argument("--dry-run", action="store_true", help="show the plan; change nothing")
|
|
667
|
+
gi = sp.add_mutually_exclusive_group()
|
|
668
|
+
gi.add_argument("--gitignore", action="store_true",
|
|
669
|
+
help="remove stale legacy park-dir lines from harness .gitignore files "
|
|
670
|
+
"without asking")
|
|
671
|
+
gi.add_argument("--no-gitignore", action="store_true",
|
|
672
|
+
help="skip the stale park-dir line check")
|
|
673
|
+
sp.set_defaults(prompt=True) # the config menu passes False: no y/n under curses
|
|
611
674
|
pp = sub.add_parser("profile", parents=[common],
|
|
612
675
|
help="save / apply / diff / list named sets of live items")
|
|
613
676
|
pp.add_argument("action", help="save | apply | diff | list")
|
|
@@ -20,7 +20,7 @@ from . import fs, ops, store
|
|
|
20
20
|
from .backends.flag_json import jsonc_loads
|
|
21
21
|
from .backends.mcp_json import claude_mcp_config
|
|
22
22
|
from .harnesses import Harness, harnesses
|
|
23
|
-
from .mechanisms import _refusal, dir_view, settle
|
|
23
|
+
from .mechanisms import _refusal, dir_view, legacy_parks, settle
|
|
24
24
|
from .output import CliError, Result
|
|
25
25
|
from .store import load_state
|
|
26
26
|
|
|
@@ -152,11 +152,12 @@ def _check_layout(out: Result, h: Harness, table: dict) -> None:
|
|
|
152
152
|
start = len(out.rows)
|
|
153
153
|
for type_, subs in h.dirs.items():
|
|
154
154
|
views = [dir_view(table, h.name, type_, h.home, s) for s in subs]
|
|
155
|
+
# a legacy sibling park dir still counts: cmd_doctor reports it
|
|
155
156
|
cands = [p for s, v in zip(subs, views) for c in (h.home / s, v.live)
|
|
156
|
-
for p in (c,
|
|
157
|
+
for p in (c, fs.legacy_park(c))] + [v.parked for v in views]
|
|
157
158
|
if not any(p.exists() or p.is_symlink() for p in cands):
|
|
158
159
|
_row(out, h.name, type_, None, "absent",
|
|
159
|
-
f"no {' / '.join(subs)} dir
|
|
160
|
+
f"no {' / '.join(subs)} dir under {h.home} (and nothing parked)")
|
|
160
161
|
if others := sorted({x for v in views for x in v.sharers} - {h.name}):
|
|
161
162
|
_row(out, h.name, type_, None, "note", f"dir shared with {', '.join(others)} "
|
|
162
163
|
f"(disable here also affects them)")
|
|
@@ -369,7 +370,7 @@ def _check_orphans(out: Result, state: dict, table: dict, only: str | None,
|
|
|
369
370
|
pass
|
|
370
371
|
root = fs.parked_dir()
|
|
371
372
|
for d in sorted(root.iterdir()) if root.is_dir() else ():
|
|
372
|
-
if d.name.startswith("."):
|
|
373
|
+
if d.name.startswith(".") or d == fs.parked_dir() / "user": # user scope: see above
|
|
373
374
|
continue
|
|
374
375
|
if d.name not in digests:
|
|
375
376
|
if d.is_dir() and not d.is_symlink() and not any(
|
|
@@ -473,6 +474,9 @@ def cmd_doctor(harness: str | None, out: Result) -> list[Fix]:
|
|
|
473
474
|
_row(out, harness, None, None, "note", f"not installed ({table[harness].home} does not exist)")
|
|
474
475
|
for h in installed:
|
|
475
476
|
_check_layout(out, h, table)
|
|
477
|
+
for old, v, type_ in legacy_parks(table, harness): # once per dir, shared or not
|
|
478
|
+
_row(out, v.owner, type_, None, "warn", f"{old} is a legacy park dir (items now park "
|
|
479
|
+
f"under {v.parked}); fix: run: agent-toggle migrate")
|
|
476
480
|
try:
|
|
477
481
|
state = load_state(write_back=False, check_entries=False) # bad entries get rows
|
|
478
482
|
except CliError as e:
|
|
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
import contextlib
|
|
10
10
|
import errno
|
|
11
|
+
import hashlib
|
|
11
12
|
import importlib
|
|
12
13
|
import json
|
|
13
14
|
import os
|
|
@@ -18,7 +19,8 @@ from pathlib import Path
|
|
|
18
19
|
|
|
19
20
|
TEXT_SUFFIXES = {".md", ".sh", ".py", ".js", ".mjs", ".cjs", ".ts", ".json",
|
|
20
21
|
".yaml", ".yml", ".toml", ".txt", ".zsh", ".bash"}
|
|
21
|
-
# Directories never worth walking when deciding if a companion is shared.
|
|
22
|
+
# Directories never worth walking when deciding if a companion is shared. The
|
|
23
|
+
# `*-disabled` names are legacy sibling park dirs, still there until `migrate`.
|
|
22
24
|
PRUNE = {".git", "node_modules", "cache", "__pycache__", "dist", "build",
|
|
23
25
|
"skills-disabled", "agents-disabled", "commands-disabled", "rules-disabled",
|
|
24
26
|
"prompts-disabled", "command-disabled", "venv"}
|
|
@@ -70,6 +72,30 @@ def parked_dir() -> Path:
|
|
|
70
72
|
return state_dir() / "parked"
|
|
71
73
|
|
|
72
74
|
|
|
75
|
+
def user_park(owner: str, sub: str) -> Path:
|
|
76
|
+
"""Where `owner`'s user-scope items from `<owner home>/<sub>` park: never inside a
|
|
77
|
+
harness home (Claude Code loads commands/ and rules/ recursively, so a park dir
|
|
78
|
+
nested there would still load). An absolute sub (an OpenCode skills.paths
|
|
79
|
+
redirect) parks as ext-<sha1(its resolved path)[:8]>."""
|
|
80
|
+
base = parked_dir() / "user" / owner
|
|
81
|
+
if not Path(sub).is_absolute():
|
|
82
|
+
return base / sub
|
|
83
|
+
return base / f"ext-{hashlib.sha1(str(Path(sub).resolve()).encode('utf-8')).hexdigest()[:8]}"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def legacy_park(live: Path) -> Path:
|
|
87
|
+
"""The legacy sibling park dir `<live>-disabled`: only `migrate` and its hints use it."""
|
|
88
|
+
return live.with_name(live.name + "-disabled")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def park_unignored() -> Path | None:
|
|
92
|
+
"""The work-tree root holding parked_dir() WITHOUT ignoring it (e.g. a tracked $HOME),
|
|
93
|
+
else None: outside any work tree a park dir cannot dirty `git status`."""
|
|
94
|
+
park = parked_dir().resolve() # git_toplevel is resolved; /var vs /private/var
|
|
95
|
+
top = git_toplevel(next(p for p in (park, *park.parents) if p.is_dir()))
|
|
96
|
+
return top if top and not gitignored(park, top) else None
|
|
97
|
+
|
|
98
|
+
|
|
73
99
|
def contained(path: Path, *roots: Path) -> bool:
|
|
74
100
|
"""True if `path` sits inside one of `roots` (DESIGN s6.1 rows 1-2).
|
|
75
101
|
|
|
@@ -224,13 +250,14 @@ def lock():
|
|
|
224
250
|
pass
|
|
225
251
|
|
|
226
252
|
|
|
227
|
-
def atomic_write(path: Path, text: str, mode: int = 0o600) -> None:
|
|
228
|
-
"""Write `text` to `path` via a same-dir tmp file created with `mode`.
|
|
253
|
+
def atomic_write(path: Path, text: str, mode: int = 0o600, newline: str | None = None) -> None:
|
|
254
|
+
"""Write `text` to `path` via a same-dir tmp file created with `mode`.
|
|
255
|
+
`newline=""` writes line endings as given (a user file edited in place)."""
|
|
229
256
|
tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
230
257
|
tmp.unlink(missing_ok=True) # leftover from a crashed run with our PID
|
|
231
258
|
fd = os.open(tmp, os.O_CREAT | os.O_EXCL | os.O_WRONLY, mode)
|
|
232
259
|
try:
|
|
233
|
-
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
260
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline=newline) as fh:
|
|
234
261
|
fh.write(text)
|
|
235
262
|
fh.flush()
|
|
236
263
|
os.fsync(fh.fileno())
|
|
@@ -132,18 +132,29 @@ def commands(default_harness: str = "claude") -> dict[str, Cmd]:
|
|
|
132
132
|
"sync-ci: show the plan; set nothing"))],
|
|
133
133
|
examples=["config", "config test", "config sync-ci --dry-run"]),
|
|
134
134
|
"install-shims": Cmd(
|
|
135
|
-
"install-shims [--dry-run]",
|
|
135
|
+
"install-shims [--dry-run] [--gitignore | --no-gitignore]",
|
|
136
136
|
t("help.install_shims.summary", "write the skill shim into every installed harness"),
|
|
137
137
|
t("help.install_shims.desc", "Install the agent-toggle skill so each harness's "
|
|
138
|
-
"agent can call this tool
|
|
138
|
+
"agent can call this tool, and report stale "
|
|
139
|
+
"legacy `<dir>-disabled/` lines in a harness "
|
|
140
|
+
"home's .gitignore (asked y/n on a terminal). "
|
|
139
141
|
"On a terminal, then offers y/n for each "
|
|
140
142
|
"problem doctor can fix."),
|
|
141
|
-
options=[_dry_run()
|
|
142
|
-
|
|
143
|
+
options=[_dry_run(),
|
|
144
|
+
("--gitignore", t("help.opt.gitignore",
|
|
145
|
+
"remove stale park-dir .gitignore lines without asking")),
|
|
146
|
+
("--no-gitignore", t("help.opt.no_gitignore",
|
|
147
|
+
"skip the stale park-dir .gitignore check"))],
|
|
148
|
+
examples=["install-shims --dry-run", "install-shims --harness codex",
|
|
149
|
+
"install-shims --gitignore"]),
|
|
143
150
|
"migrate": Cmd(
|
|
144
151
|
"migrate",
|
|
145
|
-
t("help.migrate.summary",
|
|
146
|
-
|
|
152
|
+
t("help.migrate.summary",
|
|
153
|
+
"import ~/.claude-toggle state; move legacy park dirs"),
|
|
154
|
+
t("help.migrate.desc", "Import the state of the older claude-toggle tool once, "
|
|
155
|
+
"and move legacy `<dir>-disabled/` park dirs out of the "
|
|
156
|
+
"harness homes into ~/.agent-toggle/parked/user/. "
|
|
157
|
+
"Safe to re-run."),
|
|
147
158
|
examples=["migrate"]),
|
|
148
159
|
}
|
|
149
160
|
|