File size: 49,336 Bytes
5cf6791
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
"""moth hooks / moth hook / moth unhook : see and change which coding agents tell moth about their turns.

  moth hooks [--project DIR]                 harnesses found on this machine, and whether moth is hooked into each
  moth hook <harness ...> | --all [flags]    install moth's hook / plugin (asks first; --yes skips, --dry-run shows)
  moth unhook <harness ...> | --all [flags]  remove exactly what `moth hook` adds, nothing else

Harnesses: claude-code, codex, hermes, pi, opencode, dsh (the adapters in moth/integrations/, README there).
Rules every change follows:
  - a config file it edits is backed up first, next to it: <file>.bak-moth-<timestamp>;
  - JSON / YAML / TOML configs are merged: other hooks, plugins and keys stay as they were;
  - installing twice changes nothing; a copied adapter (Pi, OpenCode, Hermes) that differs from this install's copy
    (say after `moth update`) is replaced, the old copy backed up (Hermes: in ~/.moth/backups/, so Hermes doesn't load
    it as a second plugin);
  - unhook removes only moth's entries, and deletes a copied file only if it is moth's.
Stdlib only (no torch, no PyYAML): safe to run anywhere. Paths are read from the environment on every call
(HOME, XDG_CONFIG_HOME, CLAUDE_CONFIG_DIR, CODEX_HOME, HERMES_HOME, PI_CODING_AGENT_DIR).
"""
from __future__ import annotations

import argparse
import filecmp
import json
import os
import re
import shlex
import shutil
import subprocess
import sys
import tempfile
import time

HERE = os.path.dirname(os.path.abspath(__file__))
INTEGRATIONS = os.path.join(HERE, "integrations")
HARNESSES = ("claude-code", "codex", "hermes", "pi", "opencode", "dsh")
ALIASES = {"claude": "claude-code", "claude_code": "claude-code", "claudecode": "claude-code", "codex-cli": "codex",
           "hermes-agent": "hermes", "open-code": "opencode", "deepseek": "dsh"}
BINARIES = {"claude-code": "claude", "codex": "codex", "hermes": "hermes", "pi": "pi", "opencode": "opencode",
            "dsh": "dsh"}
SIGNATURE = {"pi": "// moth extension for Pi", "opencode": "// moth plugin for OpenCode"}
IGNORE = shutil.ignore_patterns("__pycache__", "*.pyc", ".DS_Store")


def prog() -> str:
    return "moth" if os.path.basename(sys.argv[0]) in ("moth", "moth.exe") else "python -m moth"


# --- paths (from the environment on every call, so tests and other HOMEs work) ----------------------------------
def home() -> str:
    return os.path.expanduser("~")


def xdg_config() -> str:
    return os.environ.get("XDG_CONFIG_HOME") or os.path.join(home(), ".config")


def claude_dir() -> str:
    return os.environ.get("CLAUDE_CONFIG_DIR") or os.path.join(home(), ".claude")


def claude_settings(project: str | None = None) -> str:
    if project:
        return os.path.join(os.path.abspath(project), ".claude", "settings.json")
    return os.path.join(claude_dir(), "settings.json")


def codex_dir() -> str:
    return os.environ.get("CODEX_HOME") or os.path.join(home(), ".codex")


def hermes_dir() -> str:
    return os.environ.get("HERMES_HOME") or os.path.join(home(), ".hermes")


def pi_dir() -> str:
    return os.environ.get("PI_CODING_AGENT_DIR") or os.path.join(home(), ".pi", "agent")


def opencode_dir() -> str:
    return os.path.join(xdg_config(), "opencode")


def dsh_dir() -> str:
    return os.path.join(home(), ".dsh")


def config_dir(name: str) -> str:
    return {"claude-code": claude_dir(), "codex": codex_dir(), "hermes": hermes_dir(),
            "pi": os.path.dirname(pi_dir()), "opencode": opencode_dir(), "dsh": dsh_dir()}[name]


def copy_target(name: str) -> str:
    return {"hermes": os.path.join(hermes_dir(), "plugins", "moth"),
            "pi": os.path.join(pi_dir(), "extensions", "moth.ts"),
            "opencode": os.path.join(opencode_dir(), "plugins", "moth.js")}[name]


def copy_source(name: str) -> str:
    return {"hermes": os.path.join(INTEGRATIONS, "hermes", "moth"), "pi": os.path.join(INTEGRATIONS, "pi", "moth.ts"),
            "opencode": os.path.join(INTEGRATIONS, "opencode", "moth.js")}[name]


def script(name: str) -> str:
    return os.path.join(INTEGRATIONS, name)


_WIN = os.name == "nt"


def command(*parts: str) -> str:
    return subprocess.list2cmdline(parts) if os.name == "nt" else shlex.join(parts)


def hook_command(*parts: str) -> str:
    """A hook's command string. On Windows with forward slashes: Claude Code can run hook commands through Git Bash,
    which eats unquoted backslashes (C:\\Python\\python.exe -> C:Pythonpython.exe); C:/Python/python.exe works in bash,
    cmd and PowerShell alike, and so does Python with a forward-slash script path."""
    if _WIN:
        return subprocess.list2cmdline([p.replace("\\", "/") for p in parts])
    return shlex.join(parts)


def ps_call(cmd: str) -> str:
    """Codex on Windows runs a hook command through PowerShell: `powershell -NoProfile -Command <cmd>`, the session's
    shell (codex-rs core/src/session/mod.rs build_hooks_config, core/src/shell.rs derive_exec_args, rust-v0.163.0).
    There a line that starts with a quoted path is a string, not a call ("Unexpected token"), so an interpreter path
    with a space (C:/Program Files/..., a venv under C:/Users/First Last) needs PowerShell's call operator `&` before
    it. Seen on windows-latest (tests/e2e): without it Codex ran nothing. Not for Claude Code, whose Git Bash rejects &."""
    return "& " + cmd if cmd.startswith('"') else cmd


