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
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 fmtbefore every commit. Nevercargo fmt. - Fix all
cargo clippywarnings before moving to the next task. - Run tests with
cargo nextest run, notcargo test. - TDD: write the failing test, run it, confirm it fails for the expected reason, then write the minimal implementation.
- No
mod.rsfor new modules —foo.rsmodule root plus afoo/directory for submodules. - All
#[cfg(test)] mod testsblocks go at the END of the source file. - Naming: short but descriptive.
errnote,resultnotr. Single chars only for loop indices (i,j,k) and counts (n). - Blank line after every statement by default; multi-line
letbindings 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;pubonly at a crate's API boundary.- The agent label is
se.aceofba.xy. The test label isse.aceofba.xy-test. - Exit codes:
0success,1operational error. Codes2and3are not used byxy 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 testsat end of file)
Interfaces:
-
Consumes: nothing.
-
Produces:
impl std::io::Write for RotatingLogWriter, withwrite(&mut self, buf: &[u8]) -> io::Result<usize>andflush(&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 duplicatedensure_dirs()call) - Test:
crates/xy/src/logging.rs
Interfaces:
- Consumes:
impl io::Write for RotatingLogWriterfrom 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.rsso 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(declaremod 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) -> boolfn 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,managerfrom 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 printrisk 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)fromcrate::service(Tasks 3 and 4). -
Produces:
pub enum ServiceCmd { Install { force: bool }, Uninstall, Start, Stop, Status }(inmain.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