Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Step 2: nexusctl Skeleton — Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: A Rust CLI (nexusctl) that talks to the nexusd daemon over HTTP. Implements nexusctl status (queries /v1/health) and nexusctl version. Actionable error messages when the daemon is unreachable.

Architecture: New binary crate (nexusctl) in the existing workspace. Shared HTTP client logic lives in nexus-lib as a NexusClient type. The CLI is a thin layer — parses args with clap, delegates to nexus-lib, formats output. Config at $XDG_CONFIG_HOME/nexusctl/config.yaml.

Tech Stack (additions to existing):

  • reqwest 0.12 (blocking not needed — CLI is async with tokio) — HTTP client
  • clap 4.x with derive macros and subcommands — noun-verb grammar
  • serde_norway — YAML config deserialization (reuses existing dep in nexus-lib)

CLI Grammar:

nexusctl <command> [options]
nexusctl status          # query daemon health
nexusctl version         # print CLI and daemon version
nexusctl --help

Recommended alias: nxc (documented in help text, not implemented in code).


Task 1: Add NexusClient to nexus-lib

Files:

  • Create: nexus/nexus-lib/src/client.rs
  • Modify: nexus/nexus-lib/src/lib.rs
  • Modify: nexus/nexus-lib/Cargo.toml

Step 1: Add dependencies to nexus-lib

Add reqwest and tokio to nexus/nexus-lib/Cargo.toml:

# nexus/nexus-lib/Cargo.toml
[package]
name = "nexus-lib"
version = "0.1.0"
edition = "2021"

[dependencies]
serde = { version = "1", features = ["derive"] }
serde_norway = "0.9"
dirs = "6"
reqwest = { version = "0.12", features = ["json"] }

[dev-dependencies]
tokio = { version = "1", features = ["rt", "macros"] }

Step 2: Write the failing test

#![allow(unused)]
fn main() {
// nexus/nexus-lib/src/client.rs

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

    #[test]
    fn default_client_uses_default_addr() {
        let client = NexusClient::new("127.0.0.1:9600");
        assert_eq!(client.base_url(), "http://127.0.0.1:9600");
    }

    #[test]
    fn client_with_custom_addr() {
        let client = NexusClient::new("10.0.0.1:8080");
        assert_eq!(client.base_url(), "http://10.0.0.1:8080");
    }

    #[tokio::test]
    async fn health_returns_error_when_daemon_not_running() {
        // Use a port that nothing is listening on
        let client = NexusClient::new("127.0.0.1:19999");
        let result = client.health().await;
        assert!(result.is_err());
        match result.unwrap_err() {
            ClientError::Connect(_) => {} // expected
            other => panic!("expected Connect error, got: {other}"),
        }
    }
}
}

Step 3: Run tests to verify they fail

Run: mise run test:lib

Expected: FAIL — NexusClient type does not exist yet.

Step 4: Implement NexusClient

#![allow(unused)]
fn main() {
// nexus/nexus-lib/src/client.rs
use serde::Deserialize;

#[derive(Debug)]
pub enum ClientError {
    /// Cannot connect to the daemon (connection refused, timeout, DNS failure)
    Connect(String),
    /// Connected but got an unexpected response
    Api(String),
}

impl std::fmt::Display for ClientError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ClientError::Connect(e) => write!(f, "connection error: {e}"),
            ClientError::Api(e) => write!(f, "API error: {e}"),
        }
    }
}

impl std::error::Error for ClientError {}

impl ClientError {
    pub fn is_connect(&self) -> bool {
        matches!(self, ClientError::Connect(_))
    }
}

#[derive(Debug, Clone, Deserialize)]
pub struct HealthResponse {
    pub status: String,
}

pub struct NexusClient {
    base_url: String,
    http: reqwest::Client,
}

impl NexusClient {
    pub fn new(addr: &str) -> Self {
        NexusClient {
            base_url: format!("http://{addr}"),
            http: reqwest::Client::builder()
                .timeout(std::time::Duration::from_secs(5))
                .build()
                .expect("failed to build HTTP client"),
        }
    }

