agent-toggle 0.6.2__tar.gz → 0.7.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/PKG-INFO +45 -13
  2. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/README.md +44 -12
  3. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle/__init__.py +1 -1
  4. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle/cli.py +94 -31
  5. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle/doctor.py +8 -4
  6. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle/fs.py +46 -5
  7. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle/help.py +17 -6
  8. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle/locales/zh_tw_cli.py +9 -3
  9. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle/mechanisms.py +124 -25
  10. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle/store.py +8 -5
  11. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle/ui/config_menu.py +3 -1
  12. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle.egg-info/PKG-INFO +45 -13
  13. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/tests/test_alias.py +20 -14
  14. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/tests/test_cli.py +3 -2
  15. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/tests/test_cli_surface.py +1 -1
  16. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/tests/test_color.py +2 -2
  17. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/tests/test_conformance.py +5 -9
  18. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/tests/test_containment.py +2 -2
  19. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/tests/test_crash.py +2 -2
  20. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/tests/test_doctor.py +22 -13
  21. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/tests/test_fs.py +17 -0
  22. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/tests/test_harnesses.py +1 -1
  23. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/tests/test_install.py +86 -8
  24. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/tests/test_mechanisms.py +7 -5
  25. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/tests/test_store.py +165 -9
  26. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/LICENSE +0 -0
  27. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle/__main__.py +0 -0
  28. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle/backends/__init__.py +0 -0
  29. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle/backends/flag_json.py +0 -0
  30. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle/backends/mcp_json.py +0 -0
  31. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle/backends/mcp_toml.py +0 -0
  32. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle/backends/plugin_cli.py +0 -0
  33. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle/companions.py +0 -0
  34. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle/config.py +0 -0
  35. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle/cost.py +0 -0
  36. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle/harnesses.py +0 -0
  37. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle/i18n.py +0 -0
  38. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle/locales/__init__.py +0 -0
  39. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle/locales/zh_tw_common.py +0 -0
  40. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle/locales/zh_tw_config.py +0 -0
  41. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle/locales/zh_tw_picker.py +0 -0
  42. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle/ops.py +0 -0
  43. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle/output.py +0 -0
  44. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle/profiles.py +0 -0
  45. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle/settings.py +0 -0
  46. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle/shims/claude.md.tmpl +0 -0
  47. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle/shims/generic.md.tmpl +0 -0
  48. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle/spinner.py +0 -0
  49. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle/toml_check.py +0 -0
  50. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle/ui/__init__.py +0 -0
  51. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle/ui/menu.py +0 -0
  52. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle/ui/model.py +0 -0
  53. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle/ui/picker.py +0 -0
  54. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle/ui/theme.py +0 -0
  55. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle/undo.py +0 -0
  56. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle/update_check.py +0 -0
  57. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle/update_prompt.py +0 -0
  58. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle.egg-info/SOURCES.txt +0 -0
  59. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle.egg-info/dependency_links.txt +0 -0
  60. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle.egg-info/entry_points.txt +0 -0
  61. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle.egg-info/requires.txt +0 -0
  62. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/agent_toggle.egg-info/top_level.txt +0 -0
  63. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/pyproject.toml +0 -0
  64. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/setup.cfg +0 -0
  65. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/tests/test_backends.py +0 -0
  66. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/tests/test_companions.py +0 -0
  67. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/tests/test_completions.py +0 -0
  68. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/tests/test_config.py +0 -0
  69. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/tests/test_config_menu.py +0 -0
  70. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/tests/test_cost.py +0 -0
  71. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/tests/test_encoding.py +0 -0
  72. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/tests/test_help.py +0 -0
  73. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/tests/test_i18n.py +0 -0
  74. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/tests/test_menu.py +0 -0
  75. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/tests/test_picker.py +0 -0
  76. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/tests/test_platform.py +0 -0
  77. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/tests/test_profiles.py +0 -0
  78. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/tests/test_project.py +0 -0
  79. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/tests/test_safety.py +0 -0
  80. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/tests/test_secret_audit.py +0 -0
  81. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/tests/test_settings.py +0 -0
  82. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/tests/test_theme.py +0 -0
  83. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/tests/test_undo.py +0 -0
  84. {agent_toggle-0.6.2 → agent_toggle-0.7.2}/tests/test_update_check.py +0 -0
  85. {agent_toggle-0.6.2 → agent_toggle-0.7.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.6.2
3
+ Version: 0.7.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
@@ -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, and adds the park dirs (`skills-disabled/` etc.) to a `.gitignore`
95
- already present in that harness home. Each harness gets its own template from
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 `rules-disabled/`).
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-disabled/beta
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. Park dirs that are not gitignored get a warning.** Without it, every
801
- disable leaves dozens of deletion lines in `git status`. The warning is shown once
802
- per park dir, and only for a dir inside a git work tree (elsewhere `git status`
803
- cannot be dirtied and the `.gitignore` fix would not apply).
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: park dirs are gitignored and do not sync.
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, and adds the park dirs (`skills-disabled/` etc.) to a `.gitignore`
63
- already present in that harness home. Each harness gets its own template from
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 `rules-disabled/`).
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-disabled/beta
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. Park dirs that are not gitignored get a warning.** Without it, every
769
- disable leaves dozens of deletion lines in `git status`. The warning is shown once
770
- per park dir, and only for a dir inside a git work tree (elsewhere `git status`
771
- cannot be dirtied and the `.gitignore` fix would not apply).
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: park dirs are gitignored and do not sync.
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
@@ -1,3 +1,3 @@
1
1
  """agent-toggle: temporarily disable / re-enable AI-agent resources."""
