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):
reqwest0.12 (blocking not needed — CLI is async with tokio) — HTTP clientclap4.x with derive macros and subcommands — noun-verb grammarserde_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 buildsucceeds with no warnings -
mise run test— all tests pass -
nexusctl --help— prints usage with status and version commands -
nexusctl status(daemon running) — printsDaemon: 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/