Files
xy/docs/superpowers/plans/2026-07-31-xy-start-on-login.md
T
logaritmiskandClaude Opus 5 2ab74c992b docs(plan): drop the launchd integration test
Task 6 is documentation-only. A real launchd cycle test would either
hard-code the live label and risk clobbering a working installation, or
need a test-only --label flag; the manual acceptance section covers the
mechanism instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGntTHCW3sEPy1VBRopNNp
2026-07-31 22:48:12 +02:00

38 KiB

Start on Login (macOS) 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: Give xy a service subcommand that installs a user-level launchd agent so the daemon starts at login and restarts on crash.

Architecture: A new service.rs in the xy crate wraps the service-manager crate's LaunchdServiceManager::user() and returns plain data; a new cli/service.rs renders that data and maps it to exit codes. Separately, the daemon gains its own rotating log file so login-time failures are diagnosable. Nothing crosses the IPC boundary — xy service * talks to launchd directly, which is what makes it work when the daemon is dead.

Tech Stack: Rust 2024, service-manager 0.11, clap derive, tracing-subscriber, etcetera, existing xy_supervisor::logs::RotatingLogWriter.

Spec: docs/superpowers/specs/2026-07-31-xy-start-on-login-design.md

Global Constraints

  • Format with cargo +nightly fmt before every commit. Never cargo fmt.
  • Fix all cargo clippy warnings before moving to the next task.
  • Run tests with cargo nextest run, not cargo test.
  • TDD: write the failing test, run it, confirm it fails for the expected reason, then write the minimal implementation.
  • No mod.rs for new modules — foo.rs module root plus a foo/ directory for submodules.
  • All #[cfg(test)] mod tests blocks go at the END of the source file.
  • Naming: short but descriptive. err not e, result not r. Single chars only for loop indices (i, j, k) and counts (n).
  • Blank line after every statement by default; multi-line let bindings ALWAYS get a blank line after. Adjacent asserts stay grouped with no blanks between them.
  • No comments unless the why is non-obvious.
  • pub(crate) for internals; pub only at a crate's API boundary.
  • The agent label is se.aceofba.xy. The test label is se.aceofba.xy-test.
  • Exit codes: 0 success, 1 operational error. Codes 2 and 3 are not used by xy service.

File Structure

File Responsibility
Cargo.toml (workspace) Declare service-manager = "0.11" in [workspace.dependencies]
crates/xy/Cargo.toml Consume service-manager
crates/xy-supervisor/src/logs.rs Modify: add impl std::io::Write for RotatingLogWriter
crates/xy/src/logging.rs New: build the daemon's tee'd subscriber writer
crates/xy/src/service.rs New: AgentSpec, AgentStatus, launchd install/uninstall/start/stop/status. Returns data, never prints
crates/xy/src/cli/service.rs New: verb dispatch, human output, exit codes
crates/xy/src/cli/mod.rs Modify: declare mod service; and re-export
crates/xy/src/main.rs Modify: Service subcommand arm; reorder path resolution before logger init
crates/xy/src/daemon/mod.rs Modify: drop the now-duplicated ensure_dirs() call
README.md Modify: document the five verbs

No integration test is planned. Driving a real launchd cycle would either hard-code the live se.aceofba.xy label and risk clobbering a working installation, or require a test-only --label flag on the CLI. The load/unload mechanism is covered instead by the Manual acceptance section at the end of this plan, which is the only check that can actually prove RunAtLoad works.


Task 1: io::Write for RotatingLogWriter

RotatingLogWriter already tracks bytes written and rotates, but only exposes write_line(tag, line), which prefixes a tag the daemon's own log must not have. An io::Write impl makes the type usable as a tracing writer.