HOOK_RUN = "hook-run"   # the installers' app: `<moth-hook> hook-run <integrations/x.py>` (packaging/moth_hook.py)


def appimage() -> str | None:
    """$APPIMAGE when this is moth's own AppImage (the frozen executable runs from inside its mount, $APPDIR); None
    otherwise. Every child of any AppImage inherits APPIMAGE (an editor shipped as one, and its terminal), so it alone
    doesn't say this moth is one."""
    image, appdir = os.environ.get("APPIMAGE"), os.environ.get("APPDIR")
    if not (getattr(sys, "frozen", False) and image and appdir):
        return None
    exe, root = os.path.realpath(sys.executable), os.path.realpath(appdir)
    return image if exe.startswith(root.rstrip(os.sep) + os.sep) else None


def frozen_exe() -> str | None:
    """The installers' bundled executable a hook runs instead of a Python (PyInstaller sets sys.frozen); None for a
    Python install. moth-hook(.exe) next to the app's executable: a small stdlib-only runner that starts in a fraction
    of the big executable's time (a hook runs on every agent turn), console on Windows (PowerShell, Codex's hook shell,
    doesn't wait for a windowed exe). AppImage: the .AppImage file ($APPIMAGE), whose AppRun hands `hook-run` to
    moth-hook, not its mount under /tmp/.mount_*, which moves on every launch."""
    if not getattr(sys, "frozen", False):
        return None
    if appimage():
        return appimage()
    exe = sys.executable
    runner = os.path.join(os.path.dirname(exe), "moth-hook.exe" if _WIN else "moth-hook")
    return runner if os.path.isfile(runner) else exe


def hook_script(name: str) -> str:
    """The integrations/<name> path a hook command names: this install's file; in an AppImage the bare relative
    integrations/<name> (the bundled copy, wherever the AppImage is mounted this time)."""
    if appimage():
        return "integrations/" + name
    return script(name)


def hook_argv(name: str, py: str | None = None) -> list[str]:
    """What a hook runs for integrations/<name>: [python, script], or [moth executable, hook-run, script] in the
    installers' app (py given: that Python, always)."""
    exe = None if py else frozen_exe()
    return [exe, HOOK_RUN, hook_script(name)] if exe else [py or sys.executable, script(name)]


def harness_command(name: str, py: str | None = None) -> str:
    """The hook command for integrations/<name> as `moth hook` writes it, per harness: Codex's gets ps_call on Windows."""
    cmd = hook_command(*hook_argv(name, py))
    return ps_call(cmd) if _WIN and name == "codex.py" else cmd


def _uncalled(h: dict, name: str) -> bool:
    """Windows: a Codex hook whose command starts with a quoted path and no `&`, which PowerShell won't run."""
    return _WIN and name == "codex.py" and str(h.get("command", "")).lstrip().startswith('"')


def _backslashed(h: dict) -> bool:
    """Windows: a moth hook written with backslash paths (older moth), which Git Bash would mangle."""
    return _WIN and "\\" in str(h.get("command", ""))


def short(path: str) -> str:
    h = home()
    return "~" + path[len(h):] if path == h or path.startswith(h + os.sep) else path


# --- small file helpers ---------------------------------------------------------------------------------------
def _read(path: str) -> str | None:
    try:
        with open(path, encoding="utf-8") as f:
            return f.read()
    except FileNotFoundError:
        return None


def _stamp() -> str:
    return time.strftime("%Y%m%d-%H%M%S")


def backup_path(path: str, where: str | None = None) -> str:
    """<file>.bak-moth-<timestamp> (or in `where`), never overwriting an earlier backup."""
    base = os.path.join(where, os.path.basename(path)) if where else path
    out, n = f"{base}.bak-moth-{_stamp()}", 1
    while os.path.lexists(out):
        out, n = f"{base}.bak-moth-{_stamp()}.{n}", n + 1
    return out


def write_atomic(path: str, text: str) -> None:
    path = os.path.realpath(path)   # a symlinked config (dotfiles) keeps its link; the target is written
    folder = os.path.dirname(os.path.abspath(path))
    os.makedirs(folder, exist_ok=True)
    mode = os.stat(path).st_mode & 0o7777 if os.path.exists(path) else None
    fd, tmp = tempfile.mkstemp(prefix=".moth-", suffix=".tmp", dir=folder)
    try:
        with os.fdopen(fd, "w", encoding="utf-8", newline="") as f:
            f.write(text)
            f.flush()
            os.fsync(f.fileno())
        if mode is not None:
            os.chmod(tmp, mode)
        os.replace(tmp, path)
    except BaseException:
        try:
            os.unlink(tmp)
        except OSError:
            pass
        raise


def same_tree(a: str, b: str) -> bool:
    """Two folders with the same files (ignoring __pycache__ / .pyc), byte for byte."""
    def files(root):
        out = set()
        for d, dirs, fs in os.walk(root):
            dirs[:] = [x for x in dirs if x != "__pycache__"]
            out |= {os.path.relpath(os.path.join(d, f), root) for f in fs if not f.endswith(".pyc") and f != ".DS_Store"}
        return out
    fa, fb = files(a), files(b)
    return fa == fb and all(filecmp.cmp(os.path.join(a, f), os.path.join(b, f), shallow=False) for f in fa)


def same_file(a: str, b: str) -> bool:
    return os.path.isfile(a) and os.path.isfile(b) and filecmp.cmp(a, b, shallow=False)


