Skip to content

Commit 32492b4

Browse files
authored
feat: Add CLI argument parsing for agent selection and initial messages (#44)
## Summary This PR adds command-line argument parsing to nori-cli, allowing users to specify the agent and initial message from the command line. ### Changes - **Add clap dependency**: Added clap v4 with derive feature for CLI argument parsing - **New CLI module**: Created `src/cli.rs` with `Cli` struct and agent name mapping functions - **Main function updates**: Modified `main.rs` to parse CLI args, validate agent names, and read from stdin when piped - **Run app updates**: Updated `run_app` to accept optional `agent_index` and `initial_message` parameters - **Comprehensive tests**: Added `tests/cli_args_test.rs` with tests for all CLI parsing scenarios - **Documentation updates**: Updated docs in both `src/docs.md` and `tests/docs.md` ### Usage Examples ```bash # Use specific agent nori-cli --agent claude # Use specific agent with initial message nori-cli --agent codex "Hello world" # Pipe input as initial message echo "Hello from stdin" | nori-cli # Short flag for agent nori-cli -a mock "Test message" ``` ### Features - Agent selection via `--agent` or `-a` flag (claude, codex, claudecode, mock) - Initial message as positional argument or via stdin piping - Case-insensitive agent name matching - Input validation with helpful error messages - CLI arguments take precedence over stdin when both provided
1 parent c4cf25d commit 32492b4

8 files changed

Lines changed: 205 additions & 5 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ async-stream = "0.3"
2626
which = "7.0"
2727
opener = "0.7"
2828
unicode-width = "0.2"
29+
clap = { version = "4", features = ["derive"] }
2930
tui-components = { path = "./tui-components" }
3031
agent-client-protocol = "0.7.0"
3132
tuicore = { git = "https://github.com/CSRessel/terminal-input-debug", rev = "a2f66fd" }

src/cli.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
use clap::Parser;
2+
3+
#[derive(Parser, Debug, PartialEq)]
4+
#[command(name = "nori-cli")]
5+
#[command(about = "A TUI for interacting with AI agents", long_about = None)]
6+
pub struct Cli {
7+
/// Select the agent to use (claude, claudecode, codex, mock)
8+
#[arg(short, long)]
9+
pub agent: Option<String>,
10+
11+
/// Initial message to send (or read from stdin)
12+
pub message: Option<String>,
13+
}
14+
15+
/// Map agent name to backend index
16+
/// Returns None if agent name is invalid
17+
pub fn agent_name_to_index(name: &str) -> Option<usize> {
18+
match name.to_lowercase().as_str() {
19+
"claude" => Some(0),
20+
"codex" => Some(1),
21+
"claudecode" => Some(2),
22+
"mock" => Some(3),
23+
_ => None,
24+
}
25+
}
26+
27+
/// Get list of valid agent names
28+
pub fn valid_agent_names() -> Vec<&'static str> {
29+
vec!["claude", "codex", "claudecode", "mock"]
30+
}

src/docs.md

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,14 @@ Core application modules implementing the TUI's architecture: application state
2020
```rust
2121
pub mod app; // Model, Message, and update logic
2222
pub mod backends; // AgentBackend trait and implementations
23+
pub mod cli; // CLI argument parsing and agent name mapping
2324
pub mod conversation; // JSONL parsing and event rendering
2425
pub mod ui; // Rendering functions for each mode
2526
```
2627

2728
**Entry Point** (@/src/main.rs):
28-
- `main()`: Sets up terminal (raw mode, Viewport::Inline(8)), runs async event loop, restores terminal on exit with cursor positioning to next line before disabling raw mode to ensure shell prompt appears cleanly below TUI content
29-
- `run_app()`: Core event loop using tokio::select! to handle messages and render at ~30 fps interval - includes mpsc channel for syncing `last_ctrl_c_time` to event handler task, conditionally increments `loading_frame` counter during streaming when using legacy spinner (only when `use_codex_components = false`)
29+
- `main()`: Parses CLI arguments via clap::Parser, validates agent name (exits with error if invalid), reads from stdin if piped, then sets up terminal (raw mode, Viewport::Inline(8)), runs async event loop, restores terminal on exit with cursor positioning to next line before disabling raw mode to ensure shell prompt appears cleanly below TUI content
30+
- `run_app(agent_index, initial_message)`: Core event loop using tokio::select! to handle messages and render at ~30 fps interval - accepts optional agent_index to skip agent selection screen and optional initial_message to pre-fill textarea - includes mpsc channel for syncing `last_ctrl_c_time` to event handler task, conditionally increments `loading_frame` counter during streaming when using legacy spinner (only when `use_codex_components = false`)
3031
- `handle_event_simple()` / `handle_key_simple()`: Convert crossterm key events to Message based on current mode - Ctrl-C detection happens FIRST before overlay/install prompt checks to ensure double Ctrl-C always works
3132
- `get_backend()`: Factory function that returns appropriate backend (Claude or Codex) based on selected_agent_index
3233
- `spawn_and_stream()`: Consumes backend stream using tokio::select! to multiplex stream consumption with cancellation signal - when cancelled, stream is dropped and child process cleanup happens via Drop semantics
@@ -57,6 +58,14 @@ pub mod ui; // Rendering functions for each mode
5758
- `render_event()`: Converts ConversationEvent into styled ratatui Lines - UserMessage renders with cyan `[user]` prefix, StatusMessage renders with green `[status]` prefix, StreamCancelled renders "Interrupted" in red, other events render with type-specific prefixes and colors
5859
- `should_render_event()`: Filters events based on debug mode - SystemEvent and UnknownEvent are considered debug events (hidden when `show_debug: false`), all other events (UserMessage, AssistantMessage, ResultSummary, StderrOutput, StreamCancelled, StatusMessage) are always visible
5960