    pub fn base_url(&self) -> &str {
        &self.base_url
    }

    pub async fn health(&self) -> Result<HealthResponse, ClientError> {
        let url = format!("{}/v1/health", self.base_url);
        let resp = self.http.get(&url).send().await.map_err(|e| {
            if e.is_connect() || e.is_timeout() {
                ClientError::Connect(e.to_string())
            } else {
                ClientError::Api(e.to_string())
            }
        })?;

        let status = resp.status();
        if !status.is_success() {
            return Err(ClientError::Api(format!("unexpected status: {status}")));
        }

        resp.json::<HealthResponse>()
            .await
            .map_err(|e| ClientError::Api(e.to_string()))
    }
}

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

    #[test]
    fn default_client_uses_default_addr() {
        let client = NexusClient::new("127.0.0.1:9600");
        assert_eq!(client.base_url(), "http://127.0.0.1:9600");
    }

    #[test]
    fn client_with_custom_addr() {
        let client = NexusClient::new("10.0.0.1:8080");
        assert_eq!(client.base_url(), "http://10.0.0.1:8080");
    }

    #[tokio::test]
    async fn health_returns_error_when_daemon_not_running() {
        // Use a port that nothing is listening on
        let client = NexusClient::new("127.0.0.1:19999");
        let result = client.health().await;
        assert!(result.is_err());
        match result.unwrap_err() {
            ClientError::Connect(_) => {} // expected
            other => panic!("expected Connect error, got: {other}"),
        }
    }
}
}

Step 5: Export the client module from lib.rs

#![allow(unused)]
fn main() {
// nexus/nexus-lib/src/lib.rs
pub mod client;
pub mod config;
}

Step 6: Run tests to verify they pass

Run: mise run test:lib

Expected: All 3 tests PASS.

Step 7: Commit

git add nexus/nexus-lib/
git commit -m "feat(nexus-lib): add NexusClient HTTP client for daemon API"

Task 2: Create nexusctl Crate with Clap CLI

Files:

  • Create: nexus/nexusctl/Cargo.toml
  • Create: nexus/nexusctl/src/main.rs
  • Modify: nexus/Cargo.toml (add to workspace members)

Step 1: Add nexusctl to workspace members

# nexus/Cargo.toml
[workspace]
members = ["nexusd", "nexus-lib", "nexusctl"]
resolver = "2"

Step 2: Create directory structure

mkdir -p nexus/nexusctl/src

Step 3: Write nexusctl Cargo.toml

# nexus/nexusctl/Cargo.toml
[package]
name = "nexusctl"
version = "0.1.0"
edition = "2021"

[dependencies]
nexus-lib = { path = "../nexus-lib" }
clap = { version = "4", features = ["derive"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
serde = { version = "1", features = ["derive"] }
serde_norway = "0.9"
dirs = "6"

Step 4: Write the CLI skeleton

// nexus/nexusctl/src/main.rs
use clap::{Parser, Subcommand};
use nexus_lib::client::NexusClient;
use std::path::{Path, PathBuf};
use std::process::ExitCode;

mod config;

#[derive(Parser)]
#[command(
    name = "nexusctl",
    about = "WorkFort Nexus CLI (alias: nxc)",
    version
)]
struct Cli {
    /// Path to configuration file
    /// [default: $XDG_CONFIG_HOME/nexusctl/config.yaml]
    #[arg(long, global = true)]
    config: Option<PathBuf>,

    /// Daemon address (host:port)
    #[arg(long, global = true)]
    daemon: Option<String>,

    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Show daemon status
    Status,
    /// Print version information
    Version,
}

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

    let config_path = cli.config.unwrap_or_else(config::default_config_path);
    let cfg = config::load(&config_path);
    let daemon_addr = cli.daemon.unwrap_or(cfg.daemon);

    match cli.command {
        Commands::Status => cmd_status(&daemon_addr).await,
        Commands::Version => cmd_version(&daemon_addr).await,
    }
}

