File size: 32,170 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
"""moth setup : from install to a running moth, in one go.

  moth setup [--yes] [--dry-run] [--url http://127.0.0.1:4747] [--timeout 300]

  1 packages   python, torch, tokenizers / safetensors / numpy, and PySide6 for the desktop pet. Anything missing:
               the exact pip command, run only when you say yes at the prompt (never by --yes, never with sudo).
  2 weights    pip install: offers to download them (about 860 MB, into ~/.cache/moth/<repo>). Hugging Face download:
               checks they're complete (`moth update` or `hf download` finish a partial one).
  3 memory     moth needs about 1 GB; the pet won't wake it with less than ~3 GB free.
  4 hooks      the coding agents found here and whether moth is hooked in; one question per harness, installed
               by moth/hooks.py (backs up and merges configs). Then show_in_harness (Pi / OpenCode, default off).
  5 autostart  start the pet at login (moth/autostart.py).
  6 the pet    start it now, detached, and wait until moth answers on /api/health. Logs: ~/.moth/pet.log and the
               server's log (~/.moth/serve.log).

Each step says what's already done and only offers what's missing, so running it again is safe. --yes takes every
default; --dry-run only prints. Without a terminal to ask in (CI, a pipe) it behaves like --dry-run unless --yes is
given. Exit 0 when moth can run here and nothing failed (a declined hook or autostart is fine), 1 otherwise; a dry run
exits 0. Stdlib only at import (torch and PySide6 are probed in child processes).
"""
from __future__ import annotations

import argparse
import contextlib
import importlib.util
import io
import json
import os
import shutil
import subprocess
import sys
import time

from moth import autostart, config, doctor, hooks, launch, weights

DEFAULT_URL = "http://127.0.0.1:4747"
PET_LOG = ("~", ".moth", "pet.log")    # same log as the macOS autostart entry (moth forget wipes ~/.moth/*.log)
PIDFILE = ("~", ".moth", "setup-pet.pid")
TORCH_INDEX = "https://download.pytorch.org/whl/cpu"
PYSIDE_PROBE = "import PySide6.QtWidgets, PySide6.QtSvg\n"
FIXABLE = ("absent", "outdated", "other", "broken", "partial")   # hook states `moth hook` fixes


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


def _path(parts) -> str:
    return os.path.join(os.path.expanduser(parts[0]), *parts[1:])


def _tty() -> bool:
    try:
        return bool(sys.stdin and not sys.stdin.closed and sys.stdin.isatty())
    except (ValueError, OSError):
        return False


# --- probes (module level, so the tests can replace them) --------------------------------------------------------
def probe_torch() -> dict:
    return doctor.probe_torch()


def probe_pyside() -> str | None:
    """None when the pet's Qt modules import (in a child process: a broken Qt can't take setup down), else why not."""
    if importlib.util.find_spec("PySide6") is None:
        return "not installed"
    try:
        p = subprocess.run([sys.executable, "-c", PYSIDE_PROBE], capture_output=True, text=True, timeout=60)
    except (OSError, subprocess.SubprocessError) as e:
        return str(e)
    if p.returncode == 0:
        return None
    err = [x for x in p.stderr.splitlines() if x.strip()]
    return f"installed but won't load: {err[-1][:140] if err else 'exit ' + str(p.returncode)}"


def ram_gb() -> float | None:
    return doctor.available_ram_gb()


def health(url: str, timeout: float = 2.0):
    return launch.probe(url, timeout)


def fetch_weights(folder: str) -> str:
    return weights.ensure(folder)


def pet_command(url: str) -> list[str]:
    return [autostart.pet_python(), "-m", "moth.pet", "--url", url]