Files:

  • Modify: crates/xy-supervisor/src/logs.rs
  • Test: crates/xy-supervisor/src/logs.rs (existing #[cfg(test)] mod tests at end of file)

Interfaces:

  • Consumes: nothing.

  • Produces: impl std::io::Write for RotatingLogWriter, with write(&mut self, buf: &[u8]) -> io::Result<usize> and flush(&mut self) -> io::Result<()>. Task 2 depends on this.

  • Step 1: Write the failing tests

Append to the #[cfg(test)] mod tests block at the end of crates/xy-supervisor/src/logs.rs:

#[test]
fn write_trait_appends_bytes() {
    use std::io::Write;

    let tmp = tempfile::tempdir().unwrap();
    let base = tmp.path().join("daemon.log");

    let mut writer = RotatingLogWriter::open(&base, 1024, 3).unwrap();

    writer.write_all(b"hello\n").unwrap();
    writer.flush().unwrap();

    let contents = std::fs::read_to_string(&base).unwrap();
    assert_eq!(contents, "hello\n");
}

#[test]
fn write_trait_rotates_at_threshold() {
    use std::io::Write;

    let tmp = tempfile::tempdir().unwrap();
    let base = tmp.path().join("daemon.log");

    let mut writer = RotatingLogWriter::open(&base, 8, 3).unwrap();

    writer.write_all(b"0123456789").unwrap();
    writer.write_all(b"after\n").unwrap();
    writer.flush().unwrap();

    let rotated = tmp.path().join("daemon.log.1");
    assert!(rotated.exists());
    assert_eq!(std::fs::read_to_string(&rotated).unwrap(), "0123456789");
    assert_eq!(std::fs::read_to_string(&base).unwrap(), "after\n");
}
  • Step 2: Run tests to verify they fail

Run: cargo nextest run -p xy-supervisor -E 'test(/write_trait/)'

Expected: FAIL to compile, with no method named 'write_all' found for struct 'RotatingLogWriter'.

  • Step 3: Write the minimal implementation

Add to crates/xy-supervisor/src/logs.rs, after the existing impl RotatingLogWriter block. Note Write is already imported at the top of the file (use std::io::Write;).

impl std::io::Write for RotatingLogWriter {
    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
        self.file.write_all(buf)?;

        self.written += buf.len() as u64;

        if self.written >= self.max_bytes {
            self.rotate()?;
        }

        Ok(buf.len())
    }

    fn flush(&mut self) -> std::io::Result<()> {
        self.file.flush()
    }
}
  • Step 4: Run tests to verify they pass

Run: cargo nextest run -p xy-supervisor -E 'test(/write_trait/)'

Expected: PASS, 2 tests.

  • Step 5: Verify nothing else broke, format, lint
cargo nextest run -p xy-supervisor
cargo +nightly fmt
cargo clippy -p xy-supervisor

Expected: all tests pass, no clippy warnings.

  • Step 6: Commit
git add crates/xy-supervisor/src/logs.rs
git commit -m "feat(logs): impl io::Write for RotatingLogWriter"

Task 2: Daemon log file

The daemon currently logs only to stderr, which launchd discards. Give it log_dir/xy.log using the same rotation as per-server logs, and reorder main.rs so paths resolve before the logger is built.

Files:

  • Create: crates/xy/src/logging.rs
  • Modify: crates/xy/src/main.rs
  • Modify: crates/xy/src/daemon/mod.rs (remove the duplicated ensure_dirs() call)
  • Test: crates/xy/src/logging.rs

Interfaces:

  • Consumes: impl io::Write for RotatingLogWriter from Task 1.
  • Produces: pub(crate) fn daemon_writer(log_dir: &Path) -> std::io::Result<Arc<Mutex<RotatingLogWriter>>>. No later task depends on this.

tracing-subscriber 0.3 already implements MakeWriter for Mutex<W> where W: io::Write and for Arc<W>, so Arc<Mutex<RotatingLogWriter>> satisfies with_writer directly. No adapter type is needed.

  • Step 1: Write the failing test

Create crates/xy/src/logging.rs containing only the test module:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn daemon_writer_creates_and_appends_to_xy_log() {
        use std::io::Write;

        let tmp = tempfile::tempdir().unwrap();

        let writer = daemon_writer(tmp.path()).unwrap();

        writer.lock().unwrap().write_all(b"line\n").unwrap();

        let contents = std::fs::read_to_string(tmp.path().join("xy.log")).unwrap();
        assert_eq!(contents, "line\n");
    }
}

Declare the module in crates/xy/src/main.rs alongside the existing mod declarations:

mod logging;
  • Step 2: Run test to verify it fails

Run: cargo nextest run -p xy -E 'test(/daemon_writer/)'

Expected: FAIL to compile, with cannot find function 'daemon_writer' in this scope.

  • Step 3: Write the minimal implementation

Prepend to crates/xy/src/logging.rs, above the test module:

use std::path::Path;
use std::sync::{Arc, Mutex};
use xy_supervisor::logs::RotatingLogWriter;

const LOG_FILE_MAX_BYTES: u64 = 10 * 1024 * 1024;
const LOG_FILE_KEEP: usize = 5;

pub(crate) fn daemon_writer(log_dir: &Path) -> std::io::Result<Arc<Mutex<RotatingLogWriter>>> {
    let writer = RotatingLogWriter::open(&log_dir.join("xy.log"), LOG_FILE_MAX_BYTES, LOG_FILE_KEEP)?;

    Ok(Arc::new(Mutex::new(writer)))
}
  • Step 4: Run test to verify it passes

Run: cargo nextest run -p xy -E 'test(/daemon_writer/)'

Expected: PASS.

  • Step 5: Reorder main.rs so paths resolve before the logger

Replace the top of main in crates/xy/src/main.rs. The current body initialises tracing_subscriber first and resolves paths second; this inverts that order and tees the daemon's output to a file.

