agent-toggle 0.7.0__tar.gz → 0.7.3__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.7.0 → agent_toggle-0.7.3}/PKG-INFO +5 -1
  2. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/README.md +4 -0
  3. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle/__init__.py +1 -1
  4. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle/backends/plugin_cli.py +2 -1
  5. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle/fs.py +15 -1
  6. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle/mechanisms.py +25 -0
  7. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle/ui/picker.py +21 -12
  8. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle.egg-info/PKG-INFO +5 -1
  9. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/tests/test_fs.py +17 -0
  10. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/tests/test_picker.py +13 -0
  11. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/tests/test_platform.py +12 -0
  12. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/tests/test_store.py +14 -0
  13. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/LICENSE +0 -0
  14. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle/__main__.py +0 -0
  15. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle/backends/__init__.py +0 -0
  16. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle/backends/flag_json.py +0 -0
  17. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle/backends/mcp_json.py +0 -0
  18. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle/backends/mcp_toml.py +0 -0
  19. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle/cli.py +0 -0
  20. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle/companions.py +0 -0
  21. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle/config.py +0 -0
  22. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle/cost.py +0 -0
  23. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle/doctor.py +0 -0
  24. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle/harnesses.py +0 -0
  25. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle/help.py +0 -0
  26. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle/i18n.py +0 -0
  27. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle/locales/__init__.py +0 -0
  28. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle/locales/zh_tw_cli.py +0 -0
  29. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle/locales/zh_tw_common.py +0 -0
  30. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle/locales/zh_tw_config.py +0 -0
  31. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle/locales/zh_tw_picker.py +0 -0
  32. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle/ops.py +0 -0
  33. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle/output.py +0 -0
  34. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle/profiles.py +0 -0
  35. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle/settings.py +0 -0
  36. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle/shims/claude.md.tmpl +0 -0
  37. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle/shims/generic.md.tmpl +0 -0
  38. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle/spinner.py +0 -0
  39. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle/store.py +0 -0
  40. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle/toml_check.py +0 -0
  41. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle/ui/__init__.py +0 -0
  42. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle/ui/config_menu.py +0 -0
  43. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle/ui/menu.py +0 -0
  44. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle/ui/model.py +0 -0
  45. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle/ui/theme.py +0 -0
  46. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle/undo.py +0 -0
  47. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle/update_check.py +0 -0
  48. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle/update_prompt.py +0 -0
  49. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle.egg-info/SOURCES.txt +0 -0
  50. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle.egg-info/dependency_links.txt +0 -0
  51. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle.egg-info/entry_points.txt +0 -0
  52. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle.egg-info/requires.txt +0 -0
  53. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/agent_toggle.egg-info/top_level.txt +0 -0
  54. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/pyproject.toml +0 -0
  55. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/setup.cfg +0 -0
  56. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/tests/test_alias.py +0 -0
  57. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/tests/test_backends.py +0 -0
  58. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/tests/test_cli.py +0 -0
  59. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/tests/test_cli_surface.py +0 -0
  60. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/tests/test_color.py +0 -0
  61. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/tests/test_companions.py +0 -0
  62. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/tests/test_completions.py +0 -0
  63. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/tests/test_config.py +0 -0
  64. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/tests/test_config_menu.py +0 -0
  65. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/tests/test_conformance.py +0 -0
  66. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/tests/test_containment.py +0 -0
  67. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/tests/test_cost.py +0 -0
  68. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/tests/test_crash.py +0 -0
  69. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/tests/test_doctor.py +0 -0
  70. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/tests/test_encoding.py +0 -0
  71. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/tests/test_harnesses.py +0 -0
  72. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/tests/test_help.py +0 -0
  73. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/tests/test_i18n.py +0 -0
  74. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/tests/test_install.py +0 -0
  75. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/tests/test_mechanisms.py +0 -0
  76. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/tests/test_menu.py +0 -0
  77. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/tests/test_profiles.py +0 -0
  78. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/tests/test_project.py +0 -0
  79. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/tests/test_safety.py +0 -0
  80. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/tests/test_secret_audit.py +0 -0
  81. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/tests/test_settings.py +0 -0
  82. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/tests/test_theme.py +0 -0
  83. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/tests/test_undo.py +0 -0
  84. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/tests/test_update_check.py +0 -0
  85. {agent_toggle-0.7.0 → agent_toggle-0.7.3}/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.7.0