async fn cmd_status(daemon_addr: &str) -> ExitCode {
    let client = NexusClient::new(daemon_addr);
    match client.health().await {
        Ok(resp) => {
            println!("Daemon: {} ({})", resp.status, daemon_addr);
            ExitCode::SUCCESS
        }
        Err(e) if e.is_connect() => {
            eprintln!(
                "Error: cannot connect to Nexus daemon at {}\n  \
                 The daemon does not appear to be running.\n\n  \
                 Start it: systemctl --user start nexus.service",
                daemon_addr
            );
            ExitCode::FAILURE
        }
        Err(e) => {
            eprintln!("Error: {e}");
            ExitCode::FAILURE
        }
    }
}

async fn cmd_version(daemon_addr: &str) -> ExitCode {
    let version = env!("CARGO_PKG_VERSION");
    println!("nexusctl {version}");

    let client = NexusClient::new(daemon_addr);
    match client.health().await {
        Ok(_) => {
            println!("nexusd   reachable at {daemon_addr}");
        }
        Err(_) => {
            println!("nexusd   not reachable at {daemon_addr}");
        }
    }
    ExitCode::SUCCESS
}

Step 5: Verify build

Run: mise run build

Expected: Compiles with no errors (config module missing — we’ll add it next task, so this step will be done after Task 3).

Note: Task 2 and Task 3 must be completed together before the build will succeed. The commit happens at the end of Task 3.


Task 3: nexusctl Config Module

Files:

  • Create: nexus/nexusctl/src/config.rs

Step 1: Write the failing test

#![allow(unused)]
fn main() {
// nexus/nexusctl/src/config.rs

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

    #[test]
    fn default_config_has_correct_daemon_addr() {
        let cfg = CtlConfig::default();
        assert_eq!(cfg.daemon, "127.0.0.1:9600");
    }

    #[test]
    fn deserialize_config_with_daemon() {
        let yaml = r#"
daemon: "10.0.0.1:8080"
"#;
        let cfg: CtlConfig = serde_norway::from_str(yaml).unwrap();
        assert_eq!(cfg.daemon, "10.0.0.1:8080");
    }

    #[test]
    fn load_nonexistent_returns_default() {
        let cfg = load("/nonexistent/path/config.yaml");
        assert_eq!(cfg.daemon, "127.0.0.1:9600");
    }
}
}

Step 2: Run tests to verify they fail

Run: mise run test:nexusctl

Expected: FAIL — CtlConfig type does not exist yet.

Step 3: Implement the config module

#![allow(unused)]
fn main() {
// nexus/nexusctl/src/config.rs
use serde::Deserialize;
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct CtlConfig {
    /// Daemon address in host:port format
    pub daemon: String,
}

impl Default for CtlConfig {
    fn default() -> Self {
        CtlConfig {
            daemon: "127.0.0.1:9600".to_string(),
        }
    }
}

/// Returns the default config file path: $XDG_CONFIG_HOME/nexusctl/config.yaml
pub fn default_config_path() -> PathBuf {
    let config_dir = dirs::config_dir()
        .expect("cannot determine XDG_CONFIG_HOME")
        .join("nexusctl");
    config_dir.join("config.yaml")
}

/// Load config from the given path. Returns defaults if the file does not exist.
pub fn load(path: impl AsRef<Path>) -> CtlConfig {
    let path = path.as_ref();
    match std::fs::read_to_string(path) {
        Ok(content) => serde_norway::from_str(&content).unwrap_or_default(),
        Err(_) => CtlConfig::default(),
    }
}

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

    #[test]
    fn default_config_has_correct_daemon_addr() {
        let cfg = CtlConfig::default();
        assert_eq!(cfg.daemon, "127.0.0.1:9600");
    }

    #[test]
    fn deserialize_config_with_daemon() {
        let yaml = r#"
daemon: "10.0.0.1:8080"
"#;
        let cfg: CtlConfig = serde_norway::from_str(yaml).unwrap();
        assert_eq!(cfg.daemon, "10.0.0.1:8080");
    }

    #[test]
    fn load_nonexistent_returns_default() {
        let cfg = load("/nonexistent/path/config.yaml");
        assert_eq!(cfg.daemon, "127.0.0.1:9600");
    }
}
}