61+
**CLI Argument Parsing** (@/src/cli.rs):
62+
- `Cli` struct: Derives clap::Parser for command-line argument parsing with two optional fields - `agent: Option<String>` for agent selection and `message: Option<String>` for initial message
63+
- `agent_name_to_index(name)`: Maps agent name strings to backend array indices - supports "claude" (0), "codex" (1), "claudecode" (2), "mock" (3) - case-insensitive matching via .to_lowercase(), returns None for invalid names
64+
- `valid_agent_names()`: Returns Vec of valid agent names for error messages
65+
- Agent selection via CLI bypasses TUI selection screen by setting `model.selected_agent_index` directly in run_app()
66+
- Stdin detection via `io::stdin().is_terminal()` from std::io::IsTerminal trait - reads piped input with `read_to_string()` before TUI initialization
67+
- CLI message argument takes precedence over stdin when both provided
68+
6069
**Backend Abstraction** (@/src/backends.rs):
6170
- `AgentBackend` trait: `spawn_stream(prompt, cancel_token) -> Pin<Box<dyn Stream<Item = ConversationEvent>>>` and metadata methods
6271
- Accepts CancellationToken for cooperative cancellation - backends receive token but child process cleanup happens via Drop
@@ -101,7 +110,16 @@ Cancellation Path
101110

102111
### Things to Know
103112

104-
**Terminal Cleanup Sequence** (@/src/main.rs:27-31):
113+
**CLI Argument Initialization Flow** (@/src/main.rs:31-68):
114+
- CLI parsing happens before terminal initialization to allow early exit on invalid agent names
115+
- Agent validation uses fail-fast strategy: invalid agent name prints error and exits with code 1 before TUI setup
116+
- Stdin detection via `!io::stdin().is_terminal()` checks if input is piped before consuming stdin with `read_to_string()`
117+
- Reading stdin consumes the entire input stream before TUI initialization - cannot read stdin after terminal is in raw mode
118+
- Message precedence: CLI argument (--message) takes priority over piped stdin via `cli.message.or(stdin_message)`
119+
- Agent selection bypass: when agent_index is Some, skips TUI agent selection screen by pre-populating `model.selected_agent_index`
120+
- Textarea pre-fill: initial_message sets textarea content via `.set_text()` before event loop starts
121+
122+
**Terminal Cleanup Sequence** (@/src/main.rs):
105123
- Cleanup happens in specific order: move cursor to next line, disable raw mode, call ratatui::restore()
106124
- `MoveToNextLine(1)` from crossterm positions cursor below TUI content before raw mode is disabled
107125
- Without cursor positioning, shell prompt would appear in middle of painted TUI area with Viewport::Inline(8)

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ pub mod acp_runner;
22
pub mod app;
33
pub mod autocomplete;
44
pub mod backends;
5+
pub mod cli;
56
pub mod commands;
67
pub mod conversation;
78
pub mod ui;

src/main.rs

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ mod acp_runner;
22
mod app;
33
mod autocomplete;
44
mod backends;
5+
mod cli;
56
mod commands;
67
mod conversation;
78
mod ui;
@@ -11,32 +12,83 @@ use crate::autocomplete::update_autocomplete_state;
1112
use crate::backends::{
1213
AgentBackend, claude::ClaudeBackend, claude_code_acp::ClaudeCodeAcpBackend, mock::MockBackend,
1314
};
15+
use crate::cli::{Cli, agent_name_to_index, valid_agent_names};
1416
use crate::commands::{CommandRegistry, parse_slash_command};
1517
use crate::conversation::{ConversationEvent, render_event, should_render_event};
1618