#[tokio::main]
async fn main() -> std::process::ExitCode {
    let cli = Cli::parse();

    let paths = match paths::Paths::resolve() {
        Ok(p) => p,
        Err(err) => {
            eprintln!("xy: failed to resolve XDG paths: {err}");
            return std::process::ExitCode::from(3);
        }
    };

    let filter = tracing_subscriber::EnvFilter::try_from_default_env()
        .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));

    if matches!(cli.cmd, Cmd::Daemon) {
        use tracing_subscriber::fmt::writer::MakeWriterExt;

        if let Err(err) = paths.ensure_dirs() {
            eprintln!("xy: failed to create state dirs: {err}");
            return std::process::ExitCode::from(3);
        }

        let file = match logging::daemon_writer(&paths.log_dir) {
            Ok(w) => w,
            Err(err) => {
                eprintln!("xy: failed to open daemon log: {err}");
                return std::process::ExitCode::from(3);
            }
        };

        tracing_subscriber::fmt()
            .with_env_filter(filter)
            .with_ansi(false)
            .with_writer(std::io::stderr.and(file))
            .init();
    } else {
        tracing_subscriber::fmt()
            .with_env_filter(filter)
            .with_writer(std::io::stderr)
            .init();
    }

    // ... existing `let result: anyhow::Result<i32> = match cli.cmd { ... }` unchanged
}

with_ansi(false) is set on the daemon path because the tee writes the same bytes to both sinks, and escape codes in a log file are noise.

  • Step 6: Remove the now-duplicated ensure_dirs() call

In crates/xy/src/daemon/mod.rs, delete the first line of run:

paths.ensure_dirs().context("create state dirs")?;

main now does this before the logger is built, which is the only ordering that lets the logger open a file inside log_dir.

  • Step 7: Verify the daemon actually writes its log
cargo build -p xy
rm -f ~/.local/state/xy/logs/xy.log
./target/debug/xy daemon &
sleep 2
cat ~/.local/state/xy/logs/xy.log
kill %1

Expected: xy.log exists and contains a daemon listening line.

  • Step 8: Run the full suite, format, lint
cargo nextest run
cargo +nightly fmt
cargo clippy --workspace

Expected: all tests pass, no clippy warnings.

  • Step 9: Commit
git add crates/xy/src/logging.rs crates/xy/src/main.rs crates/xy/src/daemon/mod.rs
git commit -m "feat(daemon): write rotating xy.log alongside stderr"

Task 3: AgentSpec and install/uninstall

The OS-facing core. AgentSpec is the single source of truth for what gets installed; it converts into service-manager's ServiceInstallCtx.

Files:

  • Modify: Cargo.toml (workspace)
  • Modify: crates/xy/Cargo.toml
  • Create: crates/xy/src/service.rs
  • Modify: crates/xy/src/main.rs (declare mod service;)
  • Test: crates/xy/src/service.rs

Interfaces:

  • Consumes: nothing from earlier tasks.
  • Produces, all pub(crate):
    • const DEFAULT_LABEL: &str = "se.aceofba.xy"
    • struct AgentSpec { label: String, program: PathBuf, args: Vec<String>, path_env: String, working_dir: PathBuf }
    • fn AgentSpec::for_current_exe() -> anyhow::Result<AgentSpec>
    • fn AgentSpec::install_ctx(&self) -> anyhow::Result<ServiceInstallCtx>
    • fn AgentSpec::plist_path(&self) -> anyhow::Result<PathBuf>
    • fn plist_path_for(label: &str) -> anyhow::Result<PathBuf>
    • fn is_build_tree_path(path: &Path) -> bool
    • fn ensure_supported() -> anyhow::Result<()>
    • fn install(spec: &AgentSpec) -> anyhow::Result<()>
    • fn uninstall(label: &str) -> anyhow::Result<()>

Task 4 adds status/start/stop to this same file; Task 5 consumes all of it.

  • Step 1: Add the dependency
cargo add --package xy service-manager@0.11

Then move the version to the workspace, matching the existing convention. In the root Cargo.toml under [workspace.dependencies] add:

service-manager = "0.11"

And in crates/xy/Cargo.toml under [dependencies] set:

service-manager = { workspace = true }
  • Step 2: Write the failing tests

Create crates/xy/src/service.rs containing only the test module:

#[cfg(test)]
mod tests {
    use super::*;

    fn sample_spec() -> AgentSpec {
        AgentSpec {
            label: "se.aceofba.xy-test".to_string(),
            program: PathBuf::from("/usr/local/bin/xy"),
            args: vec!["daemon".to_string()],
            path_env: "/usr/local/bin:/usr/bin".to_string(),
            working_dir: PathBuf::from("/Users/someone"),
        }
    }

    #[test]
    fn install_ctx_maps_every_field() {
        let ctx = sample_spec().install_ctx().unwrap();

        assert_eq!(ctx.label.to_qualified_name(), "se.aceofba.xy-test");
        assert_eq!(ctx.program, PathBuf::from("/usr/local/bin/xy"));
        assert_eq!(ctx.args, vec![std::ffi::OsString::from("daemon")]);
        assert_eq!(ctx.working_directory, Some(PathBuf::from("/Users/someone")));
        assert!(ctx.autostart);
        assert_eq!(
            ctx.environment,
            Some(vec![("PATH".to_string(), "/usr/local/bin:/usr/bin".to_string())])
        );
    }