Step 4: Run tests to verify they pass

Run: mise run test:nexusctl

Expected: All 3 tests PASS.

Step 5: Verify full build

Run: mise run build

Expected: Compiles with no errors.

Step 6: Verify --help

Run: mise run run:nexusctl -- --help

Expected:

WorkFort Nexus CLI (alias: nxc)

Usage: nexusctl [OPTIONS] <COMMAND>

Commands:
  status   Show daemon status
  version  Print version information
  help     Print this message or the help of the given subcommand(s)

Options:
      --config <CONFIG>  Path to configuration file [default: $XDG_CONFIG_HOME/nexusctl/config.yaml]
      --daemon <DAEMON>  Daemon address (host:port)
  -h, --help             Print help
  -V, --version          Print version

Step 7: Commit

git add nexus/Cargo.toml nexus/nexusctl/
git commit -m "feat(nexusctl): add CLI skeleton with status and version commands"

Task 4: Actionable Error Messages

This task verifies the error output format matches the spec exactly.

Files:

  • Modify: nexus/nexusctl/src/main.rs (if needed — the error handling from Task 2 should already match)

Step 1: Manual verification — daemon NOT running

Make sure nexusd is not running on port 9600, then:

Run: mise run run:nexusctl -- status

Expected output (exact match):

Error: cannot connect to Nexus daemon at 127.0.0.1:9600
  The daemon does not appear to be running.

  Start it: systemctl --user start nexus.service

Expected exit code: non-zero (1).

Verify: echo $? should print 1.

Step 2: Manual verification — daemon running

Start the daemon in a separate terminal:

mise run run

Then run:

mise run run:nexusctl -- status

Expected output:

Daemon: ok (127.0.0.1:9600)

Expected exit code: 0.

Step 3: Verify nexusctl version output

Run: mise run run:nexusctl -- version

Expected (daemon not running):

nexusctl 0.1.0
nexusd   not reachable at 127.0.0.1:9600

Expected (daemon running):

nexusctl 0.1.0
nexusd   reachable at 127.0.0.1:9600

Step 4: Verify --daemon flag override

Run: mise run run:nexusctl -- --daemon 127.0.0.1:1234 status

Expected: Error message should reference port 1234:

Error: cannot connect to Nexus daemon at 127.0.0.1:1234
  The daemon does not appear to be running.

  Start it: systemctl --user start nexus.service

Step 5: If any output does not match, adjust the code in main.rs and repeat.

No commit needed if no changes were made. If adjustments were needed:

git add nexus/nexusctl/
git commit -m "fix(nexusctl): adjust error message formatting to match spec"

Task 5: mise Task for nexusctl Install

Files:

  • Modify: nexus/.mise.toml

Step 1: Update .mise.toml with nexusctl tasks

Add test:nexusctl and the nexusctl install tasks:

# Add to nexus/.mise.toml

[tasks."test:nexusctl"]
description = "Run tests for nexusctl"
run = "cargo test -p nexusctl"

[tasks."install:nexusctl:debug"]
description = "Install nexusctl debug build"
depends = ["build"]
run = """
mkdir -p ~/.local/bin
cp target/debug/nexusctl ~/.local/bin/
echo "Installed nexusctl (debug) to ~/.local/bin/"
"""

[tasks."install:nexusctl:release"]
description = "Install nexusctl release build"
depends = ["build:release"]
run = """
mkdir -p ~/.local/bin
cp target/release/nexusctl ~/.local/bin/
echo "Installed nexusctl (release) to ~/.local/bin/"
"""

Update the aggregate install tasks to include nexusctl:

[tasks."install:release"]
description = "Install all binaries (release) + systemd unit"
depends = ["install:nexusd:release", "install:nexusctl:release"]

[tasks."install:debug"]
description = "Install all binaries (debug) + systemd unit"
depends = ["install:nexusd:debug", "install:nexusctl:debug"]

Step 2: Verify

Run: mise run install:release