def launch_pet(url: str, log: str) -> subprocess.Popen:
    """The pet, detached (its own session / process group): setup returns while it keeps flying. Output to log."""
    os.makedirs(os.path.dirname(log), exist_ok=True)
    env = dict(os.environ, PYTHONUNBUFFERED="1")
    env["PYTHONPATH"] = autostart.CODE + (os.pathsep + env["PYTHONPATH"] if env.get("PYTHONPATH") else "")
    kw: dict = {"cwd": autostart.CODE, "env": env, "stdin": subprocess.DEVNULL, "stderr": subprocess.STDOUT,
                "close_fds": True}
    if os.name == "nt":
        kw["creationflags"] = (getattr(subprocess, "DETACHED_PROCESS", 0x8)
                               | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x200))
    else:
        kw["start_new_session"] = True
    with open(log, "ab") as f:
        f.write(f"\n--- moth setup started the pet, {time.strftime('%Y-%m-%d %H:%M:%S')} ---\n".encode())
        f.flush()
        return subprocess.Popen(pet_command(url), stdout=f, **kw)


def alive(pid: int) -> bool:
    from moth import update   # stdlib only
    return update._alive(pid)


def has_display(env=None, platform: str | None = None) -> bool:
    env = os.environ if env is None else env
    platform = platform or sys.platform
    if platform == "darwin" or platform.startswith("win"):
        return True
    return bool(env.get("DISPLAY") or env.get("WAYLAND_DISPLAY") or env.get("QT_QPA_PLATFORM"))


def _tail(path: str, n: int = 8) -> list[str]:
    try:
        with open(path, encoding="utf-8", errors="replace") as f:
            return [x.rstrip() for x in f.readlines()[-n:] if x.strip()]
    except OSError:
        return []


def _fmt_cmd(cmd: list[str]) -> str:
    return hooks.command(*cmd)