# --- a plan: what one hook / unhook will do, printed before it is done -----------------------------------------
class Action:
    """kind: write (path, text) | copy (src -> path, a file or a folder) | remove (path, a file or a folder) |
    run (cmd). backup: back the old path up first (backup_dir: where, for folders a harness would load)."""

    def __init__(self, kind: str, desc: str, path: str | None = None, text: str | None = None,
                 src: str | None = None, cmd: list[str] | None = None, backup: bool = True,
                 backup_dir: str | None = None, backup_also: tuple[str, ...] = (), fallback: list[str] | None = None):
        self.kind, self.desc, self.path, self.text, self.src, self.cmd = kind, desc, path, text, src, cmd
        self.fallback = fallback   # run: tried instead when cmd's flags are unknown to an older CLI
        self.backup, self.backup_dir, self.backup_also = backup, backup_dir, backup_also

    def apply(self) -> list[str]:
        """Do it; returns the backups made."""
        made = []
        for p in ((self.path,) if self.backup and self.path else ()) + self.backup_also:
            if p and os.path.lexists(p):
                if self.backup_dir:
                    os.makedirs(self.backup_dir, exist_ok=True)
                dst = backup_path(p, self.backup_dir)
                (shutil.copytree(p, dst, ignore=IGNORE, symlinks=True) if os.path.isdir(p) and not os.path.islink(p)
                 else shutil.copy2(p, dst))
                made.append(dst)
        if self.kind == "write":
            write_atomic(self.path, self.text)
        elif self.kind == "copy":
            if os.path.isdir(self.src):
                tmp = self.path + ".moth-new"
                shutil.rmtree(tmp, ignore_errors=True)
                os.makedirs(os.path.dirname(self.path), exist_ok=True)
                shutil.copytree(self.src, tmp, ignore=IGNORE)
                if os.path.lexists(self.path):
                    shutil.rmtree(self.path) if os.path.isdir(self.path) and not os.path.islink(self.path) \
                        else os.remove(self.path)
                os.replace(tmp, self.path)
            else:
                with open(self.src, encoding="utf-8") as f:
                    write_atomic(self.path, f.read())
        elif self.kind == "remove":
            if os.path.isdir(self.path) and not os.path.islink(self.path):
                shutil.rmtree(self.path)
            elif os.path.lexists(self.path):
                os.remove(self.path)
        elif self.kind == "run":
            r = subprocess.run(self.cmd, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                               text=True, timeout=120)
            if r.returncode != 0 and self.fallback and "unrecognized arguments" in r.stdout:
                r = subprocess.run(self.fallback, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
                                   stderr=subprocess.STDOUT, text=True, timeout=120)
            if r.returncode != 0:
                raise RuntimeError(f"{command(*self.cmd)} failed (exit {r.returncode}): {r.stdout.strip()[-300:]}")
        return made


class Plan:
    def __init__(self, harness: str):
        self.harness = harness
        self.actions: list[Action] = []
        self.notes: list[str] = []     # printed after (approval reminders, manual steps)
        self.nothing: str | None = None  # "already installed ..." when there is nothing to do
        self.error: str | None = None


# --- hook commands in JSON settings (Claude Code, Codex hooks.json) -------------------------------------------
# The hook runs in the foreground (no "async"): one-shot runs (`claude -p`, `codex exec`) kill a background hook when
# they exit, before it has posted (seen live, 2026-10-18). It takes a moment: the adapter gives up on moth after 1 s
# and moth only queues the text. HOOK_TIMEOUT is the harness's own hard stop, with room for a slow interpreter start.
HOOK_TIMEOUT = 3


def hook_entry(cmd: str) -> dict:
    """moth's command hook, as `moth hook` writes it and `python -m moth` prints it."""
    return {"type": "command", "command": cmd, "timeout": HOOK_TIMEOUT}


def moth_script_in(cmd, name: str) -> str | None:
    """The path of moth's integrations/<name> that a hook command runs (any interpreter, MOTH_URL=... prefixes and
    quoting allowed), else None. A copy of moth elsewhere counts too: it's still moth's hook."""
    if not isinstance(cmd, str):
        return None
    try:
        parts = shlex.split(cmd, posix=os.name != "nt")
    except ValueError:
        parts = cmd.split()
    for p in parts:
        p = p.strip("\"'")
        norm = p.replace("\\", "/")
        if norm.endswith("/integrations/" + name) or norm == "integrations/" + name:
            return p
    return None


def _load_json(path: str) -> tuple[dict | None, str | None]:
    text = _read(path)
    if text is None or not text.strip():
        return {}, None
    try:
        data = json.loads(text)
    except ValueError as e:
        return None, f"{path} isn't valid JSON ({e}); fix it by hand first, moth won't touch it"
    if not isinstance(data, dict):
        return None, f"{path} isn't a JSON object; moth won't touch it"
    return data, None


def _dump_json(data: dict, old: str | None) -> str:
    indent = 2
    m = re.search(r"\n( +)\S", old or "")
    if m:
        indent = len(m.group(1))
    return json.dumps(data, indent=indent, ensure_ascii=False) + "\n"


def _stop_entries(data: dict, name: str) -> list[tuple[dict, dict, str]]:
    """(group, handler, script path) for each of moth's handlers under hooks.Stop."""
    out = []
    hooks = data.get("hooks")
    stop = hooks.get("Stop") if isinstance(hooks, dict) else None
    for group in stop if isinstance(stop, list) else ():
        for h in (group.get("hooks") or ()) if isinstance(group, dict) else ():
            if isinstance(h, dict):
                p = moth_script_in(h.get("command"), name)
                if p:
                    out.append((group, h, p))
    return out


def _where(p: str, name: str) -> str:
    """'this' (this install's script), 'other' (another moth that exists) or 'missing'."""
    if os.path.realpath(os.path.expanduser(p)) == os.path.realpath(script(name)):
        return "this"
    if p.replace("\\", "/") == "integrations/" + name and hook_script(name) == "integrations/" + name:
        return "this"   # an AppImage's hook (hook_script): its bundled copy
    return "other" if os.path.isfile(os.path.expanduser(p)) else "missing"


