Skip to content

Commit 20e8316

Browse files
authored
feat: add non-interactive mode for health checks and scripting (#314)
* feat: add non-interactive mode for health checks and scripting Add --no-interactive flag to ahnlich-cli to enable: - Docker health checks using CLI commands - Scripting and automation via stdin - Clean, parseable output (ANSI codes stripped) Implementation: - Added --no-interactive flag to CLI config - Implemented run_non_interactive() method that reads from stdin - Added strip_ansi_codes() helper for clean output - Created integration tests - Updated README with comprehensive documentation * chore: add superpowers folder to gitignore * test: add integration tests for non-interactive CLI mode Add comprehensive integration tests that provision real servers: - DB agent tests (PING, INFOSERVER, LISTSTORES, multiple commands) - AI agent tests (PING, INFOSERVER, LISTSTORES, multiple commands) - Error handling tests (connection refused, invalid command) Implementation: - Added dev-dependencies (db, ai, utils) for server provisioning - Created test infrastructure with helper functions - Fixed stdin EOF handling using spawn_blocking - AI server now provisions with DB backend (required for most commands) - All tests include 10s timeout protection - Tests validate ANSI stripping works (non-interactive mode) Tests use programmatic server provisioning with OS-selected ports for isolation and parallel execution. * docs: add health check examples for non-interactive CLI mode * fix: check stdin before connecting in non-interactive mode * fix: wrap stdin reading in spawn_blocking to prevent deadlock - Prevents blocking the async runtime when reading from stdin - Fixes timeout issues in integration tests - Allows proper EOF signaling from parent processes
1 parent d36fce4 commit 20e8316

File tree

12 files changed

+787
-14
lines changed

12 files changed

+787
-14
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,4 @@ profiling.json*
3535
#npm residue
3636
node_modules/
3737
.DS_Store
38+
docs/superpowers/

README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,45 @@ services:
209209
210210
```
211211

212+
Below is an example `docker-compose.yaml` configuration with health checks using the CLI in non-interactive mode:
213+
214+
```yaml
215+
216+
services:
217+
ahnlich_db:
218+
image: ghcr.io/deven96/ahnlich-db:latest
219+
command: >
220+
"ahnlich-db run --host 0.0.0.0"
221+
ports:
222+
- "1369:1369"
223+
healthcheck:
224+
test: ["CMD-SHELL", "echo 'PING' | ahnlich-cli ahnlich --agent db --host 127.0.0.1 --port 1369 --no-interactive"]
225+
interval: 10s
226+
timeout: 5s
227+
retries: 3
228+
start_period: 5s
229+
230+
ahnlich_ai:
231+
image: ghcr.io/deven96/ahnlich-ai:latest
232+
command: >
233+
"ahnlich-ai run --db-host ahnlich_db --host 0.0.0.0 \
234+
--supported-models all-minilm-l6-v2"
235+
ports:
236+
- "1370:1370"
237+
depends_on:
238+
ahnlich_db:
239+
condition: service_healthy
240+
healthcheck:
241+
test: ["CMD-SHELL", "echo 'PING' | ahnlich-cli ahnlich --agent ai --host 127.0.0.1 --port 1370 --no-interactive"]
242+
interval: 10s
243+
timeout: 5s
244+
retries: 3
245+
start_period: 10s
246+
247+
```
248+
249+
The `--no-interactive` flag allows the CLI to accept commands via stdin and exit immediately after processing, making it ideal for Docker health checks, CI/CD pipelines, and automated scripts.
250+
212251
### Execution Providers (Ahnlich AI)
213252

214253
- `CUDA`: Only supports >= CUDAv12 and might need to `sudo apt install libcudnn9-dev-cuda-12`

ahnlich/Cargo.lock

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

ahnlich/cli/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,8 @@ ahnlich_types = { path = "../types", version = "*" }
2424
serde_json.workspace = true
2525
serde.workspace = true
2626
tonic.workspace = true
27+
28+
[dev-dependencies]
29+
db = { path = "../db" }
30+
ai = { path = "../ai" }
31+
utils = { path = "../utils", version = "*" }

ahnlich/cli/README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,59 @@ ahnlich_cli ahnlich --agent db --host 127.0.0.1 --port 1369
3434
ahnlich_cli ahnlich --agent ai --host 127.0.0.1 --port 1370
3535
```
3636

37+
### Non-Interactive Mode
38+
39+
For scripting, health checks, and automation, use `--no-interactive` to read commands from stdin:
40+
41+
```bash
42+
# Simple health check
43+
echo 'PING' | ahnlich-cli ahnlich --agent db --host 127.0.0.1 --port 1369 --no-interactive
44+
45+
# From a file
46+
cat commands.txt | ahnlich-cli ahnlich --agent ai --host 127.0.0.1 --port 1370 --no-interactive
47+
48+
# Heredoc
49+
ahnlich-cli ahnlich --agent db --host 127.0.0.1 --port 1369 --no-interactive << EOF
50+
PING
51+
LISTSTORES
52+
EOF
53+
```
54+
55+
#### Docker Health Check
56+
57+
Use non-interactive mode for Docker health checks:
58+
59+
```yaml
60+
healthcheck:
61+
test: ["CMD-SHELL", "echo 'PING' | ahnlich-cli ahnlich --agent db --host 127.0.0.1 --port 1369 --no-interactive"]
62+
interval: 10s
63+
timeout: 5s
64+
retries: 3
65+
```
66+
67+
#### Exit Codes
68+
69+
- `0`: Success
70+
- `1`: Error (connection failure, invalid command, etc.)
71+
72+
#### Output
73+
74+
Non-interactive mode produces clean output without colors or prompts, suitable for parsing:
75+
76+
```bash
77+
$ echo 'PING' | ahnlich-cli --no-interactive ...
78+
Success
79+
```
80+
81+
Errors are written to stderr:
82+
83+
```bash
84+
$ echo 'INVALID' | ahnlich-cli --no-interactive ...
85+
Error: Unknown command 'INVALID'
86+
$ echo $?
87+
1
88+
```
89+
3790
## Querying the DB
3891

3992
The CLI accepts a range of commands for database operations. Commands are written in the following format:

ahnlich/cli/src/config/cli.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,8 @@ pub struct AhnlichCliConfig {
3131
/// Host to connect to Ahnlich AI or DB
3232
#[arg(long)]
3333
pub port: Option<u16>,
34+
35+
/// Run in non-interactive mode (read commands from stdin)
36+
#[arg(long, default_value_t = false)]
37+
pub no_interactive: bool,
3438
}

ahnlich/cli/src/main.rs

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,61 @@ async fn main() -> std::io::Result<()> {
88

99
match cli.commands {
1010
ahnlich_cli::config::cli::Commands::Ahnlich(config) => {
11-
let client = AgentClient::create_client(config.agent, &config.host, config.port)
11+
// In non-interactive mode, check for input first before connecting
12+
if config.no_interactive {
13+
// Read stdin in a blocking task to avoid blocking the async runtime
14+
let input_raw = tokio::task::spawn_blocking(|| {
15+
use std::io::Read;
16+
let mut input = String::new();
17+
io::stdin().read_to_string(&mut input)?;
18+
Ok::<String, io::Error>(input)
19+
})
1220
.await
13-
.map_err(io::Error::other)?;
21+
.map_err(|e| io::Error::other(format!("Failed to join task: {}", e)))??;
1422

15-
if !client
16-
.is_valid_connection()
17-
.await
18-
.map_err(io::Error::other)?
19-
{
20-
return Err(io::Error::other(format!(
21-
"Connected Server is not a valid {client} Server"
22-
)));
23+
let input = input_raw.trim();
24+
25+
if input.is_empty() {
26+
eprintln!("Error: No input provided");
27+
std::process::exit(1);
28+
}
29+
30+
// Now connect and execute
31+
let client = AgentClient::create_client(config.agent, &config.host, config.port)
32+
.await
33+
.map_err(io::Error::other)?;
34+
35+
if !client
36+
.is_valid_connection()
37+
.await
38+
.map_err(io::Error::other)?
39+
{
40+
return Err(io::Error::other(format!(
41+
"Connected Server is not a valid {client} Server"
42+
)));
43+
}
44+
45+
let term = Term::new(client);
46+
term.execute_non_interactive(input).await?;
47+
} else {
48+
let client = AgentClient::create_client(config.agent, &config.host, config.port)
49+
.await
50+
.map_err(io::Error::other)?;
51+
52+
if !client
53+
.is_valid_connection()
54+
.await
55+
.map_err(io::Error::other)?
56+
{
57+
return Err(io::Error::other(format!(
58+
"Connected Server is not a valid {client} Server"
59+
)));
60+
}
61+
62+
let term = Term::new(client);
63+
term.welcome_message()?;
64+
term.run().await?;
2365
}
24-
let term = Term::new(client);
25-
term.welcome_message()?;
26-
term.run().await?;
2766
}
2867
}
2968
Ok(())

ahnlich/cli/src/term.rs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,4 +305,83 @@ impl Term {
305305
disable_raw_mode()?;
306306
Ok(())
307307
}
308+
309+
/// Run in non-interactive mode: read from stdin, execute, output results
310+
///
311+
/// Behavior:
312+
/// - Reads all input from stdin until EOF
313+
/// - Executes queries via the client
314+
/// - Outputs clean results to stdout (no colors, no prompts)
315+
/// - Errors go to stderr
316+
/// - Exits with code 1 on error, 0 on success
317+
pub async fn execute_non_interactive(&self, input: &str) -> io::Result<()> {
318+
// Execute queries
319+
match self.client.parse_queries(input).await {
320+
Ok(responses) => {
321+
// Output clean results (strip ANSI color codes)
322+
for msg in responses {
323+
println!("{}", strip_ansi_codes(&msg));
324+
}
325+
Ok(())
326+
}
327+
Err(err) => {
328+
// Error to stderr, exit with error code
329+
eprintln!("{}", err);
330+
std::process::exit(1);
331+
}
332+
}
333+
}
334+
}
335+
336+
/// Strip ANSI color codes from a string for clean output
337+
fn strip_ansi_codes(s: &str) -> String {
338+
let mut result = String::new();
339+
let mut chars = s.chars();
340+
341+
while let Some(ch) = chars.next() {
342+
if ch == '\x1b' {
343+
// Skip ANSI escape sequence
344+
if chars.next() == Some('[') {
345+
// Skip until we hit a letter (end of escape sequence)
346+
for ch in chars.by_ref() {
347+
if ch.is_ascii_alphabetic() {
348+
break;
349+
}
350+
}
351+
}
352+
} else {
353+
result.push(ch);
354+
}
355+
}
356+
357+
result
358+
}
359+
360+
#[cfg(test)]
361+
mod tests {
362+
use super::*;
363+
364+
#[test]
365+
fn test_strip_ansi_codes_no_codes() {
366+
let input = "Hello World";
367+
assert_eq!(strip_ansi_codes(input), "Hello World");
368+
}
369+
370+
#[test]
371+
fn test_strip_ansi_codes_with_color() {
372+
let input = "\x1b[32mSuccess\x1b[0m";
373+
assert_eq!(strip_ansi_codes(input), "Success");
374+
}
375+
376+
#[test]
377+
fn test_strip_ansi_codes_multiple_colors() {
378+
let input = "\x1b[31mError:\x1b[0m \x1b[32mDetails\x1b[0m";
379+
assert_eq!(strip_ansi_codes(input), "Error: Details");
380+
}
381+
382+
#[test]
383+
fn test_strip_ansi_codes_empty() {
384+
let input = "";
385+
assert_eq!(strip_ansi_codes(input), "");
386+
}
308387
}

0 commit comments

Comments
 (0)