1719
use _tuicore::{TerminalWriter, TuiApp};
20+
use clap::Parser;
1821
use color_eyre::Result;
1922
use futures::StreamExt;
2023
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind};
2124
use ratatui::prelude::CrosstermBackend;
25+
use std::io::{self, IsTerminal, Read};
2226
use std::thread;
2327
use tokio::sync::mpsc;
2428
use tokio::time::{Duration, interval};
2529

2630
#[tokio::main]
2731
async fn main() -> Result<()> {
32+
// Parse CLI arguments
33+
let cli = Cli::parse();
34+
35+
// Validate agent name if provided
36+
let agent_index = if let Some(ref agent_name) = cli.agent {
37+
match agent_name_to_index(agent_name) {
38+
Some(index) => Some(index),
39+
None => {
40+
eprintln!("Error: Invalid agent name '{agent_name}'");
41+
eprintln!("Valid agents: {}", valid_agent_names().join(", "));
42+
std::process::exit(1);
43+
}
44+
}
45+
} else {
46+
None
47+
};
48+
49+
// Read from stdin if available (piped input)
50+
let stdin_message = if !io::stdin().is_terminal() {
51+
let mut buffer = String::new();
52+
io::stdin().read_to_string(&mut buffer)?;
53+
if !buffer.trim().is_empty() {
54+
Some(buffer.trim().to_string())
55+
} else {
56+
None
57+
}
58+
} else {
59+
None
60+
};
61+
62+
// Determine initial message (CLI arg takes precedence over stdin)
63+
let initial_message = cli.message.or(stdin_message);
64+
2865
let mut tui_app = TuiApp::builder("nori-cli").inline(20).build();
2966

3067
let mut terminal = tui_app.init()?;
31-
run_app(&mut terminal).await?;
68+
run_app(&mut terminal, agent_index, initial_message).await?;
3269

3370
tui_app.restore()?;
3471
// Optional terminal.insert_before here, for an exit status/usage message!
3572
Ok(())
3673
}
3774