def json_hook_status(path: str, name: str) -> tuple[str, str]:
    data, err = _load_json(path)
    if err:
        return "error", err
    entries = _stop_entries(data, name)
    if not entries:
        return "absent", f"no moth hook in {short(path)}"
    kinds = [_where(p, name) for _, _, p in entries]
    if "this" in kinds:
        if any(h.get("async") for _, h, _ in entries):
            return "outdated", (f"Stop hook in {short(path)} runs in the background (\"async\": true), which one-shot "
                                f"runs (claude -p, codex exec) kill before it posts")
        if any(_backslashed(h) for _, h, _ in entries):
            return "outdated", (f"Stop hook in {short(path)} has backslash paths, which Git Bash mangles; "
                                f"`{prog()} hook` rewrites them with forward slashes")
        if any(_uncalled(h, name) for _, h, _ in entries):
            return "outdated", (f"Stop hook in {short(path)} starts with a quoted path, which PowerShell (Codex's hook "
                                f"shell on Windows) doesn't run; `{prog()} hook` adds its call operator &")
        extra = f" ({len(entries)} moth hooks: duplicates)" if len(entries) > 1 else ""
        return "installed", f"Stop hook in {short(path)}{extra}"
    if "other" in kinds:
        return "other", f"Stop hook in {short(path)} runs another moth copy: {entries[0][2]}"
    return "broken", f"Stop hook in {short(path)} points to a missing file: {entries[0][2]}"


def plan_json_hook(plan: Plan, path: str, name: str, what: str) -> None:
    data, err = _load_json(path)
    if err:
        plan.error = err
        return
    old = _read(path)
    want = harness_command(name)
    entries = _stop_entries(data, name)
    here = any(_where(p, name) == "this" for _, _, p in entries)
    background = [h for _, h, _ in entries if h.get("async")]
    slashes = [h for _, h, _ in entries if _backslashed(h)]
    uncalled = [h for _, h, _ in entries if _uncalled(h, name)]
    if here and not background and not slashes and not uncalled:
        plan.nothing = f"already installed ({what} in {short(path)})"
        return
    if entries:   # moth's hook from a moved / older install, or an older background one: fix it in place
        changes = []
        if not here:
            for _, h, p in entries:
                h["command"] = want
            changes.append(f"{entries[0][2]} -> {script(name)}")
        elif slashes:   # this install's hook, maybe with the person's own python: keep it, only turn the slashes
            for h in slashes:
                h["command"] = h["command"].replace("\\", "/")
            changes.append("forward slashes in its paths (Git Bash mangles backslashes)")
        if here and uncalled:
            for h in uncalled:
                h["command"] = ps_call(h["command"].strip())
            changes.append("PowerShell's call operator & before its quoted interpreter path (Codex runs hooks "
                           "through PowerShell on Windows)")
        for h in background:
            del h["async"]
            h["timeout"] = HOOK_TIMEOUT
        if background:
            changes.append("run it in the foreground (a background hook is killed by claude -p / codex exec before "
                           "it posts)")
        plan.actions.append(Action("write", f"update moth's {what} in {short(path)}: " + "; ".join(changes), path,
                                   _dump_json(data, old)))
        return
    hooks = data.setdefault("hooks", {})
    if not isinstance(hooks, dict):
        plan.error = f"{path}: \"hooks\" isn't an object; moth won't touch it"
        return
    stop = hooks.setdefault("Stop", [])
    if not isinstance(stop, list):
        plan.error = f"{path}: \"hooks.Stop\" isn't a list; moth won't touch it"
        return
    stop.append({"hooks": [hook_entry(want)]})   # appended: other
    verb = "add" if old is not None else "create"   # entries keep their index (Codex trust is keyed by it)
    plan.actions.append(Action("write", f"{verb} {short(path)} with moth's {what}: {want}" if verb == "create" else
                               f"add moth's {what} to {short(path)}: {want}", path, _dump_json(data, old)))


def plan_json_unhook(plan: Plan, path: str, name: str, what: str) -> None:
    data, err = _load_json(path)
    if err:
        plan.error = err
        return
    old = _read(path)
    entries = _stop_entries(data, name)
    if not entries:
        return
    stop = data["hooks"]["Stop"]
    ids = {id(h) for _, h, _ in entries}
    for group in stop:
        if isinstance(group, dict) and isinstance(group.get("hooks"), list):
            group["hooks"] = [h for h in group["hooks"] if id(h) not in ids]
    data["hooks"]["Stop"] = [g for g in stop if not (isinstance(g, dict) and g.get("hooks") == []
                                                      and any(id(g) == id(eg) for eg, _, _ in entries))]
    if not data["hooks"]["Stop"]:
        del data["hooks"]["Stop"]
    if not data["hooks"]:
        del data["hooks"]
    plan.actions.append(Action("write", f"remove moth's {what} from {short(path)} "
                                        f"({len(entries)} entr{'y' if len(entries) == 1 else 'ies'})",
                               path, _dump_json(data, old)))


# --- Codex legacy notify (config.toml), read and written as text: no TOML library needed -----------------------
NOTIFY = re.compile(r"^notify\s*=\s*\[(?P<body>[^\]]*)\][^\n]*\n?", re.M)


def _toml_top(text: str) -> str:
    """The part of a TOML file before its first [table] header (where a top-level key like notify lives)."""
    m = re.search(r"^\s*\[", text, re.M)
    return text[:m.start()] if m else text


def codex_notify(text: str | None) -> tuple[re.Match | None, list[str]]:
    if not text:
        return None, []
    m = NOTIFY.search(_toml_top(text))
    if not m:
        return None, []
    items = [_toml_basic(s[1:-1]) if s.startswith('"') else s[1:-1]
             for s in re.findall(r'"(?:[^"\\]|\\.)*"|\'[^\']*\'', m.group("body"))]
    return m, items


_TOML_ESC = {"b": "\b", "t": "\t", "n": "\n", "f": "\f", "r": "\r", "e": "\x1b", '"': '"', "\\": "\\"}


