OS-Native Scheduler Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add sap-devs service install/uninstall/status commands that register an OS-native scheduled task to run sap-devs sync && sap-devs inject --no-sync on a configurable interval.
Architecture: A Scheduler interface in internal/service/ with three platform implementations behind build tags. Each implementation generates a platform-native config file and shells out to OS tools (schtasks, launchctl, systemctl). CLI commands in cmd/service.go wire the interface to cobra.
Tech Stack: Go stdlib (os/exec, text/template, runtime), cobra, build tags
Spec: docs/superpowers/specs/2026-04-20-system-tray-design.md
Windows note: go test fails locally due to Windows Defender. Use go build ./... and go vet ./... locally; CI (ubuntu-latest) is the authoritative test runner.
Task 1: Config — Add service_interval key
Files:
Modify:
internal/config/config.goTest:
internal/config/config_test.go[ ] Step 1: Write the failing test
Add to internal/config/config_test.go:
func TestServiceConfig_Defaults(t *testing.T) {
dir := t.TempDir()
cfg, err := config.Load(dir)
require.NoError(t, err)
assert.Equal(t, 6*time.Hour, cfg.Service.Interval)
}
func TestServiceConfig_RoundTrip(t *testing.T) {
dir := t.TempDir()
cfg := config.Default()
cfg.Service.Interval = 12 * time.Hour
require.NoError(t, cfg.Save(dir))
loaded, err := config.Load(dir)
require.NoError(t, err)
assert.Equal(t, 12*time.Hour, loaded.Service.Interval)
}- [ ] Step 2: Run test to verify it fails
Run: go build ./... && go vet ./... Expected: Compilation failure — Config has no Service field
- [ ] Step 3: Write minimal implementation
In internal/config/config.go, add the struct and field.
Note: The spec shows a flat service_interval: 6h for brevity, but we use a nested struct (service.interval) to match the existing pattern for SyncConfig, TipConfig, etc. This is an intentional, superior organization — all config sub-structs follow this nested pattern.
// ServiceConfig controls the OS-native background scheduler.
type ServiceConfig struct {
Interval time.Duration `yaml:"interval"`
}Add to the Config struct:
Service ServiceConfig `yaml:"service,omitempty"`Add to Default():
Service: ServiceConfig{
Interval: 6 * time.Hour,
},- [ ] Step 4: Run build to verify it compiles
Run: go build ./... && go vet ./... Expected: Clean build
- [ ] Step 5: Commit
git add internal/config/config.go internal/config/config_test.go
git commit -m "feat(service): add service_interval config key with 6h default"Task 2: Scheduler interface and Status type
Files:
Create:
internal/service/scheduler.go[ ] Step 1: Create the interface file
Create internal/service/scheduler.go:
package service
import (
"fmt"
"time"
)
// Status describes the current state of the OS scheduler entry.
type Status struct {
Installed bool
Interval time.Duration
LastRun time.Time // zero value if unknown or never run
NextRun time.Time // zero value if unknown
}
// Scheduler manages an OS-native scheduled task for background sync+inject.
type Scheduler interface {
Install(interval time.Duration, binaryPath string) error
Uninstall() error
Status() (*Status, error)
}
// New returns the platform-appropriate Scheduler for the given cache directory.
// cacheDir is used for the daemon log path.
func New(cacheDir string) Scheduler {
return newPlatformScheduler(cacheDir)
}
// ErrNotInstalled is returned when querying status of an uninstalled scheduler.
var ErrNotInstalled = fmt.Errorf("scheduler is not installed")- [ ] Step 2: Run build to verify it compiles
Run: go build ./... Expected: Fails because newPlatformScheduler is not defined yet (expected — platform files come next)
- [ ] Step 3: Commit
git add internal/service/scheduler.go
git commit -m "feat(service): add Scheduler interface and Status type"Task 3: Windows scheduler implementation
Files:
Create:
internal/service/scheduler_windows.goCreate:
internal/service/scheduler_windows_test.go[ ] Step 1: Write the test
Create internal/service/scheduler_windows_test.go:
//go:build windows
package service
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestWindowsTaskName(t *testing.T) {
s := &windowsScheduler{cacheDir: t.TempDir()}
assert.Equal(t, "sap-devs-sync", s.taskName())
}
func TestWindowsIntervalMinutes(t *testing.T) {
assert.Equal(t, "360", intervalMinutes(6*time.Hour))
assert.Equal(t, "60", intervalMinutes(1*time.Hour))
assert.Equal(t, "1440", intervalMinutes(24*time.Hour))
}- [ ] Step 2: Write the implementation
Create internal/service/scheduler_windows.go:
//go:build windows
package service
import (
"fmt"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
type windowsScheduler struct {
cacheDir string
}
func newPlatformScheduler(cacheDir string) Scheduler {
return &windowsScheduler{cacheDir: cacheDir}
}
func (s *windowsScheduler) taskName() string { return "sap-devs-sync" }
func (s *windowsScheduler) logPath() string {
return filepath.Join(s.cacheDir, "daemon.log")
}
func (s *windowsScheduler) Install(interval time.Duration, binaryPath string) error {
script := fmt.Sprintf(`%s sync > "%s" 2>&1 && %s inject --no-sync >> "%s" 2>&1`,
binaryPath, s.logPath(), binaryPath, s.logPath())
cmd := exec.Command("schtasks", "/create",
"/tn", s.taskName(),
"/tr", fmt.Sprintf(`cmd /c "%s"`, script),
"/sc", "minute",
"/mo", intervalMinutes(interval),
"/f",
)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("schtasks create failed: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
func (s *windowsScheduler) Uninstall() error {
cmd := exec.Command("schtasks", "/delete", "/tn", s.taskName(), "/f")
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("schtasks delete failed: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
func (s *windowsScheduler) Status() (*Status, error) {
cmd := exec.Command("schtasks", "/query", "/tn", s.taskName(), "/fo", "csv", "/nh")
out, err := cmd.CombinedOutput()
if err != nil {
return &Status{Installed: false}, nil
}
fields := strings.Split(strings.TrimSpace(string(out)), ",")
st := &Status{Installed: true}
if len(fields) >= 3 {
if t, err := time.Parse("1/2/2006 3:04:05 PM", strings.Trim(fields[2], "\"")); err == nil {
st.NextRun = t
}
}
return st, nil
}
func intervalMinutes(d time.Duration) string {
mins := int(d.Minutes())
if mins < 1 {
mins = 1
}
return strconv.Itoa(mins)
}- [ ] Step 3: Run build to verify it compiles
Run: go build ./... Expected: On Windows, should compile. On other platforms, this file is skipped by build tag.
- [ ] Step 4: Commit
git add internal/service/scheduler_windows.go internal/service/scheduler_windows_test.go
git commit -m "feat(service): add Windows Task Scheduler implementation"Task 4: macOS scheduler implementation
Files:
Create:
internal/service/scheduler_darwin.goCreate:
internal/service/scheduler_darwin_test.go[ ] Step 1: Write the test
Create internal/service/scheduler_darwin_test.go:
//go:build darwin
package service
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDarwinPlistGeneration(t *testing.T) {
dir := t.TempDir()
s := &darwinScheduler{cacheDir: dir}
plist := s.generatePlist(6*time.Hour, "/usr/local/bin/sap-devs")
assert.Contains(t, plist, "<integer>21600</integer>")
assert.Contains(t, plist, "/usr/local/bin/sap-devs")
assert.Contains(t, plist, "com.sap-devs.sync")
}
func TestDarwinPlistPath(t *testing.T) {
s := &darwinScheduler{cacheDir: t.TempDir()}
path := s.plistPath()
assert.Contains(t, path, "LaunchAgents")
assert.Contains(t, path, "com.sap-devs.sync.plist")
}- [ ] Step 2: Write the implementation
Create internal/service/scheduler_darwin.go:
//go:build darwin
package service
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
type darwinScheduler struct {
cacheDir string
}
func newPlatformScheduler(cacheDir string) Scheduler {
return &darwinScheduler{cacheDir: cacheDir}
}
const plistLabel = "com.sap-devs.sync"
func (s *darwinScheduler) plistPath() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, "Library", "LaunchAgents", plistLabel+".plist")
}
func (s *darwinScheduler) logPath() string {
return filepath.Join(s.cacheDir, "daemon.log")
}
func (s *darwinScheduler) generatePlist(interval time.Duration, binaryPath string) string {
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>%s</string>
<key>ProgramArguments</key>
<array>
<string>/bin/sh</string>
<string>-c</string>
<string>%s sync && %s inject --no-sync</string>
</array>
<key>StartInterval</key>
<integer>%d</integer>
<key>StandardOutPath</key>
<string>%s</string>
<key>StandardErrorPath</key>
<string>%s</string>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>`, plistLabel, binaryPath, binaryPath, int(interval.Seconds()), s.logPath(), s.logPath())
}
func (s *darwinScheduler) Install(interval time.Duration, binaryPath string) error {
plist := s.generatePlist(interval, binaryPath)
path := s.plistPath()
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
if err := os.WriteFile(path, []byte(plist), 0644); err != nil {
return err
}
cmd := exec.Command("launchctl", "load", path)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("launchctl load failed: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
func (s *darwinScheduler) Uninstall() error {
path := s.plistPath()
cmd := exec.Command("launchctl", "unload", path)
_ = cmd.Run() // ignore error if not loaded
return os.Remove(path)
}
func (s *darwinScheduler) Status() (*Status, error) {
path := s.plistPath()
if _, err := os.Stat(path); os.IsNotExist(err) {
return &Status{Installed: false}, nil
}
cmd := exec.Command("launchctl", "list", plistLabel)
out, err := cmd.CombinedOutput()
if err != nil {
return &Status{Installed: true}, nil
}
_ = out // launchctl list output is informational
return &Status{Installed: true}, nil
}- [ ] Step 3: Run build to verify
Run: go build ./... Expected: Compiles (darwin build tag active on macOS, skipped elsewhere)
- [ ] Step 4: Commit
git add internal/service/scheduler_darwin.go internal/service/scheduler_darwin_test.go
git commit -m "feat(service): add macOS launchd scheduler implementation"Task 5: Linux scheduler implementation
Files:
Create:
internal/service/scheduler_linux.goCreate:
internal/service/scheduler_linux_test.go[ ] Step 1: Write the test
Create internal/service/scheduler_linux_test.go:
//go:build linux
package service
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestLinuxServiceUnit(t *testing.T) {
s := &linuxScheduler{cacheDir: "/tmp/test"}
unit := s.generateServiceUnit("/usr/local/bin/sap-devs")
assert.Contains(t, unit, "ExecStart=/bin/sh")
assert.Contains(t, unit, "/usr/local/bin/sap-devs sync")
assert.Contains(t, unit, "sap-devs background sync")
}
func TestLinuxTimerUnit(t *testing.T) {
s := &linuxScheduler{cacheDir: "/tmp/test"}
timer := s.generateTimerUnit(6 * time.Hour)
assert.Contains(t, timer, "OnUnitActiveSec=6h0m0s")
assert.Contains(t, timer, "OnBootSec=5min")
}- [ ] Step 2: Write the implementation
Create internal/service/scheduler_linux.go:
//go:build linux
package service
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
type linuxScheduler struct {
cacheDir string
}
func newPlatformScheduler(cacheDir string) Scheduler {
return &linuxScheduler{cacheDir: cacheDir}
}
const unitName = "sap-devs-sync"
func (s *linuxScheduler) unitDir() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "systemd", "user")
}
func (s *linuxScheduler) logPath() string {
return filepath.Join(s.cacheDir, "daemon.log")
}
func (s *linuxScheduler) generateServiceUnit(binaryPath string) string {
return fmt.Sprintf(`[Unit]
Description=sap-devs background sync
[Service]
Type=oneshot
ExecStart=/bin/sh -c '%s sync && %s inject --no-sync'
StandardOutput=file:%s
StandardError=file:%s
`, binaryPath, binaryPath, s.logPath(), s.logPath())
}
func (s *linuxScheduler) generateTimerUnit(interval time.Duration) string {
return fmt.Sprintf(`[Unit]
Description=sap-devs background sync timer
[Timer]
OnBootSec=5min
OnUnitActiveSec=%s
Persistent=true
[Install]
WantedBy=timers.target
`, interval)
}
func (s *linuxScheduler) Install(interval time.Duration, binaryPath string) error {
dir := s.unitDir()
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
svc := s.generateServiceUnit(binaryPath)
if err := os.WriteFile(filepath.Join(dir, unitName+".service"), []byte(svc), 0644); err != nil {
return err
}
tmr := s.generateTimerUnit(interval)
if err := os.WriteFile(filepath.Join(dir, unitName+".timer"), []byte(tmr), 0644); err != nil {
return err
}
_ = exec.Command("systemctl", "--user", "daemon-reload").Run()
cmd := exec.Command("systemctl", "--user", "enable", "--now", unitName+".timer")
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("systemctl enable failed: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
func (s *linuxScheduler) Uninstall() error {
_ = exec.Command("systemctl", "--user", "disable", "--now", unitName+".timer").Run()
dir := s.unitDir()
_ = os.Remove(filepath.Join(dir, unitName+".service"))
_ = os.Remove(filepath.Join(dir, unitName+".timer"))
_ = exec.Command("systemctl", "--user", "daemon-reload").Run()
return nil
}
func (s *linuxScheduler) Status() (*Status, error) {
dir := s.unitDir()
timerPath := filepath.Join(dir, unitName+".timer")
if _, err := os.Stat(timerPath); os.IsNotExist(err) {
return &Status{Installed: false}, nil
}
st := &Status{Installed: true}
cmd := exec.Command("systemctl", "--user", "show", unitName+".timer",
"--property=LastTriggerUSec,NextElapseUSecRealtime")
out, err := cmd.CombinedOutput()
if err != nil {
return st, nil
}
for _, line := range strings.Split(string(out), "\n") {
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
if t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", parts[1]); err == nil {
switch parts[0] {
case "LastTriggerUSec":
st.LastRun = t
case "NextElapseUSecRealtime":
st.NextRun = t
}
}
}
return st, nil
}- [ ] Step 3: Run build to verify
Run: go build ./... Expected: Compiles on Linux; skipped on other platforms
- [ ] Step 4: Commit
git add internal/service/scheduler_linux.go internal/service/scheduler_linux_test.go
git commit -m "feat(service): add Linux systemd timer scheduler implementation"Task 6: CLI commands — sap-devs service install/uninstall/status
Files:
Create:
cmd/service.go[ ] Step 1: Write the command file
Create cmd/service.go:
package cmd
import (
"fmt"
"os"
"time"
"github.com/SAP-samples/sap-devs-cli/internal/config"
"github.com/SAP-samples/sap-devs-cli/internal/service"
"github.com/SAP-samples/sap-devs-cli/internal/xdg"
"github.com/spf13/cobra"
)
var serviceCmd = &cobra.Command{
Use: "service",
Short: "Manage background sync scheduler",
Long: "Register or remove an OS-native scheduled task that runs sap-devs sync + inject on a configurable interval.",
}
var serviceInstallCmd = &cobra.Command{
Use: "install",
Short: "Register the OS scheduler entry",
RunE: func(cmd *cobra.Command, args []string) error {
paths, err := xdg.New()
if err != nil {
return err
}
cfg, err := config.Load(paths.ConfigDir)
if err != nil {
return err
}
binaryPath, err := os.Executable()
if err != nil {
return fmt.Errorf("could not determine binary path: %w", err)
}
sched := service.New(paths.CacheDir)
interval := cfg.Service.Interval
if interval == 0 {
interval = 6 * time.Hour
}
if err := sched.Install(interval, binaryPath); err != nil {
return fmt.Errorf("could not install scheduler: %w", err)
}
fmt.Fprintf(cmd.OutOrStdout(), "Background scheduler installed (every %s).\n", interval)
fmt.Fprintf(cmd.OutOrStdout(), "Runs: %s sync && %s inject --no-sync\n", binaryPath, binaryPath)
return nil
},
}
var serviceUninstallCmd = &cobra.Command{
Use: "uninstall",
Short: "Remove the OS scheduler entry",
RunE: func(cmd *cobra.Command, args []string) error {
paths, err := xdg.New()
if err != nil {
return err
}
sched := service.New(paths.CacheDir)
if err := sched.Uninstall(); err != nil {
return fmt.Errorf("could not uninstall scheduler: %w", err)
}
fmt.Fprintln(cmd.OutOrStdout(), "Background scheduler removed.")
return nil
},
}
var serviceStatusCmd = &cobra.Command{
Use: "status",
Short: "Show scheduler status",
RunE: func(cmd *cobra.Command, args []string) error {
paths, err := xdg.New()
if err != nil {
return err
}
sched := service.New(paths.CacheDir)
st, err := sched.Status()
if err != nil {
return err
}
out := cmd.OutOrStdout()
if !st.Installed {
fmt.Fprintln(out, "Scheduler: not installed")
fmt.Fprintln(out, "Run `sap-devs service install` to enable background sync.")
return nil
}
fmt.Fprintln(out, "Scheduler: installed")
if !st.LastRun.IsZero() {
fmt.Fprintf(out, "Last run: %s\n", st.LastRun.Format(time.RFC3339))
}
if !st.NextRun.IsZero() {
fmt.Fprintf(out, "Next run: %s\n", st.NextRun.Format(time.RFC3339))
}
return nil
},
}
func init() {
serviceCmd.AddCommand(serviceInstallCmd)
serviceCmd.AddCommand(serviceUninstallCmd)
serviceCmd.AddCommand(serviceStatusCmd)
rootCmd.AddCommand(serviceCmd)
}- [ ] Step 2: Run build to verify
Run: go build ./... && go vet ./... Expected: Clean build
- [ ] Step 3: Smoke test
Run: go run . service status Expected: "Scheduler: not installed" message
- [ ] Step 4: Commit
git add cmd/service.go
git commit -m "feat(service): add service install/uninstall/status CLI commands"Task 7: Update CLAUDE.md and docs
Files:
Modify:
CLAUDE.mdModify:
docs/developer-guide.md(if exists)[ ] Step 1: Add service commands to CLI reference table in CLAUDE.md
Add to the CLI Commands table:
| `service install/uninstall/status` | Manage OS-native background scheduler (systemd/launchd/Task Scheduler) |- [ ] Step 2: Commit
git add CLAUDE.md
git commit -m "docs: add service commands to CLI reference"