3
+ Version: 0.7.3
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
@@ -855,6 +855,10 @@ ruff check .
855
855
  stdlib `unittest`, no fixtures, no network. Every test runs against a
856
856
  throwaway temp `HOME` and a stubbed `claude` CLI.
857
857
 
858
+ `python3 tools/wheel_smoke.py` builds the wheel, installs it without extras into a
859
+ fresh venv (what a PyPI user gets) and runs the CLI against a temp `HOME`; CI runs
860
+ it on macOS, Linux and Windows.
861
+
858
862
  ## CI notifications
859
863
 
860
864
  `.github/workflows/ci.yml` can send a Telegram message when the test matrix fails on a
@@ -823,6 +823,10 @@ ruff check .
823
823
  stdlib `unittest`, no fixtures, no network. Every test runs against a
824
824
  throwaway temp `HOME` and a stubbed `claude` CLI.
825
825
 
826
+ `python3 tools/wheel_smoke.py` builds the wheel, installs it without extras into a
827
+ fresh venv (what a PyPI user gets) and runs the CLI against a temp `HOME`; CI runs
828
+ it on macOS, Linux and Windows.
829
+
826
830
  ## CI notifications
827
831
 
828
832
  `.github/workflows/ci.yml` can send a Telegram message when the test matrix fails on a
@@ -1,3 +1,3 @@
1
1
  """agent-toggle: temporarily disable / re-enable AI-agent resources."""
2
2
 
3
- __version__ = "0.7.0"
3
+ __version__ = "0.7.3"
@@ -28,7 +28,8 @@ def claude_bin() -> str | None:
28
28
  if found:
29
29
  return found
30
30
  home = fs.home()
31
- for cand in (home / ".local/bin/claude", home / ".claude/local/claude"):
31
+ for cand in (home / ".local/bin/claude", home / ".local/bin/claude.exe", # .exe: Windows
32
+ home / ".claude/local/claude"):
32
33
  if cand.is_file() and os.access(cand, os.X_OK):
33
34
  return str(cand)
34
35
  return None
@@ -416,6 +416,9 @@ def safe_move(src: Path, dest_dir: Path) -> Path:
416
416
  if target.exists() or target.is_symlink():
417
417
  raise FileExistsError(f"{target} already exists -- refusing to overwrite")
418
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
419
422
  try:
420
423
  os.rename(src, target) # same filesystem: one atomic step
421
424
  except OSError as e:
@@ -425,6 +428,17 @@ def safe_move(src: Path, dest_dir: Path) -> Path:
425
428
  return target
426
429
 
427
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
+
428
442
  def move_leftovers(src: Path, target: Path) -> tuple[Path, Path]:
429
443
  """(half copy, trash) a killed cross-fs move can leave beside `target` / `src`."""
