Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 61 additions & 14 deletions cmd/claude-memsync/task_windows.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}

Expand All @@ -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 {
Expand Down
Loading