def _toml_basic(body: str) -> str:
    """The value of a TOML basic string ("..." without the quotes). Not json.loads: TOML also has \\e and \\UXXXXXXXX,
    and a hand-written invalid escape (a raw Windows path like "C:\\Users\\me\\x.py") must not crash `moth hooks`: then
    the text is taken as it is, which is what the person meant."""
    out, i = [], 0
    while i < len(body):
        c = body[i]
        if c != "\\":
            out.append(c)
            i += 1
            continue
        k = body[i + 1:i + 2]
        if k in _TOML_ESC:
            out.append(_TOML_ESC[k])
            i += 2
        elif k in ("u", "U"):
            n = 4 if k == "u" else 8
            hexd = body[i + 2:i + 2 + n]
            try:
                if len(hexd) != n:
                    raise ValueError
                out.append(chr(int(hexd, 16)))
            except ValueError:
                return body
            i += 2 + n
        else:
            return body
    return "".join(out)


def codex_config() -> str:
    return os.path.join(codex_dir(), "config.toml")


def codex_hooks() -> str:
    return os.path.join(codex_dir(), "hooks.json")


def notify_is_moth(items: list[str]) -> str | None:
    for it in items:
        if moth_script_in(command(it), "codex.py") or it.replace("\\", "/").endswith("/integrations/codex.py"):
            return it
    return None


# --- Hermes plugins.enabled, read without PyYAML ---------------------------------------------------------------
def yaml_enabled_plugins(text: str | None) -> list[str]:
    """plugins.enabled of a Hermes config.yaml (block or flow list). PyYAML if it's there, else a small reader."""
    if not text:
        return []
    try:
        import yaml   # optional
        d = yaml.safe_load(text)
        en = (d or {}).get("plugins", {}).get("enabled") if isinstance(d, dict) else None
        return [str(x) for x in en] if isinstance(en, list) else []
    except ImportError:
        pass
    except Exception:
        return []
    lines = text.splitlines()
    out, in_plugins, en_indent = [], False, None
    for line in lines:
        if not line.strip() or line.lstrip().startswith("#"):
            continue
        indent = len(line) - len(line.lstrip())
        if indent == 0:
            in_plugins = line.split("#")[0].strip() == "plugins:"
            en_indent = None
            continue
        if not in_plugins:
            continue
        if en_indent is None:
            m = re.match(r"\s*enabled:\s*(.*)$", line)
            if m:
                rest = m.group(1).split("#")[0].strip()
                if rest.startswith("["):
                    return [x.strip().strip("'\"") for x in rest.strip("[]").split(",") if x.strip()]
                en_indent = indent
            continue
        m = re.match(r"\s*-\s*(.+)$", line)
        if indent >= en_indent and m:
            out.append(m.group(1).split(" #")[0].strip().strip("'\""))
        elif indent <= en_indent:
            break
    return out


def hermes_config() -> str:
    return os.path.join(hermes_dir(), "config.yaml")


def hermes_is_moth(folder: str) -> bool:
    y = _read(os.path.join(folder, "plugin.yaml")) or ""
    return bool(re.search(r"^name:\s*['\"]?moth['\"]?\s*$", y, re.M)) and \
        os.path.isfile(os.path.join(folder, "__init__.py"))


# --- copied single files (Pi, OpenCode) -------------------------------------------------------------------------
def file_is_moth(path: str, name: str) -> bool:
    if same_file(path, copy_source(name)):
        return True
    try:
        with open(path, encoding="utf-8") as f:
            return f.readline().startswith(SIGNATURE[name])
    except (OSError, UnicodeDecodeError):
        return False


# --- dsh: one line in a YAML list (cordis.patch.yml), as text --------------------------------------------------
DSH_LINE = re.compile(r"^[ \t]*-[ \t]*insert:[ \t]*\[[ \t]*\{[ \t]*id:[ \t]*['\"]?moth['\"]?[ \t]*,[ \t]*name:[ \t]*"
                      r"(?P<name>'(?:[^']|'')*'|\"(?:[^\"\\]|\\.)*\"|[^}\s]+)[ \t]*\}[ \t]*\][ \t]*(?:#[^\n]*)?\n?",
                      re.M)


def dsh_profiles() -> list[str]:
    base = os.path.join(dsh_dir(), "profiles")
    return sorted(d for d in os.listdir(base) if os.path.isdir(os.path.join(base, d))) if os.path.isdir(base) else []


def dsh_patch(profile: str) -> str:
    return os.path.join(dsh_dir(), "profiles", profile, "cordis.patch.yml")


def dsh_line() -> str:
    return "- insert: [{ id: moth, name: '" + script(os.path.join("dsh", "moth.mjs")).replace("'", "''") + "' }]\n"


def _dsh_name(m: re.Match) -> str:
    n = m.group("name")
    if n.startswith("'"):
        return n[1:-1].replace("''", "'")
    if n.startswith('"'):
        try:
            return json.loads(n)
        except ValueError:   # a YAML escape JSON lacks, or a raw Windows path: the text as written
            return n[1:-1]
    return n


# --- status ----------------------------------------------------------------------------------------------------
def detected(name: str) -> tuple[bool, str]:
    why = []
    b = shutil.which(BINARIES[name])
    if b:
        why.append(f"`{BINARIES[name]}` on PATH")
    if os.path.isdir(config_dir(name)):
        why.append(short(config_dir(name)))
    return bool(why), ", ".join(why)