# --- the run -----------------------------------------------------------------------------------------------------
class Setup:
    def __init__(self, yes: bool = False, dry: bool = False, url: str | None = None, timeout: float = 300.0,
                 note: str | None = None):
        self.yes, self.dry, self.timeout, self.note = yes, dry, timeout, note
        self.url = (url or os.environ.get("MOTH_URL") or DEFAULT_URL).rstrip("/")
        self.did: list[str] = []      # changed this run
        self.had: list[str] = []      # already in place
        self.left: list[str] = []     # still missing (declined, blocked or failed)
        self.failed = False
        self.serve_ok = self.weights_ok = self.pet_ok = True
        self.mode = weights.mode()
        self.started = self.running = False

    # output --------------------------------------------------------------------------------------------------
    @staticmethod
    def say(text: str = ""):
        print(text, flush=True)

    def line(self, mark: str, text: str):
        self.say(f"  {doctor._mark(mark)} {text}")

    def head(self, n: int, title: str):
        self.say(f"\n{n} {title}")

    def ask(self, question: str, default: bool) -> bool:
        """The one place setup asks. --yes / dry run: the default (printed); a closed input (EOF): no."""
        if self.yes or self.dry:
            self.say(f"    {question} {'yes' if default else 'no'}")
            return default
        try:
            a = input(f"    {question} {'[Y/n]' if default else '[y/N]'} ").strip().lower()
        except EOFError:
            self.say("")
            return False
        return default if not a else a in ("y", "yes")

    def run_quiet(self, fn, *args) -> tuple[int, str]:
        out = io.StringIO()
        with contextlib.redirect_stdout(out):
            rc = fn(*args)
        return rc or 0, out.getvalue()

    def echo(self, text: str, indent: int = 4):
        for x in text.rstrip().splitlines():
            self.say(" " * indent + x)

    # 1 -------------------------------------------------------------------------------------------------------
    def packages(self):
        self.head(1, "packages")
        v = sys.version_info
        if v < (3, 10):
            self.line("βœ—", f"python {v.major}.{v.minor}: moth needs 3.10 or newer")
            self.serve_ok = self.pet_ok = False
            self.left.append("python 3.10+")
            return
        self.line("βœ“", f"python {v.major}.{v.minor}.{v.micro}  ({sys.executable})")
        cmds = self._package_check()
        if not cmds:
            return
        for c in cmds:
            self.say(f"      {_fmt_cmd(c)}")
        many = len(cmds) > 1
        if not self.ask(f"run {'these' if many else 'it'} now (pip, into {sys.prefix}; no sudo)?", False):
            self.left.append("packages (the pip command" + ("s" if many else "") + " above)")
            return
        if self.dry:
            self.say("    (dry run: not installing)")
            return
        for c in cmds:
            self.say(f"    running: {_fmt_cmd(c)}")
            try:
                rc = subprocess.call(c)
            except OSError as e:
                rc = f"can't run it: {e}"
            if rc != 0:
                self.line("βœ—", f"pip failed ({rc}); run it yourself and then {prog()} setup again")
                self.failed = True
                self.left.append("packages")
                return
        self.say("    checking again...")
        if self._package_check(again=True):
            self.left.append("packages")
            self.failed = True
        else:
            self.did.append("installed packages")

    def _package_check(self, again: bool = False) -> list[list[str]]:
        py = sys.executable
        cmds = []
        t = probe_torch()
        if "error" in t:
            missing = "No module named 'torch'" in t["error"]
            self.line("βœ—", "torch: " + ("not installed" if missing else f"broken ({t['error'][:120]})"))
            cmds.append([py, "-m", "pip", "install", "torch", "--index-url", TORCH_INDEX])
            self.serve_ok = False
        else:
            devs = [d for d in ("cuda", "xpu") if t.get(d)]
            self.line("βœ“", f"torch {t.get('version')}  (cpu{', ' + ', '.join(devs) if devs else ''})")
            self.serve_ok = True
        need = ["tokenizers", "safetensors", "numpy"] + (["huggingface_hub"] if self.mode == "pip" else [])
        gone = [m for m in need if importlib.util.find_spec(m) is None]
        if gone:
            self.line("βœ—", f"missing: {', '.join(gone)}")
            cmds.append([py, "-m", "pip", "install", *gone])
            if set(gone) - {"huggingface_hub"}:
                self.serve_ok = False
        else:
            self.line("βœ“", ", ".join(need))
        why = probe_pyside()
        if why:
            self.line("βœ—", f"PySide6 (the desktop pet): {why}")
            cmds.append([py, "-m", "pip", "install", self._pet_pkg()] if why == "not installed" else
                        [py, "-m", "pip", "install", "--force-reinstall", "PySide6-Essentials"])   # broken: just Qt
            self.pet_ok = False
        else:
            self.line("βœ“", "PySide6 (the desktop pet)")
            self.pet_ok = True
        if not cmds and not again:
            self.had.append("packages")
        return cmds

    @staticmethod
    def _pet_pkg() -> str:
        from importlib.metadata import PackageNotFoundError, distribution
        try:
            distribution("moth-companion")
            return "moth-companion[pet]"   # the pip install's own extra (PySide6-Essentials)
        except PackageNotFoundError:
            return "PySide6-Essentials"

    # 2 -------------------------------------------------------------------------------------------------------
    def weights_step(self):
        self.head(2, "weights")
        if config.model():   # another model behind moth (`moth config model`): moth's own weights aren't needed
            note = config._model_note(config.model()) or config.model()
            self.line("Β·", f"model: {note.splitlines()[0]}  (moth config model; moth doctor checks it)")
            for extra in note.splitlines()[1:]:
                self.say(f"      {extra}")
            return
        if self.mode == "hf":
            folder = os.path.dirname(weights.ROOT)
            if weights.complete(folder) and os.path.isfile(os.path.join(folder, "tokenizer.json")):
                self._have(folder, "hugging face download")
            else:
                self.weights_ok = False
                self.line("βœ—", f"{folder} is missing model files: "
                               + (", ".join(weights.missing(folder)) or "model.safetensors is cut off"))
                self.say(f"      finish it: cd {hooks.command(weights.ROOT)} && {prog()} update")
                self.say(f"      (or: hf download {weights.repo()} --local-dir {hooks.command(folder)}, which resumes)")
                self.left.append("weights (finish the download)")
            return
        if self.mode == "dev":
            try:
                p = launch.server_paths()
                self._have(p["model"], "dev checkout")
            except launch.LaunchError as e:
                self.weights_ok = False
                self.line("βœ—", str(e))
                self.left.append("weights")
            return
        folder = weights.cache_dir()
        if weights.present(folder) and weights.complete(folder):
            self._have(folder, "pip install")
            return
        if weights.present(folder):
            self.weights_ok = False
            self.line("βœ—", f"{os.path.join(folder, 'model.safetensors')} looks cut off")
            self.say(f"      repair it in place (with a backup): {prog()} update")
            self.left.append(f"weights ({prog()} update)")
            return
        self.weights_ok = False
        size = weights.SIZE_HINT if weights.repo() == weights.REPO_ID else "size unknown"
        self.line("Β·", f"not downloaded yet: {size}, from huggingface.co/{weights.repo()}")
        self.say(f"      into {folder}")
        if config.offline():
            self.line("βœ—", "offline mode is on (HF_HUB_OFFLINE / MOTH_OFFLINE): unset it to download")
            self.left.append("weights (offline)")
            return
        if importlib.util.find_spec("huggingface_hub") is None:
            self.line("βœ—", f"the download needs huggingface_hub: {sys.executable} -m pip install -U huggingface_hub")
            self.left.append("weights (needs huggingface_hub)")
            return
        free = self._free_gb(folder)
        if free is not None and free < 1.0:
            self.line("βœ—", f"only {free:.1f} GB free there; the weights need about 0.9 GB")
            self.left.append("weights (disk space)")
            return
        if not self.ask("download them now?", True):
            self.left.append("weights (the pet downloads them on its first start)")
            return
        if self.dry:
            self.say("    (dry run: not downloading)")
            self.weights_ok = True   # for the rest of the dry run: as if they were there
            return
        try:
            fetch_weights(folder)
        except SystemExit as e:   # weights.ensure says why (offline, no repo, cut off)
            self.line("βœ—", str(e.code if e.code is not None else "download failed"))
            self.failed = True
            self.left.append("weights (run setup again to resume)")
            return
        self.weights_ok = True
        self.line("βœ“", f"downloaded into {folder}")
        self.did.append("downloaded the weights")

    def _have(self, folder: str, what: str):
        try:
            mb = os.path.getsize(os.path.join(folder, "model.safetensors")) / 1e6
            self.line("βœ“", f"{mb:.0f} MB in {folder}  ({what})")
        except OSError:
            self.line("βœ“", f"{folder}  ({what})")
        self.had.append("weights")

    @staticmethod
    def _free_gb(folder: str) -> float | None:
        p = folder
        while p and not os.path.isdir(p) and os.path.dirname(p) != p:
            p = os.path.dirname(p)
        try:
            return shutil.disk_usage(p).free / 2**30
        except OSError:
            return None

    # 3 -------------------------------------------------------------------------------------------------------
    def memory(self):
        """A warning, never a block: free-memory estimates run low (macOS counts only free + inactive pages), and
        moth itself needs about 1 GB once loaded. On Linux the pet still refuses to wake moth below
        launch.MIN_FREE_GB (MemAvailable is reliable there), and step 6 shows that message if it does."""
        self.head(3, "memory")
        gb = ram_gb()
        if self.running:
            self.line("βœ“", (f"{gb:.1f} GB available" if gb is not None else "fine") + " (moth is already loaded)")
        elif gb is None:
            self.line("Β·", "couldn't read free memory here; moth needs about 1 GB")
        elif gb < launch.MIN_FREE_GB:
            self.line("!", f"{gb:.1f} GB available by this system's count: moth needs about 1 GB once it runs and "
                           f"~{launch.MIN_FREE_GB:g} GB while it loads. if it doesn't wake, close something and "
                           f"start it again")
        else:
            self.line("βœ“", f"{gb:.1f} GB available (moth needs about 1 GB)")

    # 4 -------------------------------------------------------------------------------------------------------
    def hooks_step(self):
        self.head(4, "coding agents")
        sts = [hooks.status(n) for n in hooks.HARNESSES]
        shown = [s for s in sts if s["detected"] or s["state"] != "absent"]
        if not shown:
            self.line("Β·", f"none found here (moth chats fine without one; later: {prog()} hook <harness>)")
            return
        for s in shown:
            self.line(hooks.MARK[s["state"]], f"{s['name']:<12} {hooks.WORD[s['state']]}: {s['detail']}")
        gone = [s["name"] for s in sts if s not in shown]
        if gone:
            self.say(f"    not found: {', '.join(gone)}")
        chosen = []
        for s in shown:
            if s["state"] == "installed":
                self.had.append(f"{s['name']} hook")
            elif s["state"] == "error":
                self.left.append(f"{s['name']} hook (see above)")
            elif s["state"] in FIXABLE:
                verb = "hook moth into" if s["state"] == "absent" else "fix moth's hook in"
                if self.ask(f"{verb} {s['name']}?", True):
                    chosen.append(s["name"])
                else:
                    self.left.append(f"{s['name']} hook")
        if chosen:
            plans = [hooks.plan_hook(n) for n in chosen]
            rc, out = self.run_quiet(hooks.run_plans, plans, True, self.dry, "install")
            self.echo(out)
            if self.dry:   # run_plans prints the notes (Codex's one approval, ...) only after doing it
                self.echo("\n".join(f"{p.harness}: note: {n}" for p in plans if p.actions for n in p.notes), 6)
            self.failed |= rc != 0 and not self.dry
            for p in plans:
                if p.error:
                    self.left.append(f"{p.harness} hook")
                elif not self.dry and p.actions:
                    ok = hooks.status(p.harness)["state"] == "installed"
                    (self.did if ok else self.left).append(f"{p.harness} hook" + ("" if ok else " (see the note)"))
        self.show_in_harness([s["name"] for s in shown], {"pi", "opencode"} & set(chosen))

    def show_in_harness(self, names: list[str], new: set):
        """Asked when a Pi / OpenCode hook goes in this run and the setting was never set; otherwise one line."""
        if not {"pi", "opencode"} & set(names):
            return
        value, source = config.lookup("show_in_harness")
        if source != "default" or not new:
            where = {"env": config.ENV["show_in_harness"], "file": "config"}.get(source, "default")
            self.line("βœ“" if source != "default" else "Β·",
                      f"show_in_harness: {'on' if value else 'off'} ({where}): moth's replies "
                      f"{'also' if value else 'not'} shown inside pi / opencode  ({prog()} config show_in_harness "
                      f"{'off' if value else 'on'})")
            return
        want = self.ask("show moth's replies inside pi / opencode too (show_in_harness)?", False)
        if self.dry or not want:
            return   # off is the default: nothing written (asked again next time, like any step left out)
        try:
            config.write("show_in_harness", want)
        except OSError as e:
            self.line("βœ—", f"couldn't write {config.path()}: {e}")
            self.failed = True
            return
        self.say(f"    show_in_harness = {'on' if want else 'off'}  (saved in {config.path()})")
        if want:
            self.did.append("show_in_harness on")

    # 5 -------------------------------------------------------------------------------------------------------
    def autostart_step(self):
        self.head(5, "autostart at login")
        st, p = autostart.state()
        if st == "on":
            self.line("βœ“", f"on ({p})")
            self.had.append("autostart")
            return
        if st == "foreign":
            self.line("!", f"{p} exists but isn't moth's; leaving it alone")
            self.left.append("autostart")
            return
        if not self.pet_ok:
            self.line("Β·", "skipped: the pet needs PySide6 (step 1)")
            self.left.append("autostart (needs PySide6)")
            return
        if st == "stale":
            self.line("!", f"{p} starts another python or folder than this one")
            q = "point it at this install?"
        else:
            self.line("Β·", "off")
            q = "start the pet when you log in?"
        if not self.ask(q, True):
            self.left.append(f"autostart ({prog()} autostart on)")
            return
        if self.dry:
            self.say(f"    would write {p}")
            return
        rc, out = self.run_quiet(autostart.main, ["on"])
        self.echo(out)
        if rc == 0:
            self.did.append("autostart on")
        else:
            self.failed = True
            self.left.append("autostart")

    # 6 -------------------------------------------------------------------------------------------------------
    def pet_step(self):
        self.head(6, "the pet")
        kind, info = health(self.url)
        if kind == "moth":
            pid = info.get("pid") if isinstance(info, dict) else None
            self.line("βœ“", f"moth is already awake at {self.url}" + (f" (pid {pid})" if pid else ""))
            self.say(f"    no pet on your screen? {'moth-pet' if prog() == 'moth' else 'python -m moth.pet'} "
                     "(it uses the moth that's running)")
            self.had.append("moth running")
            return
        if kind == "other":
            self.line("βœ—", f"something else answers at {self.url} ({info}); moth won't start there")
            self.say(f"      pick another port: {prog()} setup --url http://127.0.0.1:<port>  (hooks then need "
                     "MOTH_URL set to it)")
            self.left.append("start moth (port taken)")
            return
        if launch.local_port(self.url) is None:
            self.line("Β·", f"{self.url} isn't on this machine: start moth there")
            return
        log = _path(PET_LOG)
        pid = self._setup_pid()
        if pid:
            self.line("Β·", f"a pet setup started earlier is still waking moth up (pid {pid})")
            self._wait(lambda: alive(pid), lambda: None, pid, log)
            return
        why = []
        if not self.serve_ok:
            why.append("torch / tokenizers missing (step 1)")
        if not self.weights_ok:
            why.append("no weights yet (step 2)")
        if not self.pet_ok:
            why.append("PySide6 missing (step 1)")
        if not has_display():
            why.append("no graphical session here (moth without the pet: " + prog() + ")")
        if why:
            self.line("Β·", "not starting it: " + "; ".join(why))
            self.left.append("start the pet")
            return
        self.line("Β·", f"moth isn't running at {self.url}")
        if not self.ask("start the pet now?", True):
            self.left.append(f"start the pet ({'moth-pet' if prog() == 'moth' else 'python -m moth.pet'})")
            return
        if self.dry:
            self.say(f"    would run: {_fmt_cmd(pet_command(self.url))}  (detached, log {log})")
            self.say(f"    and wait up to {self.timeout:.0f} s for {self.url}/api/health")
            return
        try:
            proc = launch_pet(self.url, log)
        except OSError as e:
            self.line("βœ—", f"couldn't start the pet: {e}")
            self.failed = True
            self.left.append("start the pet")
            return
        self._write_pid(proc.pid)
        self._wait(lambda: proc.poll() is None, proc.poll, proc.pid, log)

    def _wait(self, is_alive, code, pid: int, log: str):
        try:
            serve_log = launch.server_paths()["log"]
        except launch.LaunchError:
            serve_log = None
        logs = f"{log}" + (f", {serve_log}" if serve_log else "")
        self.say("    waiting for moth to wake up (the model loads in well under a minute on most machines)...")
        deadline = time.monotonic() + self.timeout
        while True:
            kind, info = health(self.url)
            if kind == "moth":
                spid = info.get("pid") if isinstance(info, dict) else None
                self.line("βœ“", f"moth is awake at {self.url} (pet pid {pid}" + (f", server pid {spid}" if spid else "")
                          + ")")
                self.say(f"    logs: {logs}")
                self.did.append("moth started")
                self.started = True
                self._drop_pid()
                return
            if kind == "other" or not is_alive():
                if kind == "other":
                    self.line("βœ—", f"something else answers at {self.url} ({info})")
                else:
                    c = code()
                    self.line("βœ—", "the pet stopped before moth woke up" + (f" (exit {c})" if c is not None else ""))
                tail = _tail(log)
                if tail:
                    self.say(f"    last lines of {log}:")
                    self.echo("\n".join(tail), 6)
                self.failed = True
                self.left.append("start the pet")
                self._drop_pid()
                return
            if time.monotonic() >= deadline:
                self.line("!", f"no answer from {self.url} after {self.timeout:.0f} s; moth may still be loading "
                               f"(the pet keeps going, pid {pid})")
                self.say(f"    logs: {logs}")
                self.left.append(f"moth answering (check {prog()} doctor in a minute)")
                self.failed = True
                return
            time.sleep(1.0)

    def _setup_pid(self) -> int | None:
        try:
            with open(_path(PIDFILE)) as f:
                d = json.load(f)
            pid = int(d["pid"])
        except (OSError, ValueError, KeyError, TypeError):
            return None
        if d.get("url") == self.url and alive(pid):
            return pid
        self._drop_pid()
        return None

    def _write_pid(self, pid: int):
        """Only while the pet wakes moth up: a re-run in that window waits for it instead of starting a second."""
        try:
            os.makedirs(os.path.dirname(_path(PIDFILE)), exist_ok=True)
            with open(_path(PIDFILE), "w") as f:
                json.dump({"pid": pid, "url": self.url}, f)
        except OSError:
            pass

    @staticmethod
    def _drop_pid():
        with contextlib.suppress(OSError):
            os.remove(_path(PIDFILE))

    # all of it ----------------------------------------------------------------------------------------------
    def where(self) -> str:
        if self.mode == "hf":
            return f"hugging face download, {os.path.dirname(weights.ROOT)}"
        if self.mode == "pip":
            return "pip install"
        return f"dev checkout, {weights.ROOT}"

    def run(self) -> int:
        self.say(f"moth setup ~ let's get you a moth.  ({self.where()})")
        if self.dry:
            self.say(self.note or "dry run: showing what setup would do with the defaults; nothing is changed or "
                                  "started.")
        kind, _ = health(self.url)
        self.running = kind == "moth"
        self.packages()
        self.weights_step()
        self.memory()
        self.hooks_step()
        self.autostart_step()
        self.pet_step()
        self.summary()
        if self.dry:
            return 0
        return 1 if self.failed or not (self.serve_ok and self.weights_ok) else 0

    def summary(self):
        p = prog()
        self.say("")
        if self.dry:
            self.say("that was a dry run. " + ("run `" + p + " setup` in a terminal to answer the questions, or "
                                               "`" + p + " setup --yes` for the defaults."))
        elif not self.left:
            self.say("all set." + (" moth is fluttering around your screen somewhere, probably near a light."
                                   if self.started else ""))
        else:
            self.say(f"almost: {len(self.left)} thing{'s' if len(self.left) > 1 else ''} left.")
        if self.did:
            self.say("  done now:      " + ", ".join(self.did))
        if self.had:
            self.say("  already there: " + ", ".join(self.had))
        if self.left:
            self.say("  left:          " + ", ".join(self.left))
        where = f" (from {weights.ROOT})" if self.mode == "hf" and p != "moth" else ""
        self.say(f"\nnext{where}:")
        for cmd, what in (("doctor", "health check"), ("config", "moth's settings"),
                          ("forget", "wipe what moth remembers about you"), ("update", "get a newer moth"),
                          ("setup", "again any time: it only offers what's missing")):
            self.say(f"  {p} {cmd:<8} {what}")
        self.say(f"uninstall: {p} unhook --all, then {p} autostart off")


def main(argv=None) -> int:
    ap = argparse.ArgumentParser(prog=f"{prog()} setup", description="from install to a running moth: checks the "
                                 "packages and weights, hooks your coding agents, autostart, and starts the pet")
    ap.add_argument("--yes", "-y", action="store_true",
                    help="take every default without asking (never runs pip; downloads the weights)")
    ap.add_argument("--dry-run", action="store_true", help="only print what would be done")
    ap.add_argument("--url", default=None, help=f"where moth answers (default $MOTH_URL or {DEFAULT_URL})")
    ap.add_argument("--timeout", type=float, default=300.0,
                    help="seconds to wait for moth to answer after starting the pet (default 300)")
    ap.add_argument("--model", default=None,
                    help="the model behind moth: a weights folder, an HF repo id, openai:<url>#<model> or default "
                         "(saved as `moth config model`; moth/docs/MODEL_BACKENDS.md)")
    a = ap.parse_args(argv)
    dry, note = a.dry_run, None
    if not dry and not a.yes and not _tty():
        dry, note = True, "no terminal to ask in, so this is a dry run: nothing is changed or started (--yes does " \
                          "it with the defaults)."
    if a.model is not None:
        value = config._model_value(a.model.strip())
        if value != "default":
            from moth import backends
            try:
                backends.resolve(value, network=False)
            except backends.ManifestError as e:
                print(f"moth setup: --model {a.model}: {e}", file=sys.stderr)
                return 2
        if dry:
            print(f"would set model = {value}  ({prog()} config model)", flush=True)
        else:
            config.write("model", value)
            print(f"model = {value}  (saved in {config.path()})", flush=True)
    s = Setup(yes=a.yes, dry=dry, url=a.url, timeout=a.timeout, note=note)
    try:
        return s.run()
    except KeyboardInterrupt:
        print("\nstopped. what's done stays done; run setup again to pick up from here.", flush=True)
        return 130


if __name__ == "__main__":
    sys.exit(main())