Tray Headless Launch & App Shortcuts 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: Eliminate the terminal window when the tray binary launches on Windows, and provide native app shortcuts for easy manual launch on all platforms.
Architecture: Build-tagged platform files split process launching (startProcess) and shortcut management (CreateShortcuts/RemoveShortcuts) into per-OS implementations. The release pipeline packages icons alongside the binary and sets the Windows PE subsystem to GUI. The existing Install()/Uninstall() methods in manager.go gain shortcut lifecycle calls.
Tech Stack: Go build tags, syscall.SysProcAttr (Windows), PowerShell COM (.lnk), macOS .app bundle, freedesktop .desktop files, GitHub Actions workflow YAML.
Spec: docs/superpowers/specs/2026-05-07-tray-headless-launch-design.md
Task 1: Platform-specific startProcess — Windows
Files:
Create:
internal/trayctl/start_windows.go[ ] Step 1: Create the build-tagged Windows start implementation
//go:build windows
package trayctl
import (
"os/exec"
"syscall"
)
func startProcess(binaryPath string) error {
cmd := exec.Command(binaryPath)
cmd.SysProcAttr = &syscall.SysProcAttr{
CreationFlags: 0x00000008 | 0x00000010, // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
}
return cmd.Start()
}- [ ] Step 2: Verify it compiles
Run: go build ./internal/trayctl/ Expected: clean build (no output)
- [ ] Step 3: Commit
git add internal/trayctl/start_windows.go
git commit -m "feat(tray): add Windows startProcess with DETACHED_PROCESS flags"Task 2: Platform-specific startProcess — Unix default
Files:
Create:
internal/trayctl/start_other.go[ ] Step 1: Create the build-tagged non-Windows implementation
//go:build !windows
package trayctl
import "os/exec"
func startProcess(binaryPath string) error {
cmd := exec.Command(binaryPath)
cmd.Stdout = nil
cmd.Stderr = nil
if err := cmd.Start(); err != nil {
return err
}
return cmd.Process.Release()
}- [ ] Step 2: Verify it compiles
Run: go build ./internal/trayctl/ Expected: clean build
- [ ] Step 3: Commit
git add internal/trayctl/start_other.go
git commit -m "feat(tray): add Unix startProcess with process release"Task 3: Refactor Start() to delegate to startProcess
Files:
Modify:
internal/trayctl/manager.go:132-143[ ] Step 1: Replace the
Start()method body
Change the current Start() method (lines 132-143) to:
func (m *Manager) Start() error {
if !m.IsInstalled() {
return fmt.Errorf("tray is not installed — run `sap-devs tray install` first")
}
return startProcess(m.BinaryPath())
}- [ ] Step 2: Remove unused import
"os/exec"if it's now only used elsewhere
Check if os/exec is still needed by other methods in manager.go (Stop(), Verify(), IsRunning() — yes, they use it). No change needed.
- [ ] Step 3: Verify it compiles
Run: go build ./internal/trayctl/ Expected: clean build
- [ ] Step 4: Run vet
Run: go vet ./internal/trayctl/ Expected: no issues
- [ ] Step 5: Commit
git add internal/trayctl/manager.go
git commit -m "refactor(tray): delegate Start() to platform-specific startProcess"Task 4: Extract assets — replace extractBinary with extractAssets
Files:
Modify:
internal/trayctl/extract.goModify:
internal/trayctl/manager.go:100-112[ ] Step 1: Add
extractAllFromTarGzandextractAllFromZipfunctions toextract.go
Add below the existing functions in extract.go:
func extractAllFromTarGz(data []byte) (map[string][]byte, error) {
gz, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
return nil, err
}
defer gz.Close()
files := make(map[string][]byte)
tr := tar.NewReader(gz)
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
if hdr.Typeflag != tar.TypeReg {
continue
}
content, err := io.ReadAll(io.LimitReader(tr, maxDownloadBytes))
if err != nil {
return nil, err
}
files[filepath.Base(hdr.Name)] = content
}
return files, nil
}
func extractAllFromZip(data []byte) (map[string][]byte, error) {
r, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, err
}
files := make(map[string][]byte)
for _, f := range r.File {
if f.FileInfo().IsDir() {
continue
}
rc, err := f.Open()
if err != nil {
return nil, err
}
content, err := io.ReadAll(io.LimitReader(rc, maxDownloadBytes))
rc.Close()
if err != nil {
return nil, err
}
files[filepath.Base(f.Name)] = content
}
return files, nil
}
func extractAssets(data []byte, assetFileName string) (map[string][]byte, error) {
if strings.HasSuffix(assetFileName, ".zip") {
return extractAllFromZip(data)
}
return extractAllFromTarGz(data)
}- [ ] Step 2: Update imports in
extract.goto add"strings"
Replace the import block in extract.go with:
import (
"archive/tar"
"archive/zip"
"bytes"
"compress/gzip"
"fmt"
"io"
"path/filepath"
"strings"
)- [ ] Step 3: Update
Install()inmanager.goto useextractAssets
Replace lines 100-112 of manager.go with:
assets, err := extractAssets(archive, asset)
if err != nil {
return fmt.Errorf("could not extract assets: %w", err)
}
if err := os.MkdirAll(m.binDir(), 0755); err != nil {
return err
}
for name, content := range assets {
perm := os.FileMode(0644)
if name == binaryName() {
perm = 0755
}
if err := os.WriteFile(filepath.Join(m.binDir(), name), content, perm); err != nil {
return err
}
}- [ ] Step 4: Delete the now-dead
extractBinaryfunction frommanager.go
Remove lines 206-212 of manager.go (the old single-file extractor is replaced by extractAssets):
// DELETE this entire function:
func extractBinary(data []byte, assetFileName string) ([]byte, error) {
name := binaryName()
if strings.HasSuffix(assetFileName, ".zip") {
return extractFromZip(data, name)
}
return extractFromTarGz(data, name)
}- [ ] Step 5: Verify it compiles
Run: go build ./internal/trayctl/ Expected: clean build
- [ ] Step 6: Commit
git add internal/trayctl/extract.go internal/trayctl/manager.go
git commit -m "feat(tray): extract all archive assets (binary + icon) on install"Task 5: Windows shortcuts — CreateShortcuts / RemoveShortcuts
Files:
Create:
internal/trayctl/shortcut_windows.go[ ] Step 1: Create the Windows shortcut implementation
//go:build windows
package trayctl
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
func (m *Manager) CreateShortcuts() error {
target := m.BinaryPath()
workDir := m.binDir()
iconPath := filepath.Join(m.binDir(), "sap-devs-tray.ico")
startMenuDir := filepath.Join(os.Getenv("APPDATA"), "Microsoft", "Windows", "Start Menu", "Programs")
if err := createLnk(filepath.Join(startMenuDir, "SAP Devs Tray.lnk"), target, workDir, iconPath); err != nil {
return fmt.Errorf("start menu shortcut: %w", err)
}
desktopPath, err := resolveDesktopPath()
if err != nil {
return fmt.Errorf("resolve desktop path: %w", err)
}
if err := createLnk(filepath.Join(desktopPath, "SAP Devs Tray.lnk"), target, workDir, iconPath); err != nil {
return fmt.Errorf("desktop shortcut: %w", err)
}
return nil
}
func (m *Manager) RemoveShortcuts() error {
startMenuLink := filepath.Join(os.Getenv("APPDATA"), "Microsoft", "Windows", "Start Menu", "Programs", "SAP Devs Tray.lnk")
_ = os.Remove(startMenuLink)
desktopPath, _ := resolveDesktopPath()
if desktopPath != "" {
_ = os.Remove(filepath.Join(desktopPath, "SAP Devs Tray.lnk"))
}
_ = os.Remove(filepath.Join(m.binDir(), "sap-devs-tray.ico"))
return nil
}
func resolveDesktopPath() (string, error) {
cmd := exec.Command("powershell", "-NoProfile", "-Command",
"[Environment]::GetFolderPath('Desktop')")
out, err := cmd.Output()
if err != nil {
return "", err
}
path := strings.TrimSpace(string(out))
if path == "" {
return "", fmt.Errorf("could not resolve Desktop folder")
}
return path, nil
}
func createLnk(lnkPath, target, workDir, iconPath string) error {
script := fmt.Sprintf(`
$ws = New-Object -ComObject WScript.Shell
$s = $ws.CreateShortcut('%s')
$s.TargetPath = '%s'
$s.WorkingDirectory = '%s'
$s.IconLocation = '%s,0'
$s.Save()
`, lnkPath, target, workDir, iconPath)
cmd := exec.Command("powershell", "-NoProfile", "-Command", script)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("%s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}- [ ] Step 2: Add
"strings"to imports
Already present in the code above — verify the import block includes "strings".
- [ ] Step 3: Verify it compiles
Run: GOOS=windows go build ./internal/trayctl/ Expected: clean build
- [ ] Step 4: Commit
git add internal/trayctl/shortcut_windows.go
git commit -m "feat(tray): Windows .lnk shortcut creation via PowerShell COM"Task 6: macOS shortcuts — .app bundle
Files:
Create:
internal/trayctl/shortcut_darwin.go[ ] Step 1: Create the macOS shortcut implementation
//go:build darwin
package trayctl
import (
"fmt"
"os"
"path/filepath"
)
func (m *Manager) CreateShortcuts() error {
home, err := os.UserHomeDir()
if err != nil {
return err
}
appDir := filepath.Join(home, "Applications", "SAP Devs Tray.app", "Contents")
macosDir := filepath.Join(appDir, "MacOS")
resDir := filepath.Join(appDir, "Resources")
for _, d := range []string{macosDir, resDir} {
if err := os.MkdirAll(d, 0755); err != nil {
return err
}
}
symlinkPath := filepath.Join(macosDir, "sap-devs-tray")
_ = os.Remove(symlinkPath)
if err := os.Symlink(m.BinaryPath(), symlinkPath); err != nil {
return fmt.Errorf("symlink: %w", err)
}
icnsSource := filepath.Join(m.binDir(), "icon.icns")
icnsDest := filepath.Join(resDir, "AppIcon.icns")
if data, err := os.ReadFile(icnsSource); err == nil {
_ = os.WriteFile(icnsDest, data, 0644)
}
plist := `<?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>CFBundleName</key>
<string>SAP Devs Tray</string>
<key>CFBundleIdentifier</key>
<string>com.sap-devs.tray</string>
<key>CFBundleExecutable</key>
<string>sap-devs-tray</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>LSUIElement</key>
<true/>
<key>LSBackgroundOnly</key>
<false/>
</dict>
</plist>`
return os.WriteFile(filepath.Join(appDir, "Info.plist"), []byte(plist), 0644)
}
func (m *Manager) RemoveShortcuts() error {
home, _ := os.UserHomeDir()
if home == "" {
return nil
}
_ = os.RemoveAll(filepath.Join(home, "Applications", "SAP Devs Tray.app"))
_ = os.Remove(filepath.Join(m.binDir(), "icon.icns"))
return nil
}- [ ] Step 2: Verify it compiles
Run: GOOS=darwin go build ./internal/trayctl/ Expected: clean build
- [ ] Step 3: Commit
git add internal/trayctl/shortcut_darwin.go
git commit -m "feat(tray): macOS .app bundle creation in ~/Applications/"Task 7: Linux shortcuts — .desktop files
Files:
Create:
internal/trayctl/shortcut_linux.go[ ] Step 1: Create the Linux shortcut implementation
//go:build linux
package trayctl
import (
"fmt"
"os"
"path/filepath"
)
func (m *Manager) CreateShortcuts() error {
home, err := os.UserHomeDir()
if err != nil {
return err
}
iconPath := filepath.Join(m.binDir(), "sap-devs-tray.png")
entry := fmt.Sprintf(`[Desktop Entry]
Type=Application
Name=SAP Devs Tray
Comment=SAP developer tools system tray companion
Exec=%s
Icon=%s
Terminal=false
Categories=Development;
StartupNotify=false
`, m.BinaryPath(), iconPath)
appsDir := filepath.Join(home, ".local", "share", "applications")
if err := os.MkdirAll(appsDir, 0755); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(appsDir, "sap-devs-tray.desktop"), []byte(entry), 0644); err != nil {
return err
}
desktopDir := filepath.Join(home, "Desktop")
if info, err := os.Stat(desktopDir); err == nil && info.IsDir() {
desktopFile := filepath.Join(desktopDir, "sap-devs-tray.desktop")
if err := os.WriteFile(desktopFile, []byte(entry), 0755); err != nil {
return err
}
}
return nil
}
func (m *Manager) RemoveShortcuts() error {
home, _ := os.UserHomeDir()
if home == "" {
return nil
}
_ = os.Remove(filepath.Join(home, ".local", "share", "applications", "sap-devs-tray.desktop"))
_ = os.Remove(filepath.Join(home, "Desktop", "sap-devs-tray.desktop"))
_ = os.Remove(filepath.Join(m.binDir(), "sap-devs-tray.png"))
return nil
}- [ ] Step 2: Verify it compiles
Run: GOOS=linux go build ./internal/trayctl/ Expected: clean build
- [ ] Step 3: Commit
git add internal/trayctl/shortcut_linux.go
git commit -m "feat(tray): Linux .desktop file creation for app launchers"Task 8: Wire shortcuts into Install()
Files:
Modify:
internal/trayctl/manager.go(Install method)[ ] Step 1: Add
CreateShortcuts()call at the end ofInstall()inmanager.go
After the asset extraction loop (the new code from Task 4), add before the closing return nil:
if err := m.CreateShortcuts(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: could not create shortcuts: %v\n", err)
}
return nil- [ ] Step 2: Verify it compiles
Run: go build ./internal/trayctl/ Expected: clean build
- [ ] Step 3: Verify the full CLI builds
Run: go build ./... Expected: clean build
- [ ] Step 4: Commit
git add internal/trayctl/manager.go
git commit -m "feat(tray): wire CreateShortcuts into Install"Task 9: Icon assets
Files:
Create:
cmd/sap-devs-tray/assets/icon.png(placeholder — real asset needs design)Create:
cmd/sap-devs-tray/assets/icon.ico(placeholder)Create:
cmd/sap-devs-tray/assets/icon.icns(placeholder)[ ] Step 1: Create the assets directory
mkdir -p cmd/sap-devs-tray/assets- [ ] Step 2: Create placeholder icon files
For now, create minimal valid placeholder files. Real icons will be provided by design. A 1x1 PNG is sufficient to validate the pipeline:
# Create a minimal 1x1 transparent PNG (67 bytes)
printf '\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n\xb4\x00\x00\x00\x00IEND\xaeB`\x82' > cmd/sap-devs-tray/assets/icon.pngFor .ico and .icns, create empty files as placeholders (the pipeline will work, shortcuts will just show default icons):
touch cmd/sap-devs-tray/assets/icon.ico
touch cmd/sap-devs-tray/assets/icon.icns- [ ] Step 3: Add a README in the assets folder
Create cmd/sap-devs-tray/assets/README.md:
# Tray Icon Assets
- `icon.png` — 1024x1024 master PNG (source of truth)
- `icon.ico` — Windows ICO (multi-resolution: 16/32/48/256)
- `icon.icns` — macOS ICNS
Current files are placeholders. Replace with real assets before release.- [ ] Step 4: Commit
git add cmd/sap-devs-tray/assets/
git commit -m "chore(tray): add placeholder icon assets for release pipeline"Task 10: Release pipeline — Windows GUI subsystem + icon packaging
Files:
Modify:
.github/workflows/release-tray.yml[ ] Step 1: Add
-H windowsguito the Windows build step
In the Build step (line 73), change the go build command to conditionally include the ldflag:
Replace the entire Build step run: block with:
EXT=""
EXTRA_LDFLAGS=""
if [ "${{ matrix.goos }}" = "windows" ]; then
EXT=".exe"
EXTRA_LDFLAGS="-H windowsgui"
fi
go build -ldflags "-X main.version=${VERSION} ${EXTRA_LDFLAGS}" -o "sap-devs-tray${EXT}" .- [ ] Step 2: Add a step to copy platform-specific icon into build output
Add after the "Prepare tray build assets" step and before "Build":
- name: Copy platform icon
shell: bash
run: |
if [ "${{ matrix.goos }}" = "windows" ]; then
cp cmd/sap-devs-tray/assets/icon.ico cmd/sap-devs-tray/sap-devs-tray.ico
elif [ "${{ matrix.goos }}" = "darwin" ]; then
cp cmd/sap-devs-tray/assets/icon.icns cmd/sap-devs-tray/icon.icns
else
cp cmd/sap-devs-tray/assets/icon.png cmd/sap-devs-tray/sap-devs-tray.png
fi- [ ] Step 3: Update the Package step to include icon files
Replace the Package step run: block:
ASSET="sap-devs-tray_${VERSION}_${{ matrix.goos }}_${{ matrix.goarch }}"
if [ "${{ matrix.goos }}" = "windows" ]; then
7z a "${ASSET}.zip" ./cmd/sap-devs-tray/sap-devs-tray.exe ./cmd/sap-devs-tray/sap-devs-tray.ico
elif [ "${{ matrix.goos }}" = "darwin" ]; then
tar czf "${ASSET}.tar.gz" -C cmd/sap-devs-tray sap-devs-tray icon.icns
else
tar czf "${ASSET}.tar.gz" -C cmd/sap-devs-tray sap-devs-tray sap-devs-tray.png
fi- [ ] Step 4: Verify YAML is valid
Run: yq '.' .github/workflows/release-tray.yml > /dev/null Expected: no error
- [ ] Step 5: Commit
git add .github/workflows/release-tray.yml
git commit -m "ci(tray): add -H windowsgui for Windows, package icons in release archives"Task 11: Update Uninstall() to remove all bin dir assets
Files:
Modify:
internal/trayctl/manager.go(Uninstall method)[ ] Step 1: Change Uninstall to remove the entire binDir instead of just the binary
The current Uninstall() does os.Remove(m.BinaryPath()). Since the bin dir now contains binary + icon, and the tray owns that directory, remove the whole directory:
func (m *Manager) Uninstall() error {
_ = m.RemoveShortcuts()
_ = m.Stop()
return os.RemoveAll(m.binDir())
}- [ ] Step 2: Verify it compiles
Run: go build ./internal/trayctl/ Expected: clean build
- [ ] Step 3: Commit
git add internal/trayctl/manager.go
git commit -m "fix(tray): Uninstall removes entire bin dir (binary + icons)"Task 12: Final integration verification
- [ ] Step 1: Full build check
Run: go build ./... Expected: clean build of all packages
- [ ] Step 2: Vet check
Run: go vet ./... Expected: no issues
- [ ] Step 3: Cross-compilation check (Windows from any platform)
Run: GOOS=windows GOARCH=amd64 go build ./internal/trayctl/ Expected: clean build
- [ ] Step 4: Cross-compilation check (Darwin)
Run: GOOS=darwin GOARCH=arm64 go build ./internal/trayctl/ Expected: clean build
- [ ] Step 5: Cross-compilation check (Linux)
Run: GOOS=linux GOARCH=amd64 go build ./internal/trayctl/ Expected: clean build
- [ ] Step 6: Commit any remaining fixes
If any compilation issues were found and fixed, commit them.
Task 13: Documentation updates
Files:
Modify:
CLAUDE.md(Tray Companion section)[ ] Step 1: Update the Tray Companion section in CLAUDE.md
In the ### Tray Companion (Experimental) section, update to mention shortcut management:
Add after the sentence ending with Config key: config.Tray.Autostart.:
`shortcut_windows.go` / `shortcut_darwin.go` / `shortcut_linux.go` handle native app shortcuts (Windows `.lnk`, macOS `.app` bundle, Linux `.desktop` files) — created during install, removed during uninstall. The release pipeline ships platform-specific icons alongside the binary and sets the Windows PE subsystem to GUI (`-H windowsgui`) to prevent terminal window allocation.- [ ] Step 2: Commit
git add CLAUDE.md
git commit -m "docs: update CLAUDE.md with tray shortcut and headless launch details"