Expected: Both binaries copied to ~/.local/bin/, systemd unit installed.

Run: nexusctl --help

Expected: Help text prints (binary is on PATH via ~/.local/bin/).

Step 3: Commit

git add nexus/.mise.toml
git commit -m "feat(mise): add nexusctl to build and install tasks"

Task 6: Integration Test — nexusctl talks to nexusd

Files:

  • Create: nexus/nexusctl/tests/cli.rs
  • Modify: nexus/nexusctl/Cargo.toml (add dev-dependencies)

Step 1: Add dev-dependencies

Add to nexus/nexusctl/Cargo.toml:

[dev-dependencies]
nix = { version = "0.30", features = ["signal"] }
tokio = { version = "1", features = ["full"] }

Step 2: Write the integration test

#![allow(unused)]
fn main() {
// nexus/nexusctl/tests/cli.rs
use nix::sys::signal::{self, Signal};
use nix::unistd::Pid;
use std::process::{Command, Child};
use std::time::Duration;

/// Port used for nexusctl integration tests.
/// Different from nexusd's integration test (port 9600) to avoid conflicts
/// when running `cargo test --workspace`.
const TEST_PORT: &str = "9601";
const TEST_ADDR: &str = "127.0.0.1:9601";

fn target_dir() -> std::path::PathBuf {
    // The test binary is at target/debug/deps/cli-<hash>
    // Cross-package binaries are at target/debug/nexusd
    let mut path = std::env::current_exe().expect("cannot get test binary path");
    path.pop(); // remove cli-<hash>
    path.pop(); // remove deps
    path
}

fn start_daemon() -> Child {
    // nexusd is a cross-package binary, so env!("CARGO_BIN_EXE_nexusd") won't work.
    // Locate it relative to the test binary in target/debug/.
    let binary = target_dir().join("nexusd");
    let config_yaml = format!("api:\n  listen: \"{TEST_ADDR}\"");
    let config_path = std::env::temp_dir().join("nexusctl-test-config.yaml");
    std::fs::write(&config_path, config_yaml).expect("failed to write test config");

    Command::new(binary)
        .env("RUST_LOG", "info")
        .arg("--config")
        .arg(&config_path)
        .spawn()
        .expect("failed to start nexusd")
}

fn stop_daemon(child: &Child) {
    signal::kill(Pid::from_raw(child.id() as i32), Signal::SIGTERM)
        .expect("failed to send SIGTERM");
}

#[tokio::test]
async fn status_when_daemon_running() {
    let mut child = start_daemon();

    // Wait for daemon to be ready
    let client = reqwest::Client::new();
    let mut ready = false;
    for _ in 0..50 {
        tokio::time::sleep(Duration::from_millis(100)).await;
        if client.get(format!("http://{TEST_ADDR}/v1/health"))
            .send()
            .await
            .is_ok()
        {
            ready = true;
            break;
        }
    }
    assert!(ready, "daemon did not become ready within 5 seconds");

    // Run nexusctl status — use env!("CARGO_BIN_EXE_nexusctl") since it's the same package
    let output = Command::new(env!("CARGO_BIN_EXE_nexusctl"))
        .args(["--daemon", TEST_ADDR, "status"])
        .output()
        .expect("failed to run nexusctl");

    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(output.status.success(), "nexusctl status failed: {stdout}");
    assert!(stdout.contains("ok"), "expected 'ok' in output: {stdout}");
    assert!(stdout.contains(TEST_ADDR), "expected address in output: {stdout}");

    // Clean up
    stop_daemon(&child);
    child.wait().expect("failed to wait on daemon");
}

#[tokio::test]
async fn status_when_daemon_not_running() {
    // Use a port where nothing is listening
    let output = Command::new(env!("CARGO_BIN_EXE_nexusctl"))
        .args(["--daemon", "127.0.0.1:19998", "status"])
        .output()
        .expect("failed to run nexusctl");

    assert!(!output.status.success(), "expected non-zero exit code");

    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("cannot connect to Nexus daemon"),
        "expected connection error in stderr: {stderr}"
    );
    assert!(
        stderr.contains("19998"),
        "expected port in error message: {stderr}"
    );
    assert!(
        stderr.contains("systemctl --user start nexus.service"),
        "expected actionable hint in stderr: {stderr}"
    );
}