def status(name: str, project: str | None = None) -> dict:
    """{"name", "detected", "why", "state", "detail"}; state: installed | outdated | other | broken | partial |
    absent | error. outdated / other / broken / partial are fixed by `moth hook <name>`."""
    found, why = detected(name)
    st = {"name": name, "detected": found, "why": why}
    if name == "claude-code":
        st["state"], st["detail"] = json_hook_status(claude_settings(project), "claude_code.py")
    elif name == "codex":
        s, d = json_hook_status(codex_hooks(), "codex.py")
        _, items = codex_notify(_read(codex_config()))
        legacy = notify_is_moth(items)
        if legacy and s != "absent" and s != "error":
            s, d = "error", f"BOTH variants installed (hooks.json and legacy notify in {short(codex_config())}): " \
                            f"every reply posts twice; `{prog()} unhook codex` then `{prog()} hook codex`"
        elif legacy:
            s = "installed" if _where(legacy, "codex.py") == "this" else _where(legacy, "codex.py").replace(
                "missing", "broken")
            d = f"legacy notify in {short(codex_config())}"
        elif s == "installed":
            d += " (Codex runs it only once approved: /hooks in codex)"
        st["state"], st["detail"] = s, d
    elif name in ("pi", "opencode"):
        dst = copy_target(name)
        if not os.path.exists(dst):
            st["state"], st["detail"] = "absent", f"no {short(dst)}"
        elif same_file(dst, copy_source(name)):
            st["state"], st["detail"] = "installed", short(dst)
        elif file_is_moth(dst, name):
            st["state"], st["detail"] = "outdated", f"{short(dst)} differs from this install's copy"
        else:
            st["state"], st["detail"] = "error", f"{short(dst)} exists but isn't moth's adapter"
    elif name == "hermes":
        dst = copy_target(name)
        on = "moth" in yaml_enabled_plugins(_read(hermes_config()))
        if not os.path.exists(dst):
            st["state"], st["detail"] = ("broken", f"enabled in config.yaml but {short(dst)} is missing") if on else \
                ("absent", f"no {short(dst)}")
        elif not hermes_is_moth(dst):
            st["state"], st["detail"] = "error", f"{short(dst)} exists but isn't moth's plugin"
        elif not same_tree(dst, copy_source(name)):
            st["state"], st["detail"] = "outdated", f"{short(dst)} differs from this install's copy" + \
                ("" if on else ", and not enabled")
        elif not on:
            st["state"], st["detail"] = "partial", f"{short(dst)} copied but not enabled (hermes plugins enable moth)"
        else:
            st["state"], st["detail"] = "installed", f"{short(dst)}, enabled"
    elif name == "dsh":
        found_in = []
        for p in dsh_profiles():
            for m in DSH_LINE.finditer(_read(dsh_patch(p)) or ""):
                found_in.append((p, _dsh_name(m)))
        if not found_in:
            st["state"], st["detail"] = "absent", "no moth entry in any ~/.dsh profile"
        else:
            kinds = [_where(n, os.path.join("dsh", "moth.mjs")) for _, n in found_in]
            st["state"] = "installed" if all(k == "this" for k in kinds) else \
                "other" if "missing" not in kinds else "broken"
            st["detail"] = "profile " + ", ".join(sorted({p for p, _ in found_in}))
    return st


MARK = {"installed": "✓", "outdated": "!", "other": "!", "broken": "✗", "partial": "!", "error": "✗", "absent": "·"}
WORD = {"installed": "installed", "outdated": "outdated", "other": "another moth", "broken": "broken",
        "partial": "half-installed", "error": "problem", "absent": "not installed"}


def status_lines(project: str | None = None, only_detected: bool = False) -> list[tuple[str, str]]:
    """(mark, text) per harness, for `moth hooks` and `moth doctor`."""
    out = []
    for name in HARNESSES:
        st = status(name, project)
        if not st["detected"] and st["state"] == "absent":
            if not only_detected:
                out.append(("-", f"{name:<12} not found on this machine"))
            continue
        hint = ""
        if st["state"] in ("outdated", "other", "broken", "partial", "absent"):
            hint = f"  -> {prog()} hook {name}"
        where = f" [{st['why']}]" if st["why"] else " [not detected, but configured]"
        out.append((MARK[st["state"]], f"{name:<12} {WORD[st['state']]}: {st['detail']}{hint}{where}"))
    return out


def ascii_safe(mark: str) -> str:
    enc = (getattr(sys.stdout, "encoding", None) or "ascii")
    try:
        mark.encode(enc)
        return mark
    except (UnicodeEncodeError, LookupError):
        return {"✓": "ok", "✗": "XX", "!": "!!", "·": "..", "-": "--"}.get(mark, mark)


def show(project: str | None = None) -> int:
    print("coding agents moth can watch (✓ hooked up):".replace("✓", ascii_safe("✓")))
    for mark, text in status_lines(project):
        print(f"  {ascii_safe(mark)} {text}")
    print(f"\n{prog()} hook <name> installs one ({prog()} hook --all: every harness found), {prog()} unhook <name> "
          "removes it.\nThe Claude Code band above the prompt is a separate mod: claude --plugin-dir "
          f"{command(os.path.join(INTEGRATIONS, 'claude_code_mod'))}")
    return 0


