Files
xy/docs/superpowers/plans/2026-07-31-xy-start-on-login.md
T
logaritmiskandClaude Opus 5 7bb80803fe fix(service): strip Disabled before load, verify it, and honour the plist
service-manager 0.11 writes Disabled: true into every plist carrying
KeepAlive, and our RestartPolicy::Always guarantees KeepAlive, so every
agent we installed was born disabled. launchctl load honours the key while
still exiting 0, so install reported success on an agent that would never
start — not then and not at the next login.

service::start now removes the Disabled key itself before loading, via a
pure enable_plist() that rewrites nothing when the key is absent and
preserves every other key, including the EnvironmentVariables PATH
snapshot. That makes start self-healing for plists left disabled by an
earlier build. The crate's own start() is still not used, since without
Disabled it degrades to launchctl start, which fails on an unloaded job.

Because launchctl load exits 0 on failure, start also checks a
post-condition: it asks launchd whether the job now exists and reports a
diagnostic if it does not. stop keeps no such check, since a benign unload
of an already-stopped job also prints a failure while exiting 0.

status now treats the plist on disk as the definition of installed, as the
spec says: a plist that exists but is not loaded reports stopped with its
program, PATH and snapshot date intact instead of collapsing to
not-installed with every field cleared. That is precisely the state the
Disabled bug left users in, so it is the state status most needs to
describe.

status also gains the log path the spec always listed, and the daemon's own
log is renamed xy.log -> daemon.log so a supervised server named xy cannot
share a file, and two rotation counters, with the daemon.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGntTHCW3sEPy1VBRopNNp
2026-08-01 00:27:34 +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/daemon.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<Mutex<RotatingLogWriter>>. No later task depends on this.

tracing-subscriber 0.3 implements MakeWriter for Mutex<W> where W: io::Write (fmt/writer.rs:808), so a plain Mutex<RotatingLogWriter> satisfies with_writer directly. No adapter type is needed.

Corrected 2026-08-01. This step originally specified Arc<Mutex<RotatingLogWriter>>, on the false premise that impl MakeWriter for Arc<W> would cover it. That impl requires &'a W: io::Write, and &Mutex<W> does not implement io::Write. The error was caught during implementation and cost one fix round; the shipped code uses a bare Mutex.

  • 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("daemon.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::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<Mutex<RotatingLogWriter>> {
    let writer = RotatingLogWriter::open(&log_dir.join("daemon.log"), LOG_FILE_MAX_BYTES, LOG_FILE_KEEP)?;

    Ok(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/daemon.log
./target/debug/xy daemon &
sleep 2
cat ~/.local/state/xy/logs/daemon.log
kill %1

Expected: daemon.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/daemon.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/daemon.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