    #[test]
    fn install_ctx_uses_always_restart_without_delay() {
        let ctx = sample_spec().install_ctx().unwrap();

        assert!(matches!(
            ctx.restart_policy,
            RestartPolicy::Always { delay_secs: None }
        ));
    }

    #[test]
    fn install_ctx_supplies_no_raw_contents() {
        let ctx = sample_spec().install_ctx().unwrap();

        assert!(ctx.contents.is_none());
        assert!(ctx.username.is_none());
    }

    #[test]
    fn build_tree_paths_are_detected() {
        assert!(is_build_tree_path(Path::new("/home/me/xy/target/debug/xy")));
        assert!(is_build_tree_path(Path::new("/home/me/xy/target/release/xy")));
    }

    #[test]
    fn installed_paths_are_not_build_tree_paths() {
        assert!(!is_build_tree_path(Path::new("/Users/me/.cargo/bin/xy")));
        assert!(!is_build_tree_path(Path::new("/usr/local/bin/xy")));
        assert!(!is_build_tree_path(Path::new("/opt/targeted/bin/xy")));
    }

    #[test]
    fn plist_path_sits_in_user_launch_agents() {
        let path = sample_spec().plist_path().unwrap();

        assert!(path.ends_with("Library/LaunchAgents/se.aceofba.xy-test.plist"));
    }

    #[test]
    #[cfg(target_os = "macos")]
    fn macos_is_supported() {
        assert!(ensure_supported().is_ok());
    }

    #[test]
    #[cfg(not(target_os = "macos"))]
    fn other_platforms_are_rejected() {
        let err = ensure_supported().unwrap_err().to_string();

        assert!(err.contains("macOS-only"));
    }
}

The last case in installed_paths_are_not_build_tree_paths is deliberate: a naive to_string_lossy().contains("target/") would match /opt/targeted/bin/xy. The implementation must compare whole path components.

Declare the module in crates/xy/src/main.rs:

mod service;
  • Step 3: Run tests to verify they fail

Run: cargo nextest run -p xy -E 'test(/service::/)'

Expected: FAIL to compile, with cannot find struct 'AgentSpec' in this scope.

  • Step 4: Write the minimal implementation

Prepend to crates/xy/src/service.rs, above the test module:

use anyhow::{Context, Result};
use service_manager::{
    LaunchdServiceManager, RestartPolicy, ServiceInstallCtx, ServiceLabel, ServiceManager,
    ServiceUninstallCtx,
};
use std::path::{Component, Path, PathBuf};

pub(crate) const DEFAULT_LABEL: &str = "se.aceofba.xy";

pub(crate) struct AgentSpec {
    pub label: String,
    pub program: PathBuf,
    pub args: Vec<String>,
    pub path_env: String,
    pub working_dir: PathBuf,
}

impl AgentSpec {
    pub fn for_current_exe() -> Result<Self> {
        let program = std::env::current_exe()
            .context("resolve current executable")?
            .canonicalize()
            .context("canonicalize current executable")?;

        let path_env = std::env::var("PATH").context("read PATH")?;

        let working_dir = etcetera::home_dir().context("locate home directory")?;

        Ok(Self {
            label: DEFAULT_LABEL.to_string(),
            program,
            args: vec!["daemon".to_string()],
            path_env,
            working_dir,
        })
    }

    pub fn install_ctx(&self) -> Result<ServiceInstallCtx> {
        let label: ServiceLabel = self.label.parse().context("parse service label")?;

        Ok(ServiceInstallCtx {
            label,
            program: self.program.clone(),
            args: self.args.iter().map(std::ffi::OsString::from).collect(),
            contents: None,
            username: None,
            working_directory: Some(self.working_dir.clone()),
            environment: Some(vec![("PATH".to_string(), self.path_env.clone())]),
            autostart: true,
            restart_policy: RestartPolicy::Always { delay_secs: None },
        })
    }

    pub fn plist_path(&self) -> Result<PathBuf> {
        plist_path_for(&self.label)
    }
}

pub(crate) fn plist_path_for(label: &str) -> Result<PathBuf> {
    let home = etcetera::home_dir().context("locate home directory")?;

    Ok(home
        .join("Library")
        .join("LaunchAgents")
        .join(format!("{label}.plist")))
}

pub(crate) fn is_build_tree_path(path: &Path) -> bool {
    let mut components = path.components().peekable();

    while let Some(component) = components.next() {
        if component != Component::Normal("target".as_ref()) {
            continue;
        }

        if matches!(
            components.peek(),
            Some(Component::Normal(next))
                if *next == std::ffi::OsStr::new("debug") || *next == std::ffi::OsStr::new("release")
        ) {
            return true;
        }
    }

    false
}