# --- plans per harness -------------------------------------------------------------------------------------------
def plan_hook(name: str, project: str | None = None, profile: str | None = None, legacy_notify: bool = False) -> Plan:
    plan = Plan(name)
    if name == "claude-code":
        plan_json_hook(plan, claude_settings(project), "claude_code.py", "Stop hook")
        if plan.actions:
            plan.notes.append("Claude Code picks the hook up in new sessions (or after /hooks). The band above the "
                              f"prompt is separate: claude --plugin-dir "
                              f"{command(os.path.join(INTEGRATIONS, 'claude_code_mod'))}")
    elif name == "codex":
        text = _read(codex_config())
        m, items = codex_notify(text)
        legacy = notify_is_moth(items)
        hs, _ = json_hook_status(codex_hooks(), "codex.py")
        if legacy and hs not in ("absent", "error"):
            plan.error = (f"both Codex variants are installed (hooks.json and notify in {short(codex_config())}), so "
                          f"every reply posts twice: run `{prog()} unhook codex`, then `{prog()} hook codex`")
        elif legacy_notify:
            if hs not in ("absent", "error"):
                plan.error = (f"moth's hooks.json hook is installed; moth never installs both variants. To switch: "
                              f"`{prog()} unhook codex`, then `{prog()} hook codex --legacy-notify`")
            elif legacy and _where(legacy, "codex.py") == "this":
                plan.nothing = f"already installed (legacy notify in {short(codex_config())})"
            elif m and not legacy:
                plan.error = (f"{short(codex_config())} already has a notify program ({' '.join(items)}); Codex has "
                              f"one notify slot, so moth won't replace it. Use the hooks.json variant (without "
                              f"--legacy-notify) instead")
            else:
                want = f"notify = {json.dumps(hook_argv('codex.py'), ensure_ascii=False)}\n"
                if m:   # moth's notify from another install: point it here
                    new = text[:m.start()] + want + text[m.end():]
                    desc = f"update moth's legacy notify in {short(codex_config())}"
                else:   # top-level key: before any [table]; after the file's leading comments
                    lead = re.match(r"(?:[ \t]*(?:#[^\n]*)?\n)*", text or "").end()
                    new = (text or "")[:lead] + want + (text or "")[lead:]
                    desc = f"add legacy notify to {short(codex_config())}: {want.strip()}"
                plan.actions.append(Action("write", desc, codex_config(), new))
        elif legacy:
            plan.nothing = (f"already installed as legacy notify in {short(codex_config())}; moth never installs both "
                            f"variants (to switch to hooks.json: `{prog()} unhook codex`, then `{prog()} hook codex`)")
            if _where(legacy, "codex.py") != "this":
                plan.nothing = None
                want = f"notify = {json.dumps(hook_argv('codex.py'), ensure_ascii=False)}\n"
                plan.actions.append(Action("write", f"update moth's legacy notify in {short(codex_config())}",
                                           codex_config(), text[:m.start()] + want + text[m.end():]))
        else:
            plan_json_hook(plan, codex_hooks(), "codex.py", "Stop hook")
        if plan.actions and not legacy_notify and not legacy:
            plan.notes.append("Codex runs a new or changed hook only after you approve it once: start `codex` and "
                              "accept it in the hook review (or later with /hooks). Until then it is skipped silently.")
    elif name in ("pi", "opencode"):
        src, dst = copy_source(name), copy_target(name)
        if not os.path.exists(dst):
            plan.actions.append(Action("copy", f"copy {short(src)} -> {short(dst)}", dst, src=src, backup=False))
        elif same_file(dst, src):
            plan.nothing = f"already installed ({short(dst)})"
        elif not file_is_moth(dst, name):
            plan.error = f"{short(dst)} exists and isn't moth's adapter; moth won't overwrite it"
        else:
            plan.actions.append(Action("copy", f"update {short(dst)} (differed from this install's copy)", dst,
                                       src=src))
        if plan.actions:
            plan.notes.append(f"{'Pi' if name == 'pi' else 'OpenCode'} loads it at its next start.")
    elif name == "hermes":
        src, dst = copy_source(name), copy_target(name)
        backups = os.path.join(home(), ".moth", "backups")
        if not os.path.exists(dst):
            plan.actions.append(Action("copy", f"copy {short(src)}/ -> {short(dst)}/", dst, src=src, backup=False))
        elif not hermes_is_moth(dst):
            plan.error = f"{short(dst)} exists and isn't moth's plugin; moth won't overwrite it"
            return plan
        elif not same_tree(dst, src):
            plan.actions.append(Action("copy", f"update {short(dst)}/ (differed from this install's copy; old one "
                                               f"kept in {short(backups)})", dst, src=src, backup_dir=backups))
        if "moth" not in yaml_enabled_plugins(_read(hermes_config())):
            hermes = shutil.which("hermes")
            if hermes:
                plan.actions.append(Action("run", f"run `hermes plugins enable moth` (adds moth to plugins.enabled in "
                                                  f"{short(hermes_config())})",
                                           # moth replaces no built-in tools: say so, which also skips Hermes's prompt
                                           cmd=[hermes, "plugins", "enable", "--no-allow-tool-override", "moth"],
                                           fallback=[hermes, "plugins", "enable", "moth"],
                                           backup=False, backup_also=(hermes_config(),)))
            else:
                plan.notes.append(f"`hermes` isn't on PATH: enable the plugin with `hermes plugins enable moth`, or add "
                                  f"moth under plugins.enabled in {short(hermes_config())}")
        if not plan.actions and not plan.notes:
            plan.nothing = f"already installed ({short(dst)}, enabled)"
    elif name == "dsh":
        profiles = dsh_profiles()
        if not profile:
            if len(profiles) == 1:
                profile = profiles[0]
            else:
                plan.error = ("pick a dsh profile with --profile NAME (" + (
                    f"found: {', '.join(profiles)}" if profiles else f"none in {short(os.path.join(dsh_dir(), 'profiles'))}")
                    + ")")
                return plan
        path = dsh_patch(profile)
        text = _read(path)
        want = dsh_line()
        ms = list(DSH_LINE.finditer(text or ""))
        mjs = os.path.join("dsh", "moth.mjs")
        if any(_where(_dsh_name(m), mjs) == "this" for m in ms):
            plan.nothing = f"already installed ({short(path)})"
        elif ms:
            new = text
            for m in reversed(ms):
                new = new[:m.start()] + want + new[m.end():]
            plan.actions.append(Action("write", f"update moth's entry in {short(path)} to {script(mjs)}", path, new))
        else:
            body = text or ""
            if body.strip() in ("", "[]"):
                body = ""
            elif not body.endswith("\n"):
                body += "\n"
            plan.actions.append(Action("write", f"{'add moth to' if text is not None else 'create'} {short(path)}: "
                                                f"{want.strip()}", path, body + want))
    return plan


