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
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/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 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<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.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/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(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/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