From 7865245cd8a837d0ba6adaaed54228cd7d867cc3 Mon Sep 17 00:00:00 2001 From: Rockford Lhotka Date: Sat, 9 May 2026 13:03:46 -0500 Subject: [PATCH] fix(windows): use VBScript launcher to avoid lingering cmd window at logon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous install dropped a .cmd into the Startup folder that ran `start "" /B claude-memsync.exe run`. Because /B shares the parent console, the daemon stayed attached to cmd.exe's console, keeping the window open for as long as the daemon ran. Switch to a .vbs launcher using WScript.Shell.Run with window-style 0 and bWaitOnReturn=False — wscript.exe launches the daemon hidden and detached, then exits. No console window is left behind. installTask and uninstallTask also remove the legacy claude-memsync.cmd so users upgrading from an older install don't end up with both launchers firing at next logon. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 10 ++-- cmd/claude-memsync/task_windows.go | 75 ++++++++++++++++++++++++------ 2 files changed, 67 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 5f7c143..38231b5 100644 --- a/README.md +++ b/README.md @@ -96,10 +96,12 @@ claude-memsync status `install` registers the daemon to auto-start when you log in: -- **Windows**: drops `claude-memsync.cmd` in - `%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\`. No admin - required. (Task Scheduler was investigated; it requires admin even for - per-user logon tasks, so the Startup folder is the simpler path.) +- **Windows**: drops `claude-memsync.vbs` in + `%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\`. The script + launches the daemon hidden and detached via `WScript.Shell.Run`, so no + console window is left behind at logon. No admin required. (Task + Scheduler was investigated; it requires admin even for per-user logon + tasks, so the Startup folder is the simpler path.) - **Linux**: writes a systemd user unit at `~/.config/systemd/user/claude-memsync.service` and enables it. - **macOS**: writes a launchd plist at diff --git a/cmd/claude-memsync/task_windows.go b/cmd/claude-memsync/task_windows.go index 77c8e57..c03712f 100644 --- a/cmd/claude-memsync/task_windows.go +++ b/cmd/claude-memsync/task_windows.go @@ -1,11 +1,18 @@ package main -// On Windows we install the daemon by dropping a startup script into the -// per-user Startup folder +// On Windows we install the daemon by dropping a VBScript launcher into +// the per-user Startup folder // (%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\). Windows runs -// every executable script in that folder at user logon, in the user's -// session, with full access to their credential vault and SSH keys, and -// without requiring admin rights. +// every script in that folder at user logon, in the user's session, with +// full access to their credential vault and SSH keys, and without +// requiring admin rights. +// +// We use a .vbs (not a .cmd) deliberately. A .cmd launched at logon opens +// a cmd.exe console; if the script then runs the daemon with +// `start "" /B`, the daemon inherits that console and the window stays +// open as long as the daemon runs. WScript.Shell.Run with window-style 0 +// and bWaitOnReturn=False launches the daemon truly hidden and detached, +// and wscript.exe exits immediately. // // We deliberately do NOT use Task Scheduler — schtasks.exe /Create requires // admin (Microsoft's rule, regardless of /RL LIMITED), which would force @@ -36,14 +43,40 @@ func detachedSysProcAttr() *syscall.SysProcAttr { } } -// startupScriptPath returns the absolute path to the .cmd file that lives -// in the user's Startup folder. -func startupScriptPath() (string, error) { +// startupFolder returns the per-user Startup folder. +func startupFolder() (string, error) { appdata := os.Getenv("APPDATA") if appdata == "" { return "", fmt.Errorf("APPDATA not set") } - return filepath.Join(appdata, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", "claude-memsync.cmd"), nil + return filepath.Join(appdata, "Microsoft", "Windows", "Start Menu", "Programs", "Startup"), nil +} + +// startupScriptPath returns the absolute path to the .vbs launcher in the +// user's Startup folder. +func startupScriptPath() (string, error) { + dir, err := startupFolder() + if err != nil { + return "", err + } + return filepath.Join(dir, "claude-memsync.vbs"), nil +} + +// legacyStartupScriptPath returns the path to the pre-0.x .cmd launcher +// that earlier installs dropped into the Startup folder. We remove it on +// install/uninstall so users who upgrade don't end up with both. +func legacyStartupScriptPath() (string, error) { + dir, err := startupFolder() + if err != nil { + return "", err + } + return filepath.Join(dir, "claude-memsync.cmd"), nil +} + +// vbsQuote escapes a string for inclusion inside a VBScript double-quoted +// literal. In VBScript, a double quote is escaped by doubling it. +func vbsQuote(s string) string { + return strings.ReplaceAll(s, `"`, `""`) } func installTask(configPath string) error { @@ -55,19 +88,27 @@ func installTask(configPath string) error { if err != nil { return err } + // Build the command line that WScript.Shell.Run will execute. The + // outer literal is a VBScript double-quoted string, so any embedded + // double quotes must be doubled. cmd := fmt.Sprintf(`"%s" run`, exe) if configPath != "" && configPath != defaultConfigPath() { cmd = fmt.Sprintf(`"%s" run --config "%s"`, exe, configPath) } - body := "@echo off\r\n" + - "REM auto-generated by claude-memsync install; do not edit by hand\r\n" + - "start \"\" /B " + cmd + "\r\n" + body := "' auto-generated by claude-memsync install; do not edit by hand\r\n" + + "' window style 0 = hidden, bWaitOnReturn = False = fire-and-forget\r\n" + + "CreateObject(\"WScript.Shell\").Run \"" + vbsQuote(cmd) + "\", 0, False\r\n" if err := os.MkdirAll(filepath.Dir(scriptPath), 0700); err != nil { return err } if err := os.WriteFile(scriptPath, []byte(body), 0600); err != nil { return fmt.Errorf("write startup script %s: %w", scriptPath, err) } + // Best-effort removal of the pre-vbs .cmd launcher so users upgrading + // from an older version don't get both at next logon. + if legacy, err := legacyStartupScriptPath(); err == nil { + _ = os.Remove(legacy) + } return nil } @@ -76,10 +117,16 @@ func uninstallTask() error { if err != nil { return err } + var firstErr error if err := os.Remove(scriptPath); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("remove startup script %s: %w", scriptPath, err) + firstErr = fmt.Errorf("remove startup script %s: %w", scriptPath, err) } - return nil + if legacy, err := legacyStartupScriptPath(); err == nil { + if err := os.Remove(legacy); err != nil && !os.IsNotExist(err) && firstErr == nil { + firstErr = fmt.Errorf("remove legacy startup script %s: %w", legacy, err) + } + } + return firstErr } func startTask() error {