430
444
  return (target.with_name(f".{target.name}.agent-toggle-tmp"),
@@ -477,7 +491,7 @@ def _copy_move(src: Path, target: Path) -> None:
477
491
  remove_leftover(tmp) # a killed earlier attempt's half copy
478
492
  try:
479
493
  if src.is_symlink():
480
- os.symlink(os.readlink(src), tmp)
494
+ os.symlink(link_text(src, target.parent), tmp)
481
495
  elif src.is_dir():
482
496
  shutil.copytree(src, tmp, symlinks=True)
483
497
  else:
@@ -360,6 +360,31 @@ def migrate_parks(state: dict, table: dict | None = None) -> list[str]:
360
360
  lines.append(f"moved {len(moved)} item(s) {old} -> {v.parked}"
361
361
  + (f", repointed {repointed} state entry(ies)" if repointed else "")
362
362
  + ("" if _exists(old) else f", removed {old}"))
363
+ return lines + repair_parked_links(table)
364
+
365
+
366
+ def repair_parked_links(table: dict) -> list[str]:
367
+ """0.7.0's migrate moved relative symlinks with their text unchanged, so they dangle
368
+ from the deeper park dir; re-aim each one whose text still resolves from its live dir."""
369
+ lines, seen = [], set()
370
+ for hn, h in table.items():
371
+ for type_, subs in h.dirs.items():
372
+ for sub in subs:
373
+ v = dir_view(table, hn, type_, h.home, sub)
374
+ if v.parked in seen or not v.parked.is_dir() or v.parked.is_symlink():
375
+ continue
376
+ seen.add(v.parked)
377
+ for p in sorted(v.parked.iterdir()):
378
+ if not p.is_symlink() or p.exists() or os.path.isabs(link := os.readlink(p)):
379
+ continue
380
+ target = os.path.normpath(os.path.join(os.path.realpath(v.live), link))
381
+ if not os.path.exists(target):
382
+ continue # dangles from its live dir too: not ours
383
+ tmp = fs.move_leftovers(p, p)[0]
384
+ fs.remove_leftover(tmp)
385
+ os.symlink(os.path.relpath(target, os.path.realpath(v.parked)), tmp)
386
+ fs.replace(tmp, p) # atomic: the link is never missing
387
+ lines.append(f"re-aimed dangling link {p} -> {target}")
363
388
  return lines
364
389
 
365
390
 
@@ -201,22 +201,18 @@ def heading(type_: str, count: int, width: int, g: theme.Glyphs) -> list[theme.S
201
201
  return [(label, "title"), (str(count), "muted"), (" " + g.h * max(0, rest), "muted")]
202
202
 
203
203
 
204
- def detail_lines(r: Row, width: int, g: theme.Glyphs) -> list[list[theme.Segment]]:
205
- """The detail pane for the cursor row: one segment list per screen line."""
204
+ def detail_lines(r: Row, width: int, g: theme.Glyphs,
205
+ height: int | None = None) -> list[list[theme.Segment]]:
206
+ """The detail pane for the cursor row: one segment list per screen line. With `height`,
207
+ the description is cut (with an ellipsis) so the fields below it still fit."""
206
208
  lab = 12 # "description" is 11 cells; leave a gap
207
209
  val = max(1, width - lab)
208
210
  out = [[(theme.truncate(r.name, width, g.ellipsis), "title")], []]
211
+ rest: list[list[theme.Segment]] = []
209
212
 
210
213
  def field(label: str, text: str, role: str = "text") -> None:
211
214
  for n in range(0, max(1, len(text)), val): # a long path wraps
212
- out.append([(pad(label if n == 0 else "", lab), "muted"), (text[n:n + val], role)])
213
-
214
- if r.description: # frontmatter prose, not a path
215
- chunks = wrap_cells(r.description, val)
216
- label = t("picker.d.description", "description")
217
- for n, chunk in enumerate(chunks):
218
- out.append([(pad(label if n == 0 else "", lab), "muted"), (chunk, "text")])
219
- out.append([])
215
+ rest.append([(pad(label if n == 0 else "", lab), "muted"), (text[n:n + val], role)])
220
216
 
221
217
  state = t("picker.state.live", "live") if r.enabled else t("picker.state.parked", "parked")
222
218
  field(t("picker.d.harness", "harness"), r.harness, theme.harness_role(r.harness))
@@ -242,7 +238,20 @@ def detail_lines(r: Row, width: int, g: theme.Glyphs) -> list[list[theme.Segment
242
238
  field(t("picker.d.since", "since"), r.since[:19].replace("T", " "))
243
239
  if r.mechanism:
244
240
  field(t("picker.d.mechanism", "mechanism"), r.mechanism)
245
- return out
241
+
242
+ if r.description: # frontmatter prose, not a path
243
+ chunks = wrap_cells(r.description, val)
244
+ # the "staged" line comes and goes with Space; reserving it keeps the text from jumping
245
+ spare = len(rest) + (not r.changed)
246
+ room = len(chunks) if height is None else max(1, height - len(out) - spare - 1)
247
+ if len(chunks) > room:
248
+ chunks = chunks[:room - 1] + [theme.truncate(" ".join(chunks[room - 1:]), val,
249
+ g.ellipsis)]
250
+ label = t("picker.d.description", "description")
251
+ for n, chunk in enumerate(chunks):
252
+ out.append([(pad(label if n == 0 else "", lab), "muted"), (chunk, "text")])
253
+ out.append([])
254
+ return out + rest
246
255
 
247
256
 
248
257
  def chips(v: View, g: theme.Glyphs) -> list[tuple[str, str]]:
@@ -322,7 +331,7 @@ def draw(win, v: View, shown: list[Row], pal: theme.Palette, g: theme.Glyphs,
322
331
  else:
323
332
  put(y, row_segments(line[1], lw, v, g, line[1] is cursor_row, any_shared))
324
333
  if dw:
325
- pane = detail_lines(cursor_row, dw - 2, g) if cursor_row else []
334
+ pane = detail_lines(cursor_row, dw - 2, g, body) if cursor_row else []
326
335
  for i in range(body):
327
336
  put(BODY_TOP + i, [(g.v, "muted"), (" ", "text"), *(pane[i] if i < len(pane) else [])],
328
337
  lw)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-toggle
3
- Version: 0.7.0
3
+ Version: 0.7.3
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
@@ -855,6 +855,10 @@ ruff check .
855
855
  stdlib `unittest`, no fixtures, no network. Every test runs against a
856
856
  throwaway temp `HOME` and a stubbed `claude` CLI.
857
857
 
858
+ `python3 tools/wheel_smoke.py` builds the wheel, installs it without extras into a
859
+ fresh venv (what a PyPI user gets) and runs the CLI against a temp `HOME`; CI runs
860
+ it on macOS, Linux and Windows.
861
+
858
862
  ## CI notifications
859
863
 
860
864
  `.github/workflows/ci.yml` can send a Telegram message when the test matrix fails on a
@@ -38,6 +38,23 @@ class SafeMoveTest(SandboxCase):
38
38
  # existing parked copy untouched
39
39
  self.assertEqual((self.home / "skills-disabled/a/SKILL.md").read_text(encoding="utf-8"), "old")
40
40
 
41
+ @unittest.skipUnless(CAN_SYMLINK, "cannot create symlinks here")
42
+ def test_relative_link_keeps_its_target_across_depths(self) -> None:
43
+ real = self.write(".repos/r/demo/SKILL.md", "hi").parent
44
+ link = self.home / "skills" / "demo"
45
+ link.parent.mkdir(parents=True, exist_ok=True)
46
+ link.symlink_to("../.repos/r/demo")
47
+ parked = fs.safe_move(link, fs.user_park("claude", "skills"))
48
+ self.assertEqual(parked.resolve(), real.resolve()) # not dangling while parked
49
+ back = fs.safe_move(parked, self.home / "skills")
50
+ # the round trip is exact (Windows re-aims with native separators)
51
+ self.assertEqual(os.path.normpath(os.readlink(back)), os.path.normpath("../.repos/r/demo"))
52
+ absolute = self.home / "skills" / "abs"
53
+ absolute.symlink_to(real)
54
+ text = os.readlink(absolute) # Windows may add \\?\
55
+ self.assertEqual(os.readlink(fs.safe_move(absolute, fs.user_park("claude", "skills"))),
56
+ text) # absolute: untouched
57
+
41
58
 
42
59
  POSIX = os.name != "nt"
43
60
 
@@ -465,6 +465,19 @@ class LayoutTest(ScreenCase):
465
465
  self.assertIn("alpha beta", body)
466
466
  self.assertIn("gamma delta", body)
467
467
 
468
+ def test_long_description_is_cut_so_the_fields_still_show(self) -> None:
469
+ r = model.Row("claude", "skill", "solo", True, description="word " * 200,
470
+ mechanism="move")
471
+ lines = picker.detail_lines(r, 30, theme.ASCII, height=12)
472
+ self.assertLessEqual(len(lines), 12)
473
+ flat = ["".join(s for s, _ in line) for line in lines]
474
+ self.assertTrue(any(line.rstrip().endswith("...") for line in flat))
475
+ self.assertTrue(any(line.startswith("mechanism") for line in flat))
476
+ r.staged = False # toggling adds the "staged" line, not a cut
477
+ toggled = picker.detail_lines(r, 30, theme.ASCII, height=12)
478
+ self.assertLessEqual(len(toggled), 12)
479
+ self.assertEqual(lines[:8], toggled[:8])
480
+
468
481
  def test_detail_pane_description_is_translated(self) -> None:
469
482
  self.addCleanup(i18n.set_language, i18n.LANGUAGE)
470
483
  i18n.set_language("zh-TW")
@@ -11,6 +11,7 @@ from base import SandboxCase
11
11
 
12
12
  from agent_toggle import fs
13
13
  from agent_toggle import mechanisms as mech
14
+ from agent_toggle.backends import plugin_cli
14
15
 
15
16
 
16
17
  class PluginIdTest(SandboxCase):
@@ -103,5 +104,16 @@ class CaseRulesTest(SandboxCase):
103
104
  self.assertEqual(mech.live_names(self.home / "commands", "command"), ["group:sub:tool"])
104
105
 
105
106
 
107
+ class ClaudeBinTest(SandboxCase):
108
+ def test_off_path_windows_install_is_found_by_its_exe_name(self) -> None:
109
+ """The native Windows installer writes ~/.local/bin/claude.exe; PATH may lack it."""
110
+ plugin_cli.which = lambda name: None
111
+ exe = self.tmp / ".local" / "bin" / "claude.exe"
112
+ exe.parent.mkdir(parents=True)
113
+ exe.touch()
114
+ exe.chmod(0o755)
115
+ self.assertEqual(plugin_cli.claude_bin(), str(exe))
116
+
117
+
106
118
  if __name__ == "__main__":
107
119
  unittest.main()
@@ -152,6 +152,20 @@ class MigrateParksTest(CliCase):
152
152
  self.assertEqual(store.load_state()["disabled"]["claude:skill:demo-skill"]["parked_at"],
153
153
  str(self.user_parked("skills", "demo-skill")))
154
154
 
155
+ @unittest.skipUnless(CAN_SYMLINK, "cannot create symlinks here")
156
+ def test_a_link_left_dangling_by_0_7_0_is_re_aimed(self) -> None:
157
+ real = self.write(".repos/r/linked/SKILL.md", "hi").parent
158
+ link = self.user_parked("skills", "linked")
159
+ link.parent.mkdir(parents=True, exist_ok=True)
160
+ link.symlink_to("../.repos/r/linked") # 0.7.0 kept the text
161
+ gone = self.user_parked("skills", "gone")
162
+ gone.symlink_to("../.repos/nowhere") # dangles from skills/ too
163
+ self.run_json("migrate")
164
+ self.assertEqual(link.resolve(), real.resolve())
165
+ self.assertEqual(os.readlink(gone), "../.repos/nowhere") # left alone
166
+ rc, env = self.run_json("migrate")
167
+ self.assertNotIn("re-aimed", env["results"][0]["detail"]) # idempotent
168
+
155
169
 
156
170
  class LogSchemaTest(SandboxCase):
157
171
  FIELDS = {"ts", "harness", "type", "name", "action", "result", "batch",
File without changes
File without changes