#[cfg(target_os = "macos")]
pub(crate) fn ensure_supported() -> Result<()> {
    Ok(())
}

#[cfg(not(target_os = "macos"))]
pub(crate) fn ensure_supported() -> Result<()> {
    anyhow::bail!("start-on-login is macOS-only for now")
}

fn manager() -> LaunchdServiceManager {
    LaunchdServiceManager::user()
}

pub(crate) fn install(spec: &AgentSpec) -> Result<()> {
    manager()
        .install(spec.install_ctx()?)
        .context("install launchd agent")
}

pub(crate) fn uninstall(label: &str) -> Result<()> {
    let label: ServiceLabel = label.parse().context("parse service label")?;

    manager()
        .uninstall(ServiceUninstallCtx { label })
        .context("uninstall launchd agent")
}
  • Step 5: Run tests to verify they pass

Run: cargo nextest run -p xy -E 'test(/service::/)'

Expected: PASS, 7 tests (six platform-independent, plus macos_is_supported).

  • Step 6: Format and lint
cargo +nightly fmt
cargo clippy -p xy

Expected: no warnings. dead_code warnings for install/uninstall are expected until Task 5 wires them; if clippy flags them, add #![allow(dead_code)] at the top of service.rs and REMOVE it in Task 5.

  • Step 7: Commit
git add Cargo.toml Cargo.lock crates/xy/Cargo.toml crates/xy/src/service.rs crates/xy/src/main.rs
git commit -m "feat(service): AgentSpec and launchd install/uninstall"

Task 4: Status, start, and stop

service-manager's stop() runs launchctl stop, which a KeepAlive: true agent survives, so start/stop are implemented directly against launchctl on the plist path. Status comes from the crate.

Files:

  • Modify: crates/xy/src/service.rs
  • Test: crates/xy/src/service.rs

Interfaces:

  • Consumes: AgentSpec, plist_path_for, manager from Task 3.

  • Produces, all pub(crate):

    • enum AgentState { NotInstalled, Stopped, Running }
    • struct AgentStatus { label: String, plist: PathBuf, state: AgentState, program: Option<PathBuf>, path_env: Option<String>, snapshotted: Option<SystemTime>, pid: Option<u32> }
    • fn status(label: &str, pidfile: &Path) -> anyhow::Result<AgentStatus>
    • fn start(label: &str) -> anyhow::Result<()>
    • fn stop(label: &str) -> anyhow::Result<()>
    • fn read_pid(pidfile: &Path) -> Option<u32>
  • Step 1: Write the failing tests

Add to the #[cfg(test)] mod tests block in crates/xy/src/service.rs:

#[test]
fn status_reports_not_installed_when_plist_is_absent() {
    let tmp = tempfile::tempdir().unwrap();

    let status = status("se.aceofba.xy-absent", &tmp.path().join("xy.pid")).unwrap();

    assert!(matches!(status.state, AgentState::NotInstalled));
    assert!(status.program.is_none());
    assert!(status.pid.is_none());
}

#[test]
fn read_pid_parses_a_pidfile() {
    let tmp = tempfile::tempdir().unwrap();
    let pidfile = tmp.path().join("xy.pid");

    std::fs::write(&pidfile, "4821\n").unwrap();

    assert_eq!(read_pid(&pidfile), Some(4821));
}

#[test]
fn read_pid_returns_none_for_missing_or_garbage() {
    let tmp = tempfile::tempdir().unwrap();
    let missing = tmp.path().join("nope.pid");
    let garbage = tmp.path().join("garbage.pid");

    std::fs::write(&garbage, "not-a-pid").unwrap();

    assert_eq!(read_pid(&missing), None);
    assert_eq!(read_pid(&garbage), None);
}

status_reports_not_installed_when_plist_is_absent relies on the plist file being the authority, so it does not shell out to launchctl and is safe in CI.

  • Step 2: Run tests to verify they fail

Run: cargo nextest run -p xy -E 'test(/service::/)'

Expected: FAIL to compile, with cannot find function 'status' in this scope.

  • Step 3: Write the minimal implementation

Add to crates/xy/src/service.rs, above the test module. Extend the existing use service_manager::{...} line to also import ServiceStatus and ServiceStatusCtx.

use std::time::SystemTime;

#[derive(Debug, PartialEq, Eq)]
pub(crate) enum AgentState {
    NotInstalled,
    Stopped,
    Running,
}

pub(crate) struct AgentStatus {
    pub label: String,
    pub plist: PathBuf,
    pub state: AgentState,
    pub program: Option<PathBuf>,
    pub path_env: Option<String>,
    pub snapshotted: Option<SystemTime>,
    pub pid: Option<u32>,
}

pub(crate) fn read_pid(pidfile: &Path) -> Option<u32> {
    std::fs::read_to_string(pidfile)
        .ok()?
        .trim()
        .parse::<u32>()
        .ok()
}

