# ADR-004 Implementation Plan: Client Merge
Detailed step-by-step implementation guide for merging vibed client features into zinit-client.
---
## Phase 1: Modularize Local Client
**Goal**: Transform the monolithic 676-line `main.rs` into a modular structure matching the ADR target.
### 1.1 Create Directory Structure
```
zinit-client/src/
├── main.rs (slim dispatcher, ~50 lines)
├── cli/
│ ├── mod.rs (re-exports)
│ ├── args.rs (Clap structs from current main.rs lines 14-193)
│ └── commands.rs (all cmd_* functions, ~400 lines)
├── tui.rs (placeholder, will be filled in Phase 3)
├── repl.rs (placeholder, will be filled in Phase 4)
└── rhai/
├── mod.rs (placeholder)
├── engine.rs (placeholder)
└── service_builder.rs (placeholder)
```
### 1.2 Extract CLI Arguments (args.rs)
- [ ] Create `zinit-client/src/cli/args.rs`
- [ ] Move `Cli` struct (lines 14-24)
- [ ] Move `Commands` enum (lines 26-193)
- [ ] Add new command variants for future phases:
```rust
Tui,
Repl,
Run {
script: PathBuf,
},
#[command(name = "-c")]
Eval {
code: String,
},
```
### 1.3 Extract Command Handlers (commands.rs)
- [ ] Create `zinit-client/src/cli/commands.rs`
- [ ] Move all `cmd_*` functions (lines 296-675):
- `cmd_list`
- `cmd_status`
- `cmd_start` + `start_with_deps`
- `cmd_stop`
- `cmd_restart`
- `cmd_kill`
- `cmd_why`
- `cmd_tree`
- `cmd_remove`
- `cmd_reload`
- `cmd_logs`
- `cmd_ping`
- `cmd_shutdown`
- `cmd_poweroff`
- `cmd_reboot`
- `cmd_add_service`
- `cmd_debug_state`
- `cmd_debug_procs`
- [ ] Create public function signatures for each
### 1.4 Create CLI Module (mod.rs)
- [ ] Create `zinit-client/src/cli/mod.rs`
- [ ] Re-export args and commands:
```rust
pub mod args;
pub mod commands;
pub use args::{Cli, Commands};
```
### 1.5 Slim Down main.rs
- [ ] Keep only:
- Imports
- `main()` function with dispatch logic
- Socket connection setup
- [ ] Import from cli module
- [ ] Add stubs for tui/repl/rhai commands that print "not yet implemented"
### 1.6 Create Placeholder Modules
- [ ] Create empty `zinit-client/src/tui.rs` with `pub fn run() -> Result<(), String>`
- [ ] Create empty `zinit-client/src/repl.rs` with `pub fn run() -> Result<(), String>`
- [ ] Create `zinit-client/src/rhai/mod.rs` with placeholder exports
### 1.7 Verify Build
- [ ] Run `cargo check -p zinit-client`
- [ ] Run `cargo test -p zinit-client` (if any tests exist)
- [ ] Verify all existing commands still work
---
## Phase 2: Add Async Client to zinit-common
**Goal**: Create tokio-based async client alongside existing blocking client.
### 2.1 Add Feature Flag
- [ ] Edit `zinit-common/Cargo.toml`:
```toml
[features]
default = []
async = ["tokio"]
[dependencies]
tokio = { version = "1.45", features = ["net", "io-util", "sync"], optional = true }
```
### 2.2 Create Async Client Module
- [ ] Create `zinit-common/src/async_client.rs`
- [ ] Define `AsyncZinitClient` struct:
```rust
pub struct AsyncZinitClient {
transport: Transport,
}
enum Transport {
Unix(tokio::net::UnixStream),
Tcp(tokio::net::TcpStream),
}
```
### 2.3 Implement Connection Methods
- [ ] `pub async fn connect_unix(path: impl AsRef<Path>) -> Result<Self>`
- [ ] `pub async fn connect_tcp(addr: impl ToSocketAddrs) -> Result<Self>`
- [ ] `pub async fn connect(uri: &str) -> Result<Self>` - auto-detect from URI
### 2.4 Implement Core RPC Method
- [ ] `async fn call(&mut self, method: &str, params: Value) -> Result<Response>`
- [ ] Handle JSON-RPC framing (newline-delimited)
- [ ] Handle request ID generation
- [ ] Parse response and extract result/error
### 2.5 Mirror Blocking Client API
Port each method from `ZinitClient` to async version:
- [ ] `pub async fn ping(&mut self) -> Result<String>`
- [ ] `pub async fn list(&mut self) -> Result<Vec<ServiceInfo>>`
- [ ] `pub async fn status(&mut self, name: &str) -> Result<ServiceStatus>`
- [ ] `pub async fn start(&mut self, name: &str) -> Result<()>`
- [ ] `pub async fn stop(&mut self, name: &str) -> Result<()>`
- [ ] `pub async fn restart(&mut self, name: &str) -> Result<()>`
- [ ] `pub async fn kill(&mut self, name: &str, signal: Option<&str>) -> Result<()>`
- [ ] `pub async fn why(&mut self, name: &str) -> Result<WhyBlocked>`
- [ ] `pub async fn tree(&mut self) -> Result<String>`
- [ ] `pub async fn remove(&mut self, name: &str) -> Result<()>`
- [ ] `pub async fn reload(&mut self) -> Result<ReloadResult>`
- [ ] `pub async fn logs_tail(&mut self, name: &str, lines: Option<usize>) -> Result<Vec<LogEntry>>`
- [ ] `pub async fn add_service(&mut self, config: &ServiceConfig, persist: bool) -> Result<AddServiceResult>`
- [ ] `pub async fn shutdown(&mut self) -> Result<()>`
### 2.6 Add to Module Exports
- [ ] Update `zinit-common/src/lib.rs`:
```rust
#[cfg(feature = "async")]
pub mod async_client;
#[cfg(feature = "async")]
pub use async_client::AsyncZinitClient;
```
### 2.7 Write Tests
- [ ] Unit tests for request/response serialization
- [ ] Integration test with mock server (if feasible)
### 2.8 Update zinit-client Cargo.toml
- [ ] Add `zinit-common = { path = "../zinit-common", features = ["async"] }`
---
## Phase 3: Port TUI
**Goal**: Adapt vibed's 1006-line TUI to use local's richer API.
### 3.1 Add TUI Dependencies
- [ ] Add to `zinit-client/Cargo.toml`:
```toml
[dependencies]
ratatui = "0.29"
crossterm = "0.28"
```
### 3.2 Copy Base TUI Structure
- [ ] Copy `vibed/zinit_client/src/tui.rs` to `zinit-client/src/tui.rs`
- [ ] Update imports to use local types
### 3.3 Adapt ServiceInfo Struct
Replace vibed's simple struct with local's rich model:
- [ ] Use `zinit_common::ServiceState` (7-state enum) instead of string
- [ ] Add `dependencies: Vec<DependencyInfo>` field
- [ ] Add `failure_reason: Option<FailureReason>` field
- [ ] Add `uptime_secs: Option<u64>` field
- [ ] Add `is_target: bool` field
- [ ] Add `class: ServiceClass` field
### 3.4 Update State Symbols
- [ ] Map ServiceState to display symbols:
```rust
fn state_symbol(state: &ServiceState) -> &'static str {
match state {
ServiceState::Inactive => "[-]",
ServiceState::Blocked { .. } => "[?]",
ServiceState::Starting { .. } => "[>]",
ServiceState::Running { .. } => "[+]",
ServiceState::Stopping { .. } => "[.]",
ServiceState::Exited { .. } => "[.]",
ServiceState::Failed { .. } => "[X]",
}
}
```
### 3.5 Update Service List Display
- [ ] Show state symbol + name + state name
- [ ] Color code by state (green=running, red=failed, yellow=blocked, etc.)
- [ ] Show class indicator for system services `[S]`
- [ ] Show critical indicator `[!]`
### 3.6 Add Dependency Panel
- [ ] Create new UI panel showing dependencies for selected service
- [ ] Display each dependency with:
- Type (requires/wants/after/conflicts)
- Satisfied indicator (checkmark/X)
- Target service name
- Target state
- [ ] Use icons: `R:` requires, `W:` wants, `A:` after, `C:` conflicts
### 3.7 Add "Why Blocked?" Popup
- [ ] Add keybinding `w` to show why-blocked popup
- [ ] Call `client.why(name)` for selected service
- [ ] Display ASCII visualization from response
- [ ] Show waiting_on and conflicts_with lists
### 3.8 Add Failure Reason Display
- [ ] In service detail popup, show failure reason if Failed state
- [ ] Include exit code, signal, or error message
### 3.9 Update Process Tree Display
- [ ] Use `debug.process_tree` RPC for accurate tree
- [ ] Show PID, command, CPU%, memory for each process
### 3.10 Update Keyboard Shortcuts
Add new shortcuts for local-specific features:
- [ ] `w` - Why blocked?
- [ ] `t` - Show dependency tree
- [ ] `d` - Show debug state
- [ ] `p` - Show process tree
- [ ] Keep existing: `s`tart, `S`top, `r`estart, `k`ill, `D`elete, `q`uit, `?`help
### 3.11 Update Help Popup
- [ ] Add new keybindings to help text
- [ ] Document all available actions
### 3.12 Integrate with main.rs
- [ ] Import tui module
- [ ] Handle `Commands::Tui` in main dispatch
- [ ] Pass socket path to TUI
### 3.13 Test TUI
- [ ] Manual testing with running zinit-server
- [ ] Test all keybindings
- [ ] Test with blocked services to verify dependency display
- [ ] Test with failed services to verify failure reason display
---
## Phase 4: Port REPL
**Goal**: Adapt vibed's 750-line REPL for local's command set.
### 4.1 Add REPL Dependencies
- [ ] Add to `zinit-client/Cargo.toml`:
```toml
rustyline = { version = "15.0", features = ["derive"] }
colored = "3.0"
```
### 4.2 Copy Base REPL Structure
- [ ] Copy `vibed/zinit_client/src/repl.rs` to `zinit-client/src/repl.rs`
- [ ] Update imports
### 4.3 Update Function Registry
Update `ZINIT_FUNCTIONS` constant with local's additional functions:
- [ ] Add `zinit_why(name)` - show blocking reasons
- [ ] Add `zinit_tree()` - show dependency tree
- [ ] Add `zinit_debug_state()` - show graph state
- [ ] Add `zinit_debug_procs(name)` - show process tree
- [ ] Keep all existing vibed functions that map to local API
### 4.4 Update Builder Methods
Update `BUILDER_METHODS` for local's full dependency model:
- [ ] Add `.requires(service)` - hard dependency
- [ ] Add `.wants(service)` - soft dependency
- [ ] Add `.conflicts(service)` - mutual exclusion
- [ ] Add `.class(class)` - "user" or "system"
- [ ] Add `.critical(bool)` - halt boot on failure
### 4.5 Update Tab Completion
- [ ] Ensure new functions appear in completions
- [ ] Add completions for local CLI commands:
- `why`
- `tree`
- `debug-state`
- `debug-procs`
- [ ] Service name completion from `zinit_list()` results
### 4.6 Update Help System
- [ ] Update help text with new functions
- [ ] Add examples for new features:
```
zinit_why("my-service") # Show why service is blocked
zinit_tree() # Display dependency tree
new_service("app")
.exec("/usr/bin/app")
.requires("database") # Hard dependency
.wants("logger") # Soft dependency
.class("system") # Protected service
.critical(true) # Halt boot on failure
.register()
```
### 4.7 Syntax Highlighting Updates
- [ ] Add new function names to highlight rules
- [ ] Highlight dependency keywords differently if desired
### 4.8 Integrate with main.rs
- [ ] Import repl module
- [ ] Handle `Commands::Repl` in main dispatch
- [ ] Pass socket path to REPL
### 4.9 Test REPL
- [ ] Test tab completion
- [ ] Test all zinit_* functions
- [ ] Test service builder with new methods
- [ ] Test history persistence
---
## Phase 5: Port Rhai Engine
**Goal**: Adapt vibed's ~2100 lines of Rhai code for local's API.
### 5.1 Add Rhai Dependencies
- [ ] Add to `zinit-client/Cargo.toml`:
```toml
rhai = { version = "1.21", features = ["sync", "serde"] }
anyhow = "1.0"
lazy_static = "1.5"
```
### 5.2 Copy Engine Base
- [ ] Copy `vibed/zinit_client/src/rhai/engine.rs` to `zinit-client/src/rhai/engine.rs`
- [ ] Update imports for local types
### 5.3 Copy Service Builder
- [ ] Copy `vibed/zinit_client/src/rhai/service_builder.rs` to `zinit-client/src/rhai/service_builder.rs`
- [ ] Update to build `zinit_common::ServiceConfig`
### 5.4 Create Module File
- [ ] Create `zinit-client/src/rhai/mod.rs`:
```rust
pub mod engine;
pub mod service_builder;
pub use engine::{ZinitEngine, run_script, run_script_file};
pub use service_builder::ServiceBuilder;
```
### 5.5 Update Service Builder for Full Dependency Model
Add methods for local's 4 dependency types:
- [ ] `.requires(name: &str)` - add to requires list
- [ ] `.wants(name: &str)` - add to wants list
- [ ] `.conflicts(name: &str)` - add to conflicts list
- [ ] `.after(name: &str)` - already exists, verify works
- [ ] `.class(class: &str)` - set "user" or "system"
- [ ] `.critical(val: bool)` - set critical flag
### 5.6 Update ServiceBuilder.build()
- [ ] Return `zinit_common::ServiceConfig` instead of vibed type
- [ ] Map all fields correctly:
```rust
ServiceConfig {
service: ServiceDef {
name: self.name,
exec: self.exec,
dir: self.dir,
oneshot: self.oneshot,
env: self.env,
class: self.class,
critical: self.critical,
status: Default::default(),
},
dependencies: DependencyDef {
after: self.after,
requires: self.requires,
wants: self.wants,
conflicts: self.conflicts,
},
lifecycle: LifecycleDef { ... },
health: self.health,
logging: self.logging,
}
```
### 5.7 Register New Rhai Functions
Add local-specific functions to engine:
- [ ] `zinit_why(name: String) -> Map` - call why RPC, return structured data
- [ ] `zinit_tree() -> String` - call tree RPC, return ASCII
- [ ] `zinit_debug_state() -> String` - call debug.state RPC
- [ ] `zinit_debug_procs(name: String) -> String` - call debug.process_tree RPC
### 5.8 Update Existing Functions for Local API
Verify/update each function to use local's response types:
- [ ] `zinit_list()` - returns `Vec<ServiceInfo>` not `Vec<String>`
- [ ] `zinit_status(name)` - returns full `ServiceStatus` with deps
- [ ] `zinit_is_running(name)` - check against `ServiceState::Running`
### 5.9 Handle Herolib Integrations (Optional)
Decide on herolib support:
- [ ] Option A: Keep herolib integrations (adds dependency)
- Add `herolib-os`, `herolib-core`, `herolib-clients` to Cargo.toml
- Keep vibed's herolib registration code
- [ ] Option B: Make herolib optional via feature flag
```toml
[features]
herolib = ["herolib-os", "herolib-core", "herolib-clients"]
```
- [ ] Option C: Remove herolib for now, add later
- Comment out herolib registrations
- Document as future enhancement
### 5.10 Update Script Execution
- [ ] `run_script(code: &str)` - execute inline Rhai
- [ ] `run_script_file(path: &Path)` - execute from file
- [ ] Handle stdin input for `-i` flag
### 5.11 Integrate with main.rs
- [ ] Import rhai module
- [ ] Handle `Commands::Run { script }` - run file
- [ ] Handle `Commands::Eval { code }` - run inline
- [ ] Add stdin detection for piped scripts
### 5.12 Test Rhai Engine
- [ ] Test basic script execution
- [ ] Test service builder with all dependency types
- [ ] Test new functions (why, tree, debug_state, debug_procs)
- [ ] Test error handling and reporting
---
## Phase 6: Dependencies (Cargo.toml)
**Goal**: Add all required dependencies with proper feature flags.
### 6.1 Update zinit-client/Cargo.toml
```toml
[package]
name = "zinit-client"
version = "0.1.0"
edition = "2021"
[features]
default = ["tui", "repl", "rhai"]
tui = ["ratatui", "crossterm"]
repl = ["rustyline", "colored"]
rhai = ["dep:rhai", "anyhow", "lazy_static"]
herolib = ["herolib-os", "herolib-core", "herolib-clients"]
full = ["tui", "repl", "rhai", "herolib"]
[dependencies]
# Core
zinit-common = { path = "../zinit-common", features = ["async"] }
clap = { version = "4", features = ["derive"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.8"
# Async runtime
tokio = { version = "1.45", features = ["full"] }
# TUI (optional)
ratatui = { version = "0.29", optional = true }
crossterm = { version = "0.28", optional = true }
# REPL (optional)
rustyline = { version = "15.0", features = ["derive"], optional = true }
colored = { version = "3.0", optional = true }
# Scripting (optional)
rhai = { version = "1.21", features = ["sync", "serde"], optional = true }
anyhow = { version = "1.0", optional = true }
lazy_static = { version = "1.5", optional = true }
# Herolib (optional)
herolib-os = { version = "0.3.6", features = ["rhai"], optional = true }
herolib-core = { version = "0.3.6", optional = true }
herolib-clients = { version = "0.3.6", features = ["rhai"], optional = true }
# System
libc = "0.2"
```
### 6.2 Conditional Compilation in Code
- [ ] Wrap TUI code with `#[cfg(feature = "tui")]`
- [ ] Wrap REPL code with `#[cfg(feature = "repl")]`
- [ ] Wrap Rhai code with `#[cfg(feature = "rhai")]`
- [ ] Wrap herolib with `#[cfg(feature = "herolib")]`
### 6.3 Update CLI Args for Conditional Features
- [ ] Only show `tui` command when feature enabled
- [ ] Only show `repl` command when feature enabled
- [ ] Only show `run`/`eval` commands when rhai enabled
### 6.4 Verify Minimal Build
- [ ] `cargo build -p zinit-client --no-default-features` should work
- [ ] Only core CLI commands available
### 6.5 Verify Full Build
- [ ] `cargo build -p zinit-client --all-features`
- [ ] All commands available
---
## Phase 7: CLI Commands
**Goal**: Wire up all new commands in the CLI.
### 7.1 Update args.rs with Final Commands
```rust
#[derive(Subcommand)]
enum Commands {
// ... existing commands ...
/// Launch interactive TUI
#[cfg(feature = "tui")]
Tui,
/// Launch interactive REPL
#[cfg(feature = "repl")]
Repl,
/// Run a Rhai script file
#[cfg(feature = "rhai")]
Run {
/// Path to .rhai script
script: PathBuf,
},
/// Execute inline Rhai code
#[cfg(feature = "rhai")]
#[command(short_flag = 'c')]
Exec {
/// Rhai code to execute
code: String,
},
}
```
### 7.2 Update main.rs Dispatch
```rust
match cli.command {
// ... existing ...
#[cfg(feature = "tui")]
Commands::Tui => tui::run(&socket_path),
#[cfg(feature = "repl")]
Commands::Repl => repl::run(&socket_path),
#[cfg(feature = "rhai")]
Commands::Run { script } => rhai::run_script_file(&socket_path, &script),
#[cfg(feature = "rhai")]
Commands::Exec { code } => rhai::run_script(&socket_path, &code),
}
```
### 7.3 Handle Stdin Input
- [ ] Detect if stdin is a pipe: `!std::io::stdin().is_terminal()`
- [ ] If piped, read script from stdin
- [ ] Allow: `cat script.rhai | zinit` or `zinit < script.rhai`
### 7.4 Add Script File Detection
- [ ] If first positional arg ends in `.rhai`, treat as script
- [ ] Allow: `zinit myscript.rhai` as shorthand for `zinit run myscript.rhai`
### 7.5 Update Help Text
- [ ] Ensure `zinit --help` shows all available commands
- [ ] Add examples in command descriptions
### 7.6 Final Integration Testing
- [ ] Test: `zinit tui`
- [ ] Test: `zinit repl`
- [ ] Test: `zinit run script.rhai`
- [ ] Test: `zinit -c 'zinit_list()'`
- [ ] Test: `echo 'zinit_ping()' | zinit`
- [ ] Test all existing commands still work
- [ ] Test with `--no-default-features`
---
## Verification Checklist
### Functionality Preserved
- [ ] All 18 original CLI commands work
- [ ] Blocking client still works
- [ ] Socket auto-detection works
- [ ] Error messages are clear
### New Functionality Works
- [ ] TUI launches and shows services
- [ ] TUI shows dependency panel
- [ ] TUI "why blocked?" popup works
- [ ] REPL launches with prompt
- [ ] REPL tab completion works
- [ ] REPL history works
- [ ] Rhai scripts execute
- [ ] Service builder creates valid configs
- [ ] New Rhai functions (why, tree, debug) work
### Build Variants Work
- [ ] `cargo build -p zinit-client` (default features)
- [ ] `cargo build -p zinit-client --no-default-features`
- [ ] `cargo build -p zinit-client --all-features`
- [ ] `cargo build -p zinit-client --features tui`
- [ ] `cargo build -p zinit-client --features repl`
- [ ] `cargo build -p zinit-client --features rhai`
### Documentation Updated
- [ ] Update CLAUDE.md with new commands
- [ ] Update README if exists
- [ ] ADR-004 status changed to "Accepted" or "Implemented"
---
## Estimated Complexity
| 1. Modularize | 5 new, 1 edit | ~50 | Easy |
| 2. Async Client | 2 new, 1 edit | ~400 | Medium |
| 3. TUI | 1 new | ~1100 | Medium |
| 4. REPL | 1 new | ~800 | Medium |
| 5. Rhai | 3 new | ~2200 | Medium-Hard |
| 6. Dependencies | 1 edit | ~50 | Easy |
| 7. CLI Commands | 2 edit | ~100 | Easy |
**Total**: ~4700 new lines (porting ~5300 from vibed with adaptations)
---
## Notes
- Phases can be done incrementally with working builds between each
- Phase 2 (async client) is prerequisite for TUI's live updates
- Herolib integration can be deferred (Phase 5.9 Option C)
- Feature flags allow slim builds for resource-constrained environments