#[test]
fn version_prints_version() {
    let output = Command::new(env!("CARGO_BIN_EXE_nexusctl"))
        .args(["version"])
        .output()
        .expect("failed to run nexusctl");

    assert!(output.status.success(), "nexusctl version failed");

    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(
        stdout.contains("nexusctl 0.1.0"),
        "expected version string in output: {stdout}"
    );
}
}

Step 3: Build all binaries first, then run the integration test

Run: mise run build && mise run test:nexusctl

Expected: All 3 tests PASS.

Note: The status_when_daemon_running test uses port 9601 (not 9600) to avoid conflicts with nexusd’s own integration test when running cargo test --workspace. The target_dir() helper is only needed for the cross-package nexusd binary; nexusctl uses env!("CARGO_BIN_EXE_nexusctl") directly.

Step 5: Commit

git add nexus/nexusctl/
git commit -m "test(nexusctl): add integration tests for status and version commands"

Task 7: Add reqwest to nexusctl dev-dependencies (for integration test HTTP polling)

The integration test in Task 6 uses reqwest for polling the daemon’s readiness. This needs to be in nexusctl’s dev-dependencies.

Files:

  • Modify: nexus/nexusctl/Cargo.toml

Step 1: Update Cargo.toml

The final nexus/nexusctl/Cargo.toml should be:

# nexus/nexusctl/Cargo.toml
[package]
name = "nexusctl"
version = "0.1.0"
edition = "2021"

[dependencies]
nexus-lib = { path = "../nexus-lib" }
clap = { version = "4", features = ["derive"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
serde = { version = "1", features = ["derive"] }
serde_norway = "0.9"
dirs = "6"

[dev-dependencies]
nix = { version = "0.30", features = ["signal"] }
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json"] }

Note: This is merged into Task 6 during implementation — listed separately here for clarity. The Task 6 commit already includes this.


Task 8: Workspace-Wide Verification

Step 1: Full build

Run: mise run build

Expected: Compiles with no errors and no warnings.

Step 2: Full test suite

Run: mise run test

Expected: All tests pass — nexus-lib unit tests, nexusd unit tests, nexusd integration test, nexusctl unit tests, nexusctl integration test.

Step 3: Help text

Run: mise run run:nexusctl -- --help

Expected: Clean help output with status and version subcommands.

Run: mise run run:nexusctl -- status --help

Expected: Help for the status subcommand.

Step 4: End-to-end smoke test

Terminal 1:

mise run run

Terminal 2:

mise run run:nexusctl -- status
# Expected: Daemon: ok (127.0.0.1:9600)

mise run run:nexusctl -- version
# Expected: nexusctl 0.1.0
#           nexusd   reachable at 127.0.0.1:9600

Kill daemon (Ctrl-C in terminal 1), then:

mise run run:nexusctl -- status
# Expected: Error message with actionable hint

Step 5: Commit (if any final adjustments were needed)

git add nexus/
git commit -m "chore: final adjustments from step 2 verification"

Verification Checklist

After all tasks are complete, verify the following:

  • mise run build succeeds with no warnings
  • mise run test — all tests pass
  • nexusctl --help — prints usage with status and version commands
  • nexusctl status (daemon running) — prints Daemon: ok (127.0.0.1:9600)
  • nexusctl status (daemon stopped) — prints actionable error message with exact format from spec
  • nexusctl version — prints CLI version and daemon reachability
  • nexusctl --daemon 127.0.0.1:1234 status — uses overridden address in error message
  • Config file at $XDG_CONFIG_HOME/nexusctl/config.yaml — respected when present
  • Missing config file — defaults used silently (no error)
  • Exit code is 0 on success, 1 on failure
  • mise run install:nexusctl:release — installs nexusctl to ~/.local/bin/
  • mise run install:release — installs both nexusd and nexusctl to ~/.local/bin/