pub(crate) fn status(label: &str, pidfile: &Path) -> Result<AgentStatus> {
    let plist = plist_path_for(label)?;

    if !plist.exists() {
        return Ok(AgentStatus {
            label: label.to_string(),
            plist,
            state: AgentState::NotInstalled,
            program: None,
            path_env: None,
            snapshotted: None,
            pid: None,
        });
    }

    let snapshotted = std::fs::metadata(&plist).and_then(|meta| meta.modified()).ok();

    let parsed: ServiceLabel = label.parse().context("parse service label")?;

    let state = match manager().status(ServiceStatusCtx { label: parsed })? {
        ServiceStatus::Running => AgentState::Running,
        ServiceStatus::Stopped(_) => AgentState::Stopped,
        ServiceStatus::NotInstalled => AgentState::NotInstalled,
    };

    let pid = if state == AgentState::Running {
        read_pid(pidfile)
    } else {
        None
    };

    let (program, path_env) = read_plist_fields(&plist);

    Ok(AgentStatus {
        label: label.to_string(),
        plist,
        state,
        program,
        path_env,
        snapshotted,
        pid,
    })
}

fn read_plist_fields(plist: &Path) -> (Option<PathBuf>, Option<String>) {
    let Ok(contents) = std::fs::read_to_string(plist) else {
        return (None, None);
    };

    let program = contents
        .split("<key>ProgramArguments</key>")
        .nth(1)
        .and_then(|rest| rest.split("<string>").nth(1))
        .and_then(|rest| rest.split("</string>").next())
        .map(PathBuf::from);

    let path_env = contents
        .split("<key>PATH</key>")
        .nth(1)
        .and_then(|rest| rest.split("<string>").nth(1))
        .and_then(|rest| rest.split("</string>").next())
        .map(str::to_string);

    (program, path_env)
}

fn launchctl(verb: &str, plist: &Path) -> Result<()> {
    let output = std::process::Command::new("launchctl")
        .arg(verb)
        .arg(plist)
        .output()
        .with_context(|| format!("run launchctl {verb}"))?;

    if !output.status.success() {
        anyhow::bail!(
            "launchctl {verb} failed: {}",
            String::from_utf8_lossy(&output.stderr).trim()
        );
    }

    Ok(())
}

pub(crate) fn start(label: &str) -> Result<()> {
    launchctl("load", &plist_path_for(label)?)
}

pub(crate) fn stop(label: &str) -> Result<()> {
    launchctl("unload", &plist_path_for(label)?)
}

read_plist_fields does string slicing rather than pulling in the plist crate as a direct dependency; it reads two well-known keys from a file this program wrote, and returns None rather than failing when the shape is unexpected.

  • Step 4: Run tests to verify they pass

Run: cargo nextest run -p xy -E 'test(/service::/)'

Expected: PASS, 9 tests.

  • Step 5: Verify the launchctl print risk empirically

The spec flags this: service-manager's status() calls launchctl print <bare-label>, but user agents normally need gui/$UID/<label>. Confirm the crate's two-pass fallback actually works before building the CLI on it.

launchctl print "gui/$(id -u)/com.apple.Finder" | head -5
launchctl print com.apple.Finder; echo "exit=$?"

Expected: the first prints a state = running block. The second is expected to fail; note its exit code and whether stderr names a fully-qualified label.

If the second command exits 64 AND its output mentions a qualified label, the crate's fallback works — proceed. If not, replace the manager().status(...) call in status() with a direct launchctl print gui/<uid>/<label> invocation, parsing state = running, and record the deviation in the commit message.

  • Step 6: Format and lint
cargo +nightly fmt
cargo clippy -p xy
  • Step 7: Commit
git add crates/xy/src/service.rs
git commit -m "feat(service): agent status plus launchctl load/unload"

Task 5: CLI verbs

Wire the five verbs into clap and render AgentStatus for humans.

Files:

  • Create: crates/xy/src/cli/service.rs
  • Modify: crates/xy/src/cli/mod.rs
  • Modify: crates/xy/src/main.rs
  • Test: crates/xy/src/cli/service.rs

Interfaces:

  • Consumes: everything pub(crate) from crate::service (Tasks 3 and 4).

  • Produces:

    • pub enum ServiceCmd { Install { force: bool }, Uninstall, Start, Stop, Status } (in main.rs)
    • pub async fn run(paths: Paths, cmd: ServiceCmd) -> anyhow::Result<i32>
    • fn render_status(status: &AgentStatus) -> String
  • Step 1: Write the failing tests