def plan_unhook(name: str, project: str | None = None, profile: str | None = None) -> Plan:
    plan = Plan(name)
    if name == "claude-code":
        plan_json_unhook(plan, claude_settings(project), "claude_code.py", "Stop hook")
    elif name == "codex":
        plan_json_unhook(plan, codex_hooks(), "codex.py", "Stop hook")
        text = _read(codex_config())
        m, items = codex_notify(text)
        if m and notify_is_moth(items):
            plan.actions.append(Action("write", f"remove moth's legacy notify from {short(codex_config())}",
                                       codex_config(), text[:m.start()] + text[m.end():]))
    elif name in ("pi", "opencode"):
        dst = copy_target(name)
        if os.path.exists(dst):
            if not file_is_moth(dst, name):
                plan.error = f"{short(dst)} isn't moth's adapter; moth won't delete it"
            else:   # a byte-identical copy can be re-made any time; anything else is backed up first
                plan.actions.append(Action("remove", f"delete {short(dst)}", dst,
                                           backup=not same_file(dst, copy_source(name))))
    elif name == "hermes":
        dst = copy_target(name)
        if "moth" in yaml_enabled_plugins(_read(hermes_config())):
            hermes = shutil.which("hermes")
            if hermes:
                plan.actions.append(Action("run", f"run `hermes plugins disable moth` ({short(hermes_config())})",
                                           cmd=[hermes, "plugins", "disable", "moth"], backup=False,
                                           backup_also=(hermes_config(),)))
            else:
                plan.notes.append(f"`hermes` isn't on PATH: remove moth from plugins.enabled in "
                                  f"{short(hermes_config())} by hand")
        if os.path.exists(dst):
            if not hermes_is_moth(dst):
                plan.error = f"{short(dst)} isn't moth's plugin; moth won't delete it"
            else:
                plan.actions.append(Action("remove", f"delete {short(dst)}/", dst,
                                           backup=not same_tree(dst, copy_source(name)),
                                           backup_dir=os.path.join(home(), ".moth", "backups")))
    elif name == "dsh":
        for p in ([profile] if profile else dsh_profiles()):
            path = dsh_patch(p)
            text = _read(path)
            ms = list(DSH_LINE.finditer(text or ""))
            if ms:
                new = text
                for m in reversed(ms):
                    new = new[:m.start()] + new[m.end():]
                plan.actions.append(Action("write", f"remove moth's entry from {short(path)}", path, new))
    if not plan.actions and not plan.error and not plan.notes:
        plan.nothing = "not installed, nothing to remove"
    return plan


# --- running plans ------------------------------------------------------------------------------------------------
def _confirm(question: str) -> bool:
    try:
        return input(question + " [y/N] ").strip().lower() in ("y", "yes")
    except EOFError:
        return False


def run_plans(plans: list[Plan], yes: bool, dry_run: bool, verb: str) -> int:
    rc = 0
    todo = [p for p in plans if p.actions and not p.error]
    for p in plans:
        if p.error:
            print(f"{p.harness}: {p.error}")
            rc = 1
        elif not p.actions:
            if p.nothing:
                print(f"{p.harness}: {p.nothing}")
            for n in p.notes:
                print(f"{p.harness}: note: {n}")
    if not todo:
        return rc
    print(("would " if dry_run else "moth will ") + verb + ":")
    for p in todo:
        for a in p.actions:
            print(f"  {p.harness}: {a.desc}")
    if dry_run:
        print("(dry run: nothing changed)")
        return rc
    if not yes and not _confirm("go ahead?"):
        print("cancelled, nothing changed")
        return 1
    for p in todo:
        for a in p.actions:
            try:
                made = a.apply()
            except Exception as e:   # one harness failing doesn't stop the others
                print(f"  {p.harness}: FAILED: {a.desc}: {e}")
                rc = 1
                break
            print(f"  {p.harness}: done: {a.desc}")
            for b in made:
                print(f"    backup: {b}")
        for n in p.notes:
            print(f"  {p.harness}: note: {n}")
    return rc


def _names(raw: list[str], everything: bool, pick) -> list[str] | str:
    if everything:
        return [n for n in HARNESSES if pick(n)]
    out = []
    for r in raw:
        n = ALIASES.get(r.lower(), r.lower())
        if n not in HARNESSES:
            return f"unknown harness {r!r}; one of: {', '.join(HARNESSES)}"
        if n not in out:
            out.append(n)
    return out


def _parser(cmd: str) -> argparse.ArgumentParser:
    what = {"hooks": "list the coding agents found here and whether moth is hooked into each",
            "hook": "install moth's hook / plugin into coding agents (backs up and merges configs)",
            "unhook": "remove moth's hook / plugin from coding agents (only moth's entries)"}[cmd]
    ap = argparse.ArgumentParser(prog=f"{prog()} {cmd}", description=what,
                                 epilog=f"harnesses: {', '.join(HARNESSES)}")
    if cmd != "hooks":
        ap.add_argument("harness", nargs="*", help="one or more harness names")
        ap.add_argument("--all", action="store_true",
                        help="every harness found on this machine" if cmd == "hook" else "every harness moth is in")
        ap.add_argument("--yes", "-y", action="store_true", help="don't ask before changing files")
        ap.add_argument("--dry-run", action="store_true", help="only show what would change")
        ap.add_argument("--profile", help="dsh profile (default: the only one in ~/.dsh/profiles)")
    if cmd == "hook":
        ap.add_argument("--legacy-notify", action="store_true",
                        help="Codex: use the legacy notify line in config.toml instead of hooks.json")
    ap.add_argument("--project", metavar="DIR", help="Claude Code: DIR/.claude/settings.json instead of ~/.claude")
    return ap


def main(cmd: str, argv=None) -> int:
    a = _parser(cmd).parse_args(argv)
    if cmd == "hooks":
        return show(a.project)
    if a.all and a.harness:
        print("give harness names or --all, not both", file=sys.stderr)
        return 2
    if cmd == "hook":
        names = _names(a.harness, a.all, lambda n: detected(n)[0])
    else:
        names = _names(a.harness, a.all, lambda n: status(n, a.project)["state"] != "absent")
    if isinstance(names, str):
        print(names, file=sys.stderr)
        return 2
    if not names:
        if a.all:
            print("no coding agents found on this machine" if cmd == "hook" else "moth isn't hooked into anything")
            return 0
        _parser(cmd).print_usage(sys.stderr)
        return 2
    if cmd == "hook":
        plans = [plan_hook(n, a.project, a.profile, a.legacy_notify) for n in names]
        return run_plans(plans, a.yes, a.dry_run, "install")
    plans = [plan_unhook(n, a.project, a.profile) for n in names]
    return run_plans(plans, a.yes, a.dry_run, "remove")


if __name__ == "__main__":
    sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "hooks", sys.argv[2:]))