Skip to content

Commit b8aac02

Browse files
committed
feat: support a --script option
This allows using the Q cli as an intepreter for a script. E.g. #!/usr/bin/env q chat --no-interactive --trust-tools=a,b --script
1 parent 3311ed2 commit b8aac02

4 files changed

Lines changed: 172 additions & 1 deletion

File tree

crates/cli/src/cli/chat/cli.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ pub struct Chat {
1717
/// prompt requests permissions to use a tool, unless --trust-all-tools is also used.
1818
#[arg(long)]
1919
pub no_interactive: bool,
20+
/// Runs the given path as a Q script
21+
#[arg(long, conflicts_with = "input")]
22+
pub script: Option<String>,
2023
/// Resumes the previous conversation from this directory.
2124
#[arg(short, long)]
2225
pub resume: bool,

crates/cli/src/cli/chat/mod.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ use crate::api_client::model::{
153153
Tool as FigTool,
154154
ToolResultStatus,
155155
};
156+
use crate::cli::script::read_q_script;
156157
use crate::database::Database;
157158
use crate::database::settings::Setting;
158159
use crate::mcp_client::{
@@ -300,10 +301,22 @@ pub async fn launch_chat(database: &mut Database, telemetry: &TelemetryThread, a
300301
tools
301302
});
302303

304+
// input and script are mutually exclusive options, so we can just read the script and
305+
// pass it as the input here
306+
let mut input = args.input.clone();
307+
if let Some(script) = args.script {
308+
match read_q_script(&script).await {
309+
Ok(v) => input = Some(v),
310+
Err(e) => {
311+
bail!("Unable to read script {}, {}", script, e);
312+
},
313+
}
314+
}
315+
303316
chat(
304317
database,
305318
telemetry,
306-
args.input,
319+
input,
307320
args.no_interactive,
308321
args.resume,
309322
args.accept_all,

crates/cli/src/cli/mod.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ mod debug;
33
mod diagnostics;
44
mod feed;
55
mod issue;
6+
mod script;
67
mod settings;
78
mod user;
89

@@ -369,6 +370,7 @@ mod test {
369370
subcommand: Some(CliRootCommands::Chat(Chat {
370371
accept_all: false,
371372
no_interactive: false,
373+
script: None,
372374
resume: false,
373375
input: None,
374376
profile: None,
@@ -408,6 +410,7 @@ mod test {
408410
CliRootCommands::Chat(Chat {
409411
accept_all: false,
410412
no_interactive: false,
413+
script: None,
411414
resume: false,
412415
input: None,
413416
profile: Some("my-profile".to_string()),
@@ -424,6 +427,7 @@ mod test {
424427
CliRootCommands::Chat(Chat {
425428
accept_all: false,
426429
no_interactive: false,
430+
script: None,
427431
resume: false,
428432
input: Some("Hello".to_string()),
429433
profile: Some("my-profile".to_string()),
@@ -440,6 +444,7 @@ mod test {
440444
CliRootCommands::Chat(Chat {
441445
accept_all: true,
442446
no_interactive: false,
447+
script: None,
443448
resume: false,
444449
input: None,
445450
profile: Some("my-profile".to_string()),
@@ -456,6 +461,7 @@ mod test {
456461
CliRootCommands::Chat(Chat {
457462
accept_all: false,
458463
no_interactive: true,
464+
script: None,
459465
resume: true,
460466
input: None,
461467
profile: None,
@@ -468,6 +474,7 @@ mod test {
468474
CliRootCommands::Chat(Chat {
469475
accept_all: false,
470476
no_interactive: true,
477+
script: None,
471478
resume: true,
472479
input: None,
473480
profile: None,
@@ -484,6 +491,7 @@ mod test {
484491
CliRootCommands::Chat(Chat {
485492
accept_all: false,
486493
no_interactive: false,
494+
script: None,
487495
resume: false,
488496
input: None,
489497
profile: None,
@@ -500,6 +508,7 @@ mod test {
500508
CliRootCommands::Chat(Chat {
501509
accept_all: false,
502510
no_interactive: false,
511+
script: None,
503512
resume: false,
504513
input: None,
505514
profile: None,
@@ -516,6 +525,7 @@ mod test {
516525
CliRootCommands::Chat(Chat {
517526
accept_all: false,
518527
no_interactive: false,
528+
script: None,
519529
resume: false,
520530
input: None,
521531
profile: None,

crates/cli/src/cli/script.rs

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
use std::io;
2+
3+
use eyre::Result;
4+
use tokio::fs;
5+
6+
/// read_q_script reads the file passed in and returns its contents. If it's first line begins with
7+
/// a shebang (#!), that line and consecutive lines beginning with a '#' character are skipped.
8+
pub async fn read_q_script(script: &str) -> Result<String, io::Error> {
9+
match fs::read_to_string(&script).await {
10+
Ok(content) => {
11+
let mut lines = content.lines().peekable();
12+
let mut result_lines = Vec::new();
13+
14+
// Only skip the first line if it starts with '#!'
15+
if lines.peek().is_some_and(|line| line.starts_with("#!")) {
16+
lines.next();
17+
// Skip consecutive comment lines after shebang
18+
while lines.peek().is_some_and(|line| line.starts_with('#')) {
19+
lines.next();
20+
}
21+
}
22+
23+
result_lines.extend(lines);
24+
Ok(result_lines.join("\n"))
25+
},
26+
Err(e) => Err(e),
27+
}
28+
}
29+
#[cfg(test)]
30+
mod tests {
31+
use std::io::Write;
32+
33+
use tempfile::NamedTempFile;
34+
35+
use super::*;
36+
37+
fn create_temp_script(content: &str) -> NamedTempFile {
38+
let mut file = NamedTempFile::new().unwrap();
39+
file.write_all(content.as_bytes()).unwrap();
40+
file
41+
}
42+
43+
#[tokio::test]
44+
async fn test_script_with_shebang_and_consecutive_comments() {
45+
let content =
46+
"#!/usr/bin/env q\n# This is a comment\n# Another comment\nactual content\n# Later comment\nmore content";
47+
let file = create_temp_script(content);
48+
49+
let result = read_q_script(file.path().to_string_lossy().as_ref()).await;
50+
assert_eq!(result.unwrap(), "actual content\n# Later comment\nmore content");
51+
}
52+
53+
#[tokio::test]
54+
async fn test_script_with_shebang_no_comments() {
55+
let content = "#!/usr/bin/env q\nactual content\nmore content";
56+
let file = create_temp_script(content);
57+
58+
let result = read_q_script(file.path().to_string_lossy().as_ref()).await;
59+
assert_eq!(result.unwrap(), "actual content\nmore content");
60+
}
61+
62+
#[tokio::test]
63+
async fn test_script_without_shebang_with_comments() {
64+
let content = "# This is a comment\nactual content\n# Later comment\nmore content";
65+
let file = create_temp_script(content);
66+
67+
let result = read_q_script(file.path().to_string_lossy().as_ref()).await;
68+
assert_eq!(
69+
result.unwrap(),
70+
"# This is a comment\nactual content\n# Later comment\nmore content"
71+
);
72+
}
73+
74+
#[tokio::test]
75+
async fn test_script_without_shebang_no_comments() {
76+
let content = "actual content\nmore content";
77+
let file = create_temp_script(content);
78+
79+
let result = read_q_script(file.path().to_string_lossy().as_ref()).await;
80+
assert_eq!(result.unwrap(), "actual content\nmore content");
81+
}
82+
83+
#[tokio::test]
84+
async fn test_script_with_only_shebang() {
85+
let content = "#!/usr/bin/env q";
86+
let file = create_temp_script(content);
87+
88+
let result = read_q_script(file.path().to_string_lossy().as_ref()).await;
89+
assert_eq!(result.unwrap(), "");
90+
}
91+
92+
#[tokio::test]
93+
async fn test_script_with_shebang_and_only_comments() {
94+
let content = "#!/usr/bin/env q\n# Comment 1\n# Comment 2";
95+
let file = create_temp_script(content);
96+
97+
let result = read_q_script(file.path().to_string_lossy().as_ref()).await;
98+
assert_eq!(result.unwrap(), "");
99+
}
100+
101+
#[tokio::test]
102+
async fn test_script_with_non_shebang_hash_first_line() {
103+
let content = "#not a shebang\nactual content\nmore content";
104+
let file = create_temp_script(content);
105+
106+
let result = read_q_script(file.path().to_string_lossy().as_ref()).await;
107+
assert_eq!(result.unwrap(), "#not a shebang\nactual content\nmore content");
108+
}
109+
110+
#[tokio::test]
111+
async fn test_empty_script() {
112+
let content = "";
113+
let file = create_temp_script(content);
114+
115+
let result = read_q_script(file.path().to_string_lossy().as_ref()).await;
116+
assert_eq!(result.unwrap(), "");
117+
}
118+
119+
#[tokio::test]
120+
async fn test_script_with_mixed_content() {
121+
let content = "#!/usr/bin/env q\n# Header comment\n# Another header comment\n\nactual content\n# Inline comment\nmore content\n\n# Final comment";
122+
let file = create_temp_script(content);
123+
124+
let result = read_q_script(file.path().to_string_lossy().as_ref()).await;
125+
assert_eq!(
126+
result.unwrap(),
127+
"\nactual content\n# Inline comment\nmore content\n\n# Final comment"
128+
);
129+
}
130+
131+
#[tokio::test]
132+
async fn test_nonexistent_file() {
133+
let result = read_q_script("/nonexistent/file.q").await;
134+
assert!(result.is_err());
135+
}
136+
137+
#[tokio::test]
138+
async fn test_script_with_shebang_and_empty_lines() {
139+
let content = "#!/usr/bin/env q\n# Comment\n\n\nactual content";
140+
let file = create_temp_script(content);
141+
142+
let result = read_q_script(file.path().to_string_lossy().as_ref()).await;
143+
assert_eq!(result.unwrap(), "\n\nactual content");
144+
}
145+
}

0 commit comments

Comments
 (0)