Create crates/xy/src/cli/service.rs containing only the test module:

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::PathBuf;

    fn running_status() -> AgentStatus {
        AgentStatus {
            label: "se.aceofba.xy".to_string(),
            plist: PathBuf::from("/Users/me/Library/LaunchAgents/se.aceofba.xy.plist"),
            state: AgentState::Running,
            program: Some(PathBuf::from("/Users/me/.cargo/bin/xy")),
            path_env: Some("/opt/homebrew/bin:/usr/bin".to_string()),
            snapshotted: None,
            pid: Some(4821),
        }
    }

    #[test]
    fn render_status_shows_running_state_with_pid() {
        let out = render_status(&running_status());

        assert!(out.contains("agent:   se.aceofba.xy (user)"));
        assert!(out.contains("state:   running (pid 4821)"));
        assert!(out.contains("program: /Users/me/.cargo/bin/xy"));
        assert!(out.contains("path:    /opt/homebrew/bin:/usr/bin"));
    }

    #[test]
    fn render_status_omits_pid_when_stopped() {
        let mut status = running_status();
        status.state = AgentState::Stopped;
        status.pid = None;

        let out = render_status(&status);

        assert!(out.contains("state:   stopped"));
        assert!(!out.contains("pid"));
    }

    #[test]
    fn render_status_annotates_path_with_snapshot_date() {
        let mut status = running_status();
        status.snapshotted = Some(std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_753_920_000));

        let out = render_status(&status);

        assert!(out.contains("path:    /opt/homebrew/bin:/usr/bin (snapshotted 2025-07-31)"));
    }

    #[test]
    fn render_status_reports_not_installed_without_program_lines() {
        let mut status = running_status();
        status.state = AgentState::NotInstalled;
        status.program = None;
        status.path_env = None;
        status.pid = None;

        let out = render_status(&status);

        assert!(out.contains("state:   not installed"));
        assert!(!out.contains("program:"));
        assert!(!out.contains("path:"));
    }
}
  • Step 2: Run tests to verify they fail

Run: cargo nextest run -p xy -E 'test(/cli::service/)'

Expected: FAIL to compile, with cannot find function 'render_status' in this scope.

  • Step 3: Write the minimal implementation

Prepend to crates/xy/src/cli/service.rs:

use crate::paths::Paths;
use crate::service::{self, AgentSpec, AgentState, AgentStatus, DEFAULT_LABEL};
use anyhow::Result;

pub(crate) fn render_status(status: &AgentStatus) -> String {
    let mut out = String::new();

    out.push_str(&format!("  agent:   {} (user)\n", status.label));
    out.push_str(&format!("  plist:   {}\n", status.plist.display()));

    let state = match (&status.state, status.pid) {
        (AgentState::Running, Some(pid)) => format!("running (pid {pid})"),
        (AgentState::Running, None) => "running".to_string(),
        (AgentState::Stopped, _) => "stopped".to_string(),
        (AgentState::NotInstalled, _) => "not installed".to_string(),
    };

    out.push_str(&format!("  state:   {state}\n"));

    if let Some(program) = &status.program {
        out.push_str(&format!("  program: {}\n", program.display()));
    }

    if let Some(path_env) = &status.path_env {
        match status.snapshotted.and_then(snapshot_date) {
            Some(date) => out.push_str(&format!("  path:    {path_env} (snapshotted {date})\n")),
            None => out.push_str(&format!("  path:    {path_env}\n")),
        }
    }

    out
}

fn snapshot_date(at: std::time::SystemTime) -> Option<String> {
    let stamp = humantime::format_rfc3339_seconds(at).to_string();

    stamp.split('T').next().map(str::to_string)
}

pub async fn run(paths: Paths, cmd: crate::ServiceCmd) -> Result<i32> {
    if let Err(err) = service::ensure_supported() {
        eprintln!("xy: {err}");

        return Ok(1);
    }

    match cmd {
        crate::ServiceCmd::Install { force } => install(force),
        crate::ServiceCmd::Uninstall => uninstall(),
        crate::ServiceCmd::Start => toggle(service::start, "loaded"),
        crate::ServiceCmd::Stop => toggle(service::stop, "unloaded"),
        crate::ServiceCmd::Status => status(&paths),
    }
}

fn install(force: bool) -> Result<i32> {
    let spec = AgentSpec::for_current_exe()?;

    let plist = spec.plist_path()?;

    if plist.exists() {
        if !force {
            eprintln!("xy: agent already installed at {}", plist.display());
            eprintln!("xy: pass --force to replace it");

            return Ok(1);
        }

        service::uninstall(&spec.label)?;
    }

    if service::is_build_tree_path(&spec.program) {
        eprintln!(
            "xy: warning: pointing the agent at a build-tree binary\n         {}\n         it will disappear on `cargo clean`",
            spec.program.display()
        );
    }

    service::install(&spec)?;
    service::start(&spec.label)?;

    println!("wrote {}", plist.display());
    println!("loaded {}", spec.label);

    Ok(0)
}

fn uninstall() -> Result<i32> {
    let plist = service::plist_path_for(DEFAULT_LABEL)?;

    if !plist.exists() {
        println!("not installed");

        return Ok(0);
    }

    service::uninstall(DEFAULT_LABEL)?;

    println!("removed {}", plist.display());

    Ok(0)
}