2
2
 
3
- __version__ = "0.6.2"
3
+ __version__ = "0.7.2"
@@ -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 ~/.claude-toggle state
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] # write the skill shim into each harness
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 dir_view, settle, sync_marker_note, validate_name
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
- table = harnesses()
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
- out.say(buf.getvalue().rstrip("\n"))
356
- out.row(None, None, None, "migrate", "ok", "; ".join(lines), show=False)
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; keep park dirs out of an existing harness-home .gitignore.
409
- A file there that is not our shim (no SHIM_MARKER) is refused, never overwritten.
410
- A harness switched off in settings is skipped unless named with --harness."""
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
- # A tracked park dir turns every disable into deletion noise in
477
- # `git status`; only touch a .gitignore that already exists.
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], help="import an older ~/.claude-toggle state")
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, c.with_name(c.name + "-disabled"))]
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 (or *-disabled park dir) under {h.home}")
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())
@@ -389,6 +416,9 @@ def safe_move(src: Path, dest_dir: Path) -> Path:
389
416
  if target.exists() or target.is_symlink():
390
417
  raise FileExistsError(f"{target} already exists -- refusing to overwrite")
391
418
 
419
+ if src.is_symlink() and link_text(src, dest_dir) != os.readlink(src):
420
+ _copy_move(src, target) # a rename keeps the relative text, now dangling
421
+ return target
392
422
  try:
393
423
  os.rename(src, target) # same filesystem: one atomic step
394
424
  except OSError as e:
@@ -398,6 +428,17 @@ def safe_move(src: Path, dest_dir: Path) -> Path:
398
428
  return target
399
429
 
400
430
 
431
+ def link_text(src: Path, dest_dir: Path) -> str:
432
+ """The symlink `src`'s text as seen from `dest_dir`: a relative link keeps its target,
433
+ not its text, since parks sit at another depth (skills/ vs parked/user/<h>/skills/)."""
434
+ link = os.readlink(src)
435
+ if os.path.isabs(link):
436
+ return link
437
+ # the kernel resolves a relative link from the link's physical dir
438
+ target = os.path.normpath(os.path.join(os.path.realpath(src.parent), link))
439
+ return os.path.relpath(target, os.path.realpath(dest_dir))
440
+
441
+
401
442
  def move_leftovers(src: Path, target: Path) -> tuple[Path, Path]:
402
443
  """(half copy, trash) a killed cross-fs move can leave beside `target` / `src`."""
403
444
  return (target.with_name(f".{target.name}.agent-toggle-tmp"),
@@ -450,7 +491,7 @@ def _copy_move(src: Path, target: Path) -> None:
450
491
  remove_leftover(tmp) # a killed earlier attempt's half copy
451
492
  try:
452
493
  if src.is_symlink():
453
- os.symlink(os.readlink(src), tmp)
494
+ os.symlink(link_text(src, target.parent), tmp)
454
495
  elif src.is_dir():
455
496
  shutil.copytree(src, tmp, symlinks=True)
456
497
  else: