diff --git a/install_darwin.go b/install_darwin.go
new file mode 100644
index 0000000..a9ed84a
--- /dev/null
+++ b/install_darwin.go
@@ -0,0 +1,202 @@
+//go:build darwin
+
+package main
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "os/user"
+ "path/filepath"
+ "strings"
+)
+
+// install_darwin.go — installation macOS via launchd (LaunchDaemon), équivalent
+// de install_linux.go (systemd). ⚠️ NON TESTÉ sur un vrai Mac : le support macOS
+// était totalement absent (le code systemd/Linux tournait par erreur et échouait
+// sur « systemctl: command not found » / « /etc/default/jean », cf. issue #4).
+// Implémentation prudente ; à valider sur une machine Apple.
+
+const configTemplate = `# Configuration JEAN — édite-moi puis: jean restart
+# Le service launchd lit ce fichier et lance ton binaire llama.cpp.
+
+# Chemins
+BIN="/usr/local/bin/llama-server"
+MODEL="/Users/USER/models/your-model.gguf"
+
+# Serveur
+PORT="8080"
+HOST="0.0.0.0"
+
+# Inference
+CTX="32768"
+BATCH="2048"
+UBATCH="512"
+NGL="999"
+
+# Args supplémentaires passés à llama-server
+EXTRA_ARGS=""
+`
+
+// launchdPlistTemplate — champs formatés : Label, UserName, WorkingDirectory,
+// JEAN_HOME, StandardOutPath, StandardErrorPath. KeepAlive/SuccessfulExit=false
+// ≈ Restart=on-failure ; RunAtLoad relance au boot une fois chargé avec `-w`.
+const launchdPlistTemplate = `
+
+
+
+ Label%s
+ ProgramArguments
+
+ /usr/local/bin/jean
+ serve
+
+ UserName%s
+ WorkingDirectory%s
+ EnvironmentVariables
+ JEAN_HOME%s
+ RunAtLoad
+ KeepAlive
+ SuccessfulExit
+ StandardOutPath%s
+ StandardErrorPath%s
+
+
+`
+
+func cmdInstall(args []string) error {
+ if os.Geteuid() != 0 {
+ return fmt.Errorf("jean install doit être exécuté en root (sudo jean install)")
+ }
+ targetUser := os.Getenv("SUDO_USER")
+ if targetUser == "" {
+ targetUser = "root"
+ }
+ for _, a := range args {
+ if strings.HasPrefix(a, "--user=") {
+ targetUser = strings.TrimPrefix(a, "--user=")
+ }
+ }
+ u, err := user.Lookup(targetUser)
+ if err != nil {
+ return fmt.Errorf("utilisateur '%s' introuvable: %w", targetUser, err)
+ }
+ jeanHome := defaultJeanHome()
+ if v := os.Getenv("JEAN_HOME"); v != "" {
+ jeanHome = v
+ }
+ svc := serviceName()
+
+ fmt.Printf("Installation pour utilisateur %s\n", cyan(targetUser))
+ fmt.Printf(" JEAN_HOME = %s\n", jeanHome)
+ fmt.Printf(" service = %s (launchd)\n", svc)
+
+ // 1. Répertoires
+ for _, d := range []string{jeanHome, filepath.Join(jeanHome, "configs"), filepath.Join(jeanHome, "SKILLS")} {
+ if err := os.MkdirAll(d, 0o755); err != nil {
+ return err
+ }
+ }
+
+ // 2. config.env si absent
+ conf := filepath.Join(jeanHome, "config.env")
+ if _, err := os.Stat(conf); os.IsNotExist(err) {
+ body := strings.ReplaceAll(configTemplate, "USER", targetUser)
+ if err := os.WriteFile(conf, []byte(body), 0o644); err != nil {
+ return err
+ }
+ fmt.Printf(" %s écrit %s\n", green("✓"), conf)
+ }
+
+ // 3. Symlink idempotent vers /usr/local/bin/jean — même garde-fou que Linux
+ // (issue #5 : ne pas se lier sur soi-même si on tourne déjà depuis la cible).
+ self, err := os.Executable()
+ if err != nil {
+ return err
+ }
+ if rp, e := filepath.EvalSymlinks(self); e == nil {
+ self = rp
+ }
+ target := "/usr/local/bin/jean"
+ if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
+ return err
+ }
+ alreadyInPlace := false
+ if tp, e := filepath.EvalSymlinks(target); e == nil && tp == self {
+ alreadyInPlace = true
+ }
+ if alreadyInPlace {
+ fmt.Printf(" %s %s est déjà le binaire installé (aucun lien à créer)\n", green("✓"), target)
+ } else {
+ _ = os.Remove(target)
+ if err := os.Symlink(self, target); err != nil {
+ if data, err := os.ReadFile(self); err == nil {
+ _ = os.WriteFile(target, data, 0o755)
+ }
+ }
+ fmt.Printf(" %s %s -> %s\n", green("✓"), target, self)
+ }
+
+ // 4. /etc/default/jean pour que les invocations CLI résolvent JEAN_HOME.
+ _ = os.MkdirAll("/etc/default", 0o755)
+ defaults := fmt.Sprintf("# Generated by jean install — racine des configs/skills/SKILLS\nJEAN_HOME=%s\n", jeanHome)
+ if err := os.WriteFile("/etc/default/jean", []byte(defaults), 0o644); err != nil {
+ return err
+ }
+ fmt.Printf(" %s /etc/default/jean\n", green("✓"))
+
+ // 5. LaunchDaemon plist (le log va sous JEAN_HOME, accessible au user cible)
+ logPath := filepath.Join(jeanHome, svc+".log")
+ plist := fmt.Sprintf(launchdPlistTemplate, launchdLabel(svc), targetUser, jeanHome, jeanHome, logPath, logPath)
+ plistPath := launchdPlistPath(svc)
+ if err := os.WriteFile(plistPath, []byte(plist), 0o644); err != nil {
+ return err
+ }
+ fmt.Printf(" %s %s\n", green("✓"), plistPath)
+
+ // 6. chown JEAN_HOME au user cible
+ chown(jeanHome, u)
+
+ fmt.Println()
+ fmt.Printf("%s installation terminée.\n", green("[ok]"))
+ fmt.Printf("\nProchaines étapes :\n")
+ fmt.Printf(" 1. édite la config : %s\n", bold("sudo -u "+targetUser+" jean edit"))
+ fmt.Printf(" (renseigne BIN, MODEL, etc.)\n")
+ fmt.Printf(" 2. démarre le service: %s\n", bold("sudo jean start"))
+ fmt.Printf(" 3. UI web : %s\n", bold("sudo -u "+targetUser+" jean web"))
+ return nil
+}
+
+func cmdUninstall(args []string) error {
+ if os.Geteuid() != 0 {
+ return fmt.Errorf("jean uninstall doit être exécuté en root")
+ }
+ svc := serviceName()
+ _ = exec.Command("launchctl", "unload", "-w", launchdPlistPath(svc)).Run()
+ for _, p := range []string{
+ launchdPlistPath(svc),
+ "/etc/default/jean",
+ "/usr/local/bin/jean",
+ } {
+ if err := os.Remove(p); err == nil {
+ fmt.Printf(" %s %s\n", green("✓"), p)
+ }
+ }
+ // On ne supprime jamais JEAN_HOME automatiquement (modèles, configs).
+ fmt.Println(dim("(données utilisateur conservées — supprime $JEAN_HOME manuellement si tu veux purger)"))
+ fmt.Println(green("[ok]") + " désinstallé")
+ return nil
+}
+
+// chown recursively changes ownership of path to the given user/group.
+func chown(path string, u *user.User) {
+ var uid, gid int
+ fmt.Sscanf(u.Uid, "%d", &uid)
+ fmt.Sscanf(u.Gid, "%d", &gid)
+ filepath.Walk(path, func(p string, info os.FileInfo, err error) error {
+ if err == nil {
+ _ = os.Chown(p, uid, gid)
+ }
+ return nil
+ })
+}
diff --git a/install_unix.go b/install_linux.go
similarity index 99%
rename from install_unix.go
rename to install_linux.go
index e4a8119..9f9a5d2 100644
--- a/install_unix.go
+++ b/install_linux.go
@@ -1,4 +1,4 @@
-//go:build unix
+//go:build linux
package main
diff --git a/service.go b/service.go
index 33ca02a..1f5cd7b 100644
--- a/service.go
+++ b/service.go
@@ -10,7 +10,8 @@ import (
// service.go holds the platform-neutral pieces of service management. The
// actual start/stop/restart/status/logs implementation is platform-specific:
-// - service_unix.go → systemd (systemctl/journalctl)
+// - service_linux.go → systemd (systemctl/journalctl)
+// - service_darwin.go → launchd (launchctl)
// - service_windows.go → PID-file background process supervisor
//
// editConfig and showVram live here because they work the same everywhere.
diff --git a/service_darwin.go b/service_darwin.go
new file mode 100644
index 0000000..f9994e6
--- /dev/null
+++ b/service_darwin.go
@@ -0,0 +1,103 @@
+//go:build darwin
+
+package main
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+// service_darwin.go — gestion du service via launchd (LaunchDaemon), équivalent
+// macOS de service_linux.go (systemd). ⚠️ NON TESTÉ sur un vrai Mac : jusqu'ici
+// le support macOS était absent (le code systemd/Linux était utilisé par erreur,
+// cf. issue #4). Implémentation prudente basée sur launchctl load/unload/list.
+
+// launchdLabel dérive le label launchd du service (ex. "com.jean.jean").
+func launchdLabel(svc string) string { return "com.jean." + svc }
+
+// launchdPlistPath : chemin du LaunchDaemon (domaine système, exécuté par root
+// puis abaissé à l'utilisateur cible via la clé UserName du plist).
+func launchdPlistPath(svc string) string {
+ return "/Library/LaunchDaemons/" + launchdLabel(svc) + ".plist"
+}
+
+// launchdLogPath : sortie standard/erreur du service, sous JEAN_HOME (accessible
+// en écriture par l'utilisateur du service après le chown de l'installation).
+func launchdLogPath() string { return filepath.Join(JeanHome(), serviceName()+".log") }
+
+// serviceAction mappe start/stop/restart/enable/disable sur launchctl. `load -w`
+// (re)active le service ET le rend persistant au boot ; `unload -w` le désactive.
+func serviceAction(action string) error {
+ svc := serviceName()
+ plist := launchdPlistPath(svc)
+ run := func(args ...string) error {
+ bin, a := "launchctl", args
+ if os.Geteuid() != 0 {
+ bin, a = "sudo", append([]string{"-n", "launchctl"}, args...)
+ }
+ cmd := exec.Command(bin, a...)
+ cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
+ return cmd.Run()
+ }
+ switch action {
+ case "start", "enable":
+ if err := run("load", "-w", plist); err != nil {
+ return err
+ }
+ return checkStarted(svc)
+ case "stop", "disable":
+ if err := run("unload", "-w", plist); err != nil {
+ return err
+ }
+ fmt.Println(green("[ok]") + " arrêté")
+ return nil
+ case "restart":
+ _ = run("unload", plist) // best-effort : peut ne pas être chargé
+ if err := run("load", "-w", plist); err != nil {
+ return err
+ }
+ return checkStarted(svc)
+ }
+ return fmt.Errorf("action inconnue: %s", action)
+}
+
+func checkStarted(svc string) error {
+ time.Sleep(2 * time.Second)
+ if serviceIsActive() {
+ fmt.Printf("%s %s: actif\n", green("[ok]"), svc)
+ return nil
+ }
+ fmt.Printf("%s %s: non démarré — derniers logs :\n", red("[ERREUR]"), svc)
+ fmt.Println("------------------------------------------------")
+ if b, err := os.ReadFile(launchdLogPath()); err == nil {
+ lines := strings.Split(strings.TrimRight(string(b), "\n"), "\n")
+ if len(lines) > 20 {
+ lines = lines[len(lines)-20:]
+ }
+ fmt.Println(strings.Join(lines, "\n"))
+ }
+ fmt.Println("------------------------------------------------")
+ fmt.Printf("→ jean logs pour plus de détails\n→ jean edit pour corriger config.env\n")
+ return fmt.Errorf("service %s non démarré", svc)
+}
+
+func serviceLogs() error {
+ // tail -f du fichier de log défini dans le plist (StandardOut/ErrorPath).
+ cmd := exec.Command("tail", "-n", "80", "-f", launchdLogPath())
+ cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
+ return cmd.Run()
+}
+
+// serviceIsActive : le service est chargé ET possède un PID courant. `launchctl
+// list