fn toggle(action: fn(&str) -> Result<()>, verb: &str) -> Result<i32> {
    let plist = service::plist_path_for(DEFAULT_LABEL)?;

    if !plist.exists() {
        eprintln!("xy: agent is not installed");

        return Ok(1);
    }

    match action(DEFAULT_LABEL) {
        Ok(()) => {
            println!("{verb} {DEFAULT_LABEL}");

            Ok(0)
        }
        Err(err) => {
            eprintln!("xy: {err:#}");

            Ok(1)
        }
    }
}

fn status(paths: &Paths) -> Result<i32> {
    let status = service::status(DEFAULT_LABEL, &paths.pidfile)?;

    print!("{}", render_status(&status));

    Ok(0)
}

toggle takes the action as a function pointer rather than a pre-computed Result, so a missing plist short-circuits before launchctl is ever invoked. ensure_supported() is checked once at the top of run, giving every verb the same clear message on non-macOS rather than a confusing launchctl failure.

  • Step 4: Declare the module

In crates/xy/src/cli/mod.rs, alongside the existing mod format;:

pub mod service;
  • Step 5: Add the subcommand

In crates/xy/src/main.rs, add to enum Cmd:

    /// Manage the launchd start-on-login agent (macOS).
    Service {
        #[command(subcommand)]
        verb: ServiceCmd,
    },

And add the new enum next to Cmd:

#[derive(Debug, Subcommand)]
pub enum ServiceCmd {
    /// Install the login agent and load it.
    Install {
        #[arg(long)]
        force: bool,
    },
    /// Remove the login agent.
    Uninstall,
    /// Load the installed agent.
    Start,
    /// Unload the agent until the next login.
    Stop,
    /// Show the agent's state.
    Status,
}

And add the dispatch arm to the match cli.cmd block:

        Cmd::Service { verb } => cli::service::run(paths, verb).await,
  • Step 6: Run tests to verify they pass

Run: cargo nextest run -p xy -E 'test(/cli::service/)'

Expected: PASS, 4 tests.

  • Step 7: Remove the Task 3 allow, if it was added

If #![allow(dead_code)] was added to service.rs in Task 3, delete it now and confirm cargo clippy -p xy is clean.

  • Step 8: Check the help output renders
cargo run -p xy -- service --help

Expected: the five verbs are listed with their doc-comment descriptions.

  • Step 9: Full suite, format, lint
cargo nextest run
cargo +nightly fmt
cargo clippy --workspace
  • Step 10: Commit
git add crates/xy/src/cli/service.rs crates/xy/src/cli/mod.rs crates/xy/src/main.rs
git commit -m "feat(cli): xy service install/uninstall/start/stop/status"

Task 6: Documentation

Document the five verbs in the README. No integration test: driving a real launchd cycle would either hard-code the live se.aceofba.xy label and risk clobbering a working installation, or require a test-only --label flag on the CLI. The Manual acceptance section below covers the mechanism instead.

Files:

  • Modify: README.md

Interfaces:

  • Consumes: nothing from earlier tasks.

  • Produces: nothing consumed by later tasks.

  • Step 1: Document the feature

Add to README.md, after the existing command list:

## Start on login (macOS)

    xy service install     # write the LaunchAgent, load it, start the daemon
    xy service uninstall   # unload and remove the agent
    xy service start       # load an installed agent
    xy service stop        # unload until the next login
    xy service status      # show agent state

`xy service install` snapshots the current `PATH` into the agent, because a
launchd agent otherwise inherits only `/usr/bin:/bin:/usr/sbin:/sbin` and
supervised servers would not find their toolchains. Re-run with `--force`
after installing a new toolchain to refresh the snapshot.

`xy service stop` lasts until the next login. To disable start-on-login
permanently, use `xy service uninstall`.

The daemon writes to `$XDG_STATE_HOME/xy/logs/xy.log`. Failures that happen
before the daemon starts logging — a missing binary, a malformed plist — are
visible only to launchd:

    launchctl print gui/$UID/se.aceofba.xy
  • Step 2: Full suite, format, lint
cargo nextest run
cargo +nightly fmt
cargo clippy --workspace
  • Step 3: Commit
git add README.md
git commit -m "docs(readme): document xy service verbs"

Manual acceptance

After Task 6, verify the feature end to end against the real agent:

cargo install --path crates/xy
xy service install
xy service status          # expect: state: running (pid N)
xy list                    # expect: configured servers, reached via the daemon
tail ~/.local/state/xy/logs/xy.log

Then log out and back in, and confirm xy service status still reports running with a different pid. That last step is the only real proof that start-on-login works, because it is the only one that exercises RunAtLoad.

Finally, confirm Disabled is absent from the installed plist — its presence would mean the agent will not start at next login:

grep -c Disabled ~/Library/LaunchAgents/se.aceofba.xy.plist   # expect: 0