38-
async fn run_app(terminal: &mut ratatui::Terminal<CrosstermBackend<TerminalWriter>>) -> Result<()> {
75+
async fn run_app(
76+
terminal: &mut ratatui::Terminal<CrosstermBackend<TerminalWriter>>,
77+
agent_index: Option<usize>,
78+
initial_message: Option<String>,
79+
) -> Result<()> {
3980
let mut model = Model::default();
81+
82+
// Set agent index if provided via CLI
83+
if let Some(index) = agent_index {
84+
model.selected_agent_index = Some(index);
85+
}
86+
87+
// Pre-fill textarea with initial message if provided
88+
if let Some(message) = initial_message {
89+
model.textarea.set_text(&message);
90+
}
91+
4092
let (tx, mut rx) = mpsc::unbounded_channel::<Message>();
4193

4294
// Create command registry

tests/cli_args_test.rs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
use clap::Parser;
2+
use nori_cli::cli::Cli;
3+
4+
#[test]
5+
fn test_agent_long_flag() {
6+
let cli = Cli::try_parse_from(&["prog", "--agent", "claude"]).unwrap();
7+
assert_eq!(cli.agent, Some("claude".to_string()));
8+
assert_eq!(cli.message, None);
9+
}
10+
11+
#[test]
12+
fn test_agent_short_flag() {
13+
let cli = Cli::try_parse_from(&["prog", "-a", "codex"]).unwrap();
14+
assert_eq!(cli.agent, Some("codex".to_string()));
15+
assert_eq!(cli.message, None);
16+
}
17+
18+
#[test]
19+
fn test_message_only() {
20+
let cli = Cli::try_parse_from(&["prog", "Hello world"]).unwrap();
21+
assert_eq!(cli.agent, None);
22+
assert_eq!(cli.message, Some("Hello world".to_string()));
23+
}
24+
25+
#[test]
26+
fn test_agent_and_message() {
27+
let cli = Cli::try_parse_from(&["prog", "--agent", "mock", "Test message"]).unwrap();
28+
assert_eq!(cli.agent, Some("mock".to_string()));
29+
assert_eq!(cli.message, Some("Test message".to_string()));
30+
}
31+
32+
#[test]
33+
fn test_no_arguments() {
34+
let cli = Cli::try_parse_from(&["prog"]).unwrap();
35+
assert_eq!(cli.agent, None);
36+
assert_eq!(cli.message, None);
37+
}
38+
39+
// Agent name mapping tests
40+
use nori_cli::cli::agent_name_to_index;
41+
42+
#[test]
43+
fn test_agent_name_claude() {
44+
assert_eq!(agent_name_to_index("claude"), Some(0));
45+
}
46+
47+
#[test]
48+
fn test_agent_name_codex() {
49+
assert_eq!(agent_name_to_index("codex"), Some(1));
50+
}
51+
52+
#[test]
53+
fn test_agent_name_claudecode() {
54+
assert_eq!(agent_name_to_index("claudecode"), Some(2));
55+
}
56+
57+
#[test]
58+
fn test_agent_name_mock() {
59+
assert_eq!(agent_name_to_index("mock"), Some(3));
60+
}
61+
62+
#[test]
63+
fn test_agent_name_case_insensitive() {
64+
assert_eq!(agent_name_to_index("Claude"), Some(0));
65+
assert_eq!(agent_name_to_index("CODEX"), Some(1));
66+
}
67+
68+
#[test]
69+
fn test_agent_name_invalid() {
70+
assert_eq!(agent_name_to_index("invalid"), None);
71+
assert_eq!(agent_name_to_index(""), None);
72+
}

tests/docs.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,19 @@ Test suite for the agent-router-tui application, covering state machine transiti
1717

1818
### Core Implementation
1919

20+
**CLI Argument Parsing Tests** (@/tests/cli_args_test.rs):
21+
- `test_agent_long_flag()`: Verifies --agent flag parses correctly with agent name
22+
- `test_agent_short_flag()`: Verifies -a short flag parses correctly
23+
- `test_message_only()`: Verifies positional message argument without agent flag
24+
- `test_agent_and_message()`: Verifies both --agent and message arguments together
25+
- `test_no_arguments()`: Verifies empty CLI args parse to None values
26+
- `test_agent_name_claude()`: Verifies "claude" maps to index 0
27+
- `test_agent_name_codex()`: Verifies "codex" maps to index 1
28+
- `test_agent_name_claudecode()`: Verifies "claudecode" maps to index 2
29+
- `test_agent_name_mock()`: Verifies "mock" maps to index 3
30+
- `test_agent_name_case_insensitive()`: Verifies agent name matching is case-insensitive
31+
- `test_agent_name_invalid()`: Verifies invalid agent names return None from agent_name_to_index()
32+
2033
**Ctrl-C Handling Tests** (@/tests/ctrl_c_handling_test.rs):
2134
- `test_first_ctrl_c_clears_textarea_and_shows_hint()`: Verifies first Ctrl-C press behavior
2235
- Adds text to textarea via .insert_str()
@@ -170,6 +183,16 @@ Test suite for the agent-router-tui application, covering state machine transiti
170183
- Extracts text field from AssistantMessage
171184
- Verifies contract between backends and main loop: backends output Claude CLI format, parse_jsonl_event() produces ConversationEvent
172185

186+
**CLI Argument Test Coverage** (@/tests/cli_args_test.rs):
187+
- Comprehensive tests for clap::Parser argument parsing (11 tests total)
188+
- Tests both long (--agent) and short (-a) flag forms
189+
- Tests positional message argument parsing
190+
- Tests combination of agent flag and message argument
191+
- Tests empty argument scenario (no flags or args)
192+
- Agent name mapping tests verify all four backends (claude, codex, claudecode, mock)
193+
- Case-insensitive matching tests verify .to_lowercase() behavior
194+
- Invalid agent name test verifies None return for unknown agents
195+
173196
**Conversation Module Test Coverage**:
174197
- Comprehensive tests for all ConversationEvent variants (21 tests total including UserMessage, StreamCancelled, StatusMessage, and filtering logic)
175198
- Edge cases: malformed JSON, missing fields, multiple text blocks, empty details
@@ -208,6 +231,8 @@ Test suite for the agent-router-tui application, covering state machine transiti
208231
- No integration test for actual stream cancellation with long-running process - test_cancel_stream_during_streaming only tests state machine
209232
- No integration test for Ctrl-C priority over overlays/install prompts - tests only verify Model state transitions, not actual key event routing in handle_key_simple
210233
- No test for main loop quit detection (Some → None timestamp transition) - would require full event loop integration
234+
- No integration tests for CLI argument flow - stdin reading, agent validation with exit code, agent selection bypass, textarea pre-fill - only unit tests for clap parsing and agent name mapping
235+
- No test for CLI message precedence (CLI arg vs stdin) - would require mocking stdin and command-line args together
211236

212237
**CI Integration**:
213238
- Tests run on every PR via @/.github/workflows/pr-ci.yml

0 commit comments

Comments
 (0)