diff --git a/.claude/rules/code-quality.md b/.claude/rules/code-quality.md new file mode 100644 index 00000000..4165909d --- /dev/null +++ b/.claude/rules/code-quality.md @@ -0,0 +1,23 @@ +# Code Quality & Scope Discipline + +## Scope + +- Only change what was asked for — don't touch surrounding code +- If you spot something worth fixing but it wasn't requested, call it out instead of silently doing it +- No drive-by refactors, no "while I'm here" improvements +- One logical change per task — don't bundle unrelated fixes + +## Change Safety + +- Read before edit — always understand a file before modifying it +- Build and test after changes, don't assume it works +- No new dependencies without discussing it first +- Don't delete code you don't fully understand + +## Review Mindset + +- Don't add comments, docstrings, or type annotations to untouched code +- Don't rename things that aren't part of the task +- Don't "improve" error messages or formatting in adjacent code +- Keep PRs reviewable — small, focused diffs +- If a change is getting large, pause and check in with the user diff --git a/.claude/rules/git-safety.md b/.claude/rules/git-safety.md index d7bec1d0..98bf0646 100644 --- a/.claude/rules/git-safety.md +++ b/.claude/rules/git-safety.md @@ -4,8 +4,11 @@ - Remote: `origin` (https://github.com/CalebisGross/mnemonic.git) - Primary branch: `main` -- Feature branches for non-trivial changes: `feat/`, `fix/` -- Direct commits to `main` are OK for small fixes during solo development +- **All new work starts on a feature branch** — never commit directly to `main` +- Branch naming: `feat/`, `fix/` +- Before branching: `git stash` (if dirty), `git pull origin main`, then `git checkout -b ` +- **All changes go through a PR** — push the branch, open a PR with `gh pr create`, get it reviewed +- No blind commits to main, no YOLO pushes ## Forbidden Operations diff --git a/.claude/rules/platform-safety.md b/.claude/rules/platform-safety.md new file mode 100644 index 00000000..bae69456 --- /dev/null +++ b/.claude/rules/platform-safety.md @@ -0,0 +1,28 @@ +# Platform Safety — Never Break the User + +## The Rule + +Never ship code that breaks an existing platform. If it works on macOS today, it must still work on macOS after your change. Same for Linux. Same for any future platform. + +## What This Means + +- Platform-specific code MUST use build tags (`//go:build darwin`, `//go:build linux`, etc.) — not runtime checks that can silently fail +- Every platform-specific file needs a corresponding stub or implementation for other platforms +- Follow the established pattern: `foo_darwin.go` / `foo_linux.go` / `foo_other.go` (see `internal/daemon/service_*.go`, `internal/watcher/filesystem/watcher_*.go`) +- When adding a feature for one platform, verify it doesn't regress another +- When refactoring shared code, mentally trace the code path on EVERY supported platform + +## Supported Platforms + +| Platform | Status | +|----------|--------| +| macOS ARM/x86 | Full support (primary dev) | +| Linux x86_64 | Supported (daemon + serve) | +| Windows | Not yet — but code must compile | + +## Before Merging + +- Does `go build` succeed with no platform-specific imports leaking across build tags? +- Does `go vet` pass? +- Are all interface implementations complete on every platform? +- Did you check that no platform lost functionality compared to before? diff --git a/.gitignore b/.gitignore index 7eab371b..e62f2df2 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ .DS_Store # Python +.venv/ sdk/.venv/ sdk/mnemonic_agent.egg-info/ __pycache__/ diff --git a/cmd/mnemonic/main.go b/cmd/mnemonic/main.go index 00d56bb4..3c219e03 100644 --- a/cmd/mnemonic/main.go +++ b/cmd/mnemonic/main.go @@ -13,7 +13,6 @@ import ( "path/filepath" "strings" "syscall" - "text/template" "time" "github.com/appsprout/mnemonic/internal/config" @@ -26,22 +25,22 @@ import ( "github.com/appsprout/mnemonic/internal/agent/abstraction" "github.com/appsprout/mnemonic/internal/agent/consolidation" - "github.com/appsprout/mnemonic/internal/agent/orchestrator" - "github.com/appsprout/mnemonic/internal/agent/reactor" "github.com/appsprout/mnemonic/internal/agent/dreaming" "github.com/appsprout/mnemonic/internal/agent/encoding" "github.com/appsprout/mnemonic/internal/agent/episoding" "github.com/appsprout/mnemonic/internal/agent/metacognition" + "github.com/appsprout/mnemonic/internal/agent/orchestrator" "github.com/appsprout/mnemonic/internal/agent/perception" + "github.com/appsprout/mnemonic/internal/agent/reactor" "github.com/appsprout/mnemonic/internal/agent/retrieval" "github.com/appsprout/mnemonic/internal/api" "github.com/appsprout/mnemonic/internal/backup" "github.com/appsprout/mnemonic/internal/mcp" "github.com/appsprout/mnemonic/internal/store" + clipwatcher "github.com/appsprout/mnemonic/internal/watcher/clipboard" fswatcher "github.com/appsprout/mnemonic/internal/watcher/filesystem" termwatcher "github.com/appsprout/mnemonic/internal/watcher/terminal" - clipwatcher "github.com/appsprout/mnemonic/internal/watcher/clipboard" "github.com/google/uuid" "github.com/gorilla/websocket" @@ -160,22 +159,24 @@ func main() { // startCommand launches the mnemonic daemon in the background. func startCommand(configPath string) { - // If launchd service is installed, use it - if daemon.IsServiceInstalled() { - if running, pid := daemon.IsServiceRunning(); running { - fmt.Printf("Mnemonic is already running (launchd, PID %d)\n", pid) + svc := daemon.NewServiceManager() + + // If platform service is installed, use it + if svc.IsInstalled() { + if running, pid := svc.IsRunning(); running { + fmt.Printf("Mnemonic is already running (%s, PID %d)\n", svc.ServiceName(), pid) os.Exit(1) } fmt.Printf("Starting mnemonic service...\n") - if err := daemon.StartService(); err != nil { + if err := svc.Start(); err != nil { fmt.Fprintf(os.Stderr, "Error starting service: %v\n", err) os.Exit(1) } // Wait and check if it started time.Sleep(2 * time.Second) - if running, pid := daemon.IsServiceRunning(); running { + if running, pid := svc.IsRunning(); running { cfg, _ := config.Load(configPath) - fmt.Printf("%sMnemonic started%s (launchd, PID %d)\n", colorGreen, colorReset, pid) + fmt.Printf("%sMnemonic started%s (%s, PID %d)\n", colorGreen, colorReset, svc.ServiceName(), pid) if cfg != nil { fmt.Printf(" Dashboard: http://%s:%d\n", cfg.API.Host, cfg.API.Port) } @@ -251,11 +252,13 @@ func startCommand(configPath string) { // stopCommand stops the running mnemonic daemon. func stopCommand() { - // Check launchd service first - if daemon.IsServiceInstalled() { - if running, pid := daemon.IsServiceRunning(); running { + svc := daemon.NewServiceManager() + + // Check platform service first + if svc.IsInstalled() { + if running, pid := svc.IsRunning(); running { fmt.Printf("Stopping mnemonic service (PID %d)...\n", pid) - if err := daemon.StopService(); err != nil { + if err := svc.Stop(); err != nil { fmt.Fprintf(os.Stderr, "Error stopping service: %v\n", err) os.Exit(1) } @@ -285,11 +288,13 @@ func stopCommand() { // restartCommand stops and starts the mnemonic daemon. func restartCommand(configPath string) { - // Check launchd first - if daemon.IsServiceInstalled() { - if running, pid := daemon.IsServiceRunning(); running { + svc := daemon.NewServiceManager() + + // Check platform service first + if svc.IsInstalled() { + if running, pid := svc.IsRunning(); running { fmt.Printf("Stopping mnemonic service (PID %d)...\n", pid) - if err := daemon.StopService(); err != nil { + if err := svc.Stop(); err != nil { fmt.Fprintf(os.Stderr, "Error stopping service: %v\n", err) os.Exit(1) } @@ -447,12 +452,14 @@ func truncID(id string) string { // statusCommand displays comprehensive system status. func statusCommand(configPath string) { + svc := daemon.NewServiceManager() + cfg, err := config.Load(configPath) if err != nil { // Even without config, show daemon state fmt.Printf("%sMnemonic v%s Status%s\n\n", colorBold, Version, colorReset) - if svcRunning, svcPid := daemon.IsServiceRunning(); svcRunning { - fmt.Printf(" Daemon: %srunning%s (launchd, PID %d)\n", colorGreen, colorReset, svcPid) + if svcRunning, svcPid := svc.IsRunning(); svcRunning { + fmt.Printf(" Daemon: %srunning%s (%s, PID %d)\n", colorGreen, colorReset, svc.ServiceName(), svcPid) } else if running, pid := daemon.IsRunning(); running { fmt.Printf(" Daemon: %srunning%s (PID %d)\n", colorGreen, colorReset, pid) } else { @@ -464,12 +471,12 @@ func statusCommand(configPath string) { fmt.Printf("%sMnemonic v%s Status%s\n\n", colorBold, Version, colorReset) - // Daemon state — check launchd first, then PID file + // Daemon state — check platform service first, then PID file running := false pid := 0 mode := "" - if svcRunning, svcPid := daemon.IsServiceRunning(); svcRunning { - running, pid, mode = true, svcPid, " (launchd)" + if svcRunning, svcPid := svc.IsRunning(); svcRunning { + running, pid, mode = true, svcPid, fmt.Sprintf(" (%s)", svc.ServiceName()) } else if pidRunning, pidPid := daemon.IsRunning(); pidRunning { running, pid = true, pidPid } @@ -658,46 +665,13 @@ func formatDuration(d time.Duration) string { } // ============================================================================ -// Install / Uninstall (macOS LaunchAgent) +// Install / Uninstall (platform service) // ============================================================================ -const launchAgentPlist = ` - - - - Label - com.appsprout.mnemonic - ProgramArguments - - {{.ExecPath}} - --config - {{.ConfigPath}} - serve - - RunAtLoad - - KeepAlive - - SuccessfulExit - - - StandardOutPath - {{.LogPath}} - StandardErrorPath - {{.LogPath}} - WorkingDirectory - {{.HomeDir}} - EnvironmentVariables - - PATH - /usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin - - - -` - -// installCommand generates and installs a macOS LaunchAgent plist. +// installCommand registers mnemonic as a platform service (launchd on macOS, systemd on Linux). func installCommand(configPath string) { + svc := daemon.NewServiceManager() + // Validate config _, err := config.Load(configPath) if err != nil { @@ -717,92 +691,34 @@ func installCommand(configPath string) { fmt.Fprintf(os.Stderr, "Error finding executable: %v\n", err) os.Exit(1) } - // Resolve symlinks - execPath, err = filepath.EvalSymlinks(execPath) - if err != nil { - fmt.Fprintf(os.Stderr, "Error resolving executable path: %v\n", err) - os.Exit(1) - } - - homeDir, err := os.UserHomeDir() - if err != nil { - fmt.Fprintf(os.Stderr, "Error getting home directory: %v\n", err) - os.Exit(1) - } - - // Generate plist content - tmpl, err := template.New("plist").Parse(launchAgentPlist) - if err != nil { - fmt.Fprintf(os.Stderr, "Error parsing template: %v\n", err) - os.Exit(1) - } - - data := struct { - ExecPath string - ConfigPath string - LogPath string - HomeDir string - }{ - ExecPath: execPath, - ConfigPath: absConfigPath, - LogPath: daemon.LogPath(), - HomeDir: homeDir, - } - - var plistContent strings.Builder - if err := tmpl.Execute(&plistContent, data); err != nil { - fmt.Fprintf(os.Stderr, "Error generating plist: %v\n", err) - os.Exit(1) - } - // Write plist file - launchAgentsDir := filepath.Join(homeDir, "Library", "LaunchAgents") - if err := os.MkdirAll(launchAgentsDir, 0755); err != nil { - fmt.Fprintf(os.Stderr, "Error creating LaunchAgents directory: %v\n", err) + if err := svc.Install(execPath, absConfigPath); err != nil { + fmt.Fprintf(os.Stderr, "Error installing service: %v\n", err) os.Exit(1) } - plistPath := filepath.Join(launchAgentsDir, "com.appsprout.mnemonic.plist") - if err := os.WriteFile(plistPath, []byte(plistContent.String()), 0644); err != nil { - fmt.Fprintf(os.Stderr, "Error writing plist: %v\n", err) - os.Exit(1) - } - - fmt.Printf("%sLaunchAgent installed.%s\n\n", colorGreen, colorReset) - fmt.Printf(" Plist: %s\n", plistPath) + fmt.Printf("%sService installed (%s).%s\n\n", colorGreen, svc.ServiceName(), colorReset) fmt.Printf(" Binary: %s\n", execPath) fmt.Printf(" Config: %s\n", absConfigPath) fmt.Printf("\nMnemonic will now start automatically on login.\n") - fmt.Printf("To load immediately without rebooting:\n") - fmt.Printf(" launchctl load %s\n\n", plistPath) + fmt.Printf("To start immediately:\n") + fmt.Printf(" mnemonic start\n\n") fmt.Printf("To check status:\n") - fmt.Printf(" launchctl list | grep mnemonic\n\n") + fmt.Printf(" mnemonic status\n\n") fmt.Printf("To uninstall:\n") fmt.Printf(" mnemonic uninstall\n") } -// uninstallCommand removes the macOS LaunchAgent. +// uninstallCommand removes the platform service registration. func uninstallCommand() { - homeDir, err := os.UserHomeDir() - if err != nil { - fmt.Fprintf(os.Stderr, "Error getting home directory: %v\n", err) - os.Exit(1) - } - - plistPath := filepath.Join(homeDir, "Library", "LaunchAgents", "com.appsprout.mnemonic.plist") - - // Try to unload first (may fail if not loaded, that's fine) - if _, err := os.Stat(plistPath); err == nil { - unload := exec.Command("launchctl", "unload", plistPath) - _ = unload.Run() - } + svc := daemon.NewServiceManager() - if err := os.Remove(plistPath); err != nil && !os.IsNotExist(err) { - fmt.Fprintf(os.Stderr, "Error removing plist: %v\n", err) + if err := svc.Uninstall(); err != nil { + fmt.Fprintf(os.Stderr, "Error uninstalling service: %v\n", err) os.Exit(1) } - fmt.Printf("%sLaunchAgent uninstalled.%s\n", colorGreen, colorReset) + fmt.Printf("%sService uninstalled (%s).%s\n", colorGreen, svc.ServiceName(), colorReset) fmt.Printf("Mnemonic will no longer start automatically on login.\n") } @@ -1996,8 +1912,8 @@ MONITORING COMMANDS: watch Live stream of daemon events SETUP COMMANDS: - install Install macOS LaunchAgent (auto-start on login) - uninstall Remove macOS LaunchAgent + install Install as system service (auto-start on login) + uninstall Remove system service version Show version EXAMPLES: diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 8ddaddbd..2005ad2f 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -6,7 +6,6 @@ import ( "os/exec" "path/filepath" "strconv" - "strings" "syscall" "time" ) @@ -143,55 +142,6 @@ func Start(execPath string, configPath string) (int, error) { return pid, nil } -// ============================================================================ -// Launchd service management -// ============================================================================ - -const serviceLabel = "com.appsprout.mnemonic" - -// IsServiceInstalled checks if the launchd service is registered. -func IsServiceInstalled() bool { - cmd := exec.Command("launchctl", "list", serviceLabel) - return cmd.Run() == nil -} - -// IsServiceRunning checks if the launchd service is running and returns its PID. -// The output of `launchctl list