gate4agent 0.2.35

Universal transport library for CLI AI agents (Claude Code, Codex, Gemini, OpenCode). Pipe, PTY, ACP (Agent Client Protocol), and Daemon transports.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
//! Pipe-based process wrapper for headless CLI tools.
//!
//! Unlike PtyWrapper (which uses a PTY for interactive TUI tools),
//! PipeProcess uses stdin/stdout pipes for headless NDJSON-streaming tools.

use std::io::Write;
use std::process::{Child, Command, Stdio};
use std::sync::mpsc::{self, Receiver, Sender};
use std::thread;

use crate::pipe::cli::cli_builder;
use crate::transport::SpawnOptions;
use crate::core::types::CliTool;

/// Claude Code-specific options for pipe mode spawning.
#[derive(Debug, Clone, Default)]
pub struct ClaudeOptions {
    /// Content to append to the system prompt via --append-system-prompt.
    /// Goes into the system prompt and is NEVER compressed or ignored — highest priority.
    pub append_system_prompt: Option<String>,
    /// Resume an existing session via --resume <session-id>.
    pub resume_session_id: Option<String>,
    /// Model override via --model.
    pub model: Option<String>,
}

/// Options for PipeProcess spawning.
///
/// Extra args are passed directly to the CLI command after the standard flags.
/// Claude-specific options live in the `claude` sub-struct to make them self-documenting.
#[derive(Debug, Clone, Default)]
pub struct PipeProcessOptions {
    /// Extra CLI arguments appended after standard flags.
    pub extra_args: Vec<String>,
    /// Claude Code-specific options (ignored for Codex/Gemini).
    pub claude: ClaudeOptions,
}

/// A pipe-based process for headless CLI tool execution.
pub struct PipeProcess {
    child: Child,
    stdin: Option<std::process::ChildStdin>,
    output_rx: Receiver<String>,
    tool: CliTool,
}

impl PipeProcess {
    /// Spawn a headless CLI process with stdin/stdout pipes.
    ///
    /// Each tool is launched with its headless NDJSON flags:
    /// - Claude: `claude -p --output-format stream-json --verbose`
    /// - Codex: `codex exec --json`
    /// - Gemini: `gemini --output-format stream-json -p`
    pub fn new(
        tool: CliTool,
        working_dir: &std::path::Path,
        initial_prompt: &str,
    ) -> Result<Self, std::io::Error> {
        Self::new_with_options(
            tool,
            working_dir,
            initial_prompt,
            PipeProcessOptions::default(),
        )
    }

    /// Spawn a headless CLI process with stdin/stdout pipes and custom options.
    ///
    /// The initial prompt is written to stdin (not as a CLI argument) to avoid
    /// Windows `cmd /C` mangling of Unicode, spaces, and special characters.
    /// Note: Codex and Gemini receive the prompt as a CLI argument because they
    /// do not read stdin; for those tools the `cmd /C` shell string includes the
    /// properly-escaped prompt.
    pub fn new_with_options(
        tool: CliTool,
        working_dir: &std::path::Path,
        initial_prompt: &str,
        options: PipeProcessOptions,
    ) -> Result<Self, std::io::Error> {
        let spawn_opts = Self::pipe_options_to_spawn_opts(tool, initial_prompt, &options, working_dir);
        let mut cmd = Self::build_command_with_options(tool, &spawn_opts);
        for (key, value) in &spawn_opts.env_vars {
            cmd.env(key, value);
        }

        cmd.current_dir(working_dir);
        cmd.stdin(Stdio::piped());
        cmd.stdout(Stdio::piped());
        cmd.stderr(Stdio::null());

        let mut child = cmd.spawn()?;

        let stdin = child.stdin.take();

        // Write prompt via stdin instead of CLI argument (avoids cmd.exe mangling).
        // Claude `-p` reads stdin until EOF, so we must drop (close) stdin after writing.
        // For Codex and Gemini the prompt is already in the argv; stdin is closed immediately.
        if let Some(mut s) = stdin {
            if tool == CliTool::ClaudeCode {
                s.write_all(initial_prompt.as_bytes())?;
                s.flush()?;
            }
            drop(s); // close stdin → Claude sees EOF → starts processing
        }

        // stdin is now closed; set to None
        let stdin: Option<std::process::ChildStdin> = None;

        let stdout = child
            .stdout
            .take()
            .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::Other, "no stdout"))?;

        let (tx, rx) = mpsc::channel();
        thread::spawn(move || {
            Self::reader_thread(stdout, tx);
        });

        Ok(Self {
            child,
            stdin,
            output_rx: rx,
            tool,
        })
    }

    /// Convert legacy `PipeProcessOptions` to the new `SpawnOptions`.
    fn pipe_options_to_spawn_opts(
        _tool: CliTool,
        prompt: &str,
        options: &PipeProcessOptions,
        working_dir: &std::path::Path,
    ) -> SpawnOptions {
        SpawnOptions {
            working_dir: working_dir.to_path_buf(),
            prompt: prompt.to_string(),
            resume_session_id: options.claude.resume_session_id.clone(),
            model: options.claude.model.clone(),
            append_system_prompt: options.claude.append_system_prompt.clone(),
            extra_args: options.extra_args.clone(),
            env_vars: Vec::new(),
            ..Default::default()
        }
    }

    /// Build the spawn `Command` by delegating to the per-CLI builder.
    ///
    /// On Unix: returns the `Command` from the per-CLI builder directly.
    ///
    /// On Windows: npm-installed CLIs get `cmd /C <program>.cmd <args...>`.
    /// Non-npm tools (bash scripts, native binaries) get `bash -c '...'`.
    /// Each arg is passed individually (not joined into a shell string)
    /// so Windows CreateProcess handles quoting correctly for prompts
    /// that contain spaces and special characters.
    fn build_command_with_options(tool: CliTool, opts: &SpawnOptions) -> Command {
        let builder = cli_builder(tool);
        let inner_cmd = builder.build_command(opts);

        if cfg!(windows) {
            // On Windows, npm-installed CLIs have `.cmd` batch wrappers that
            // cmd.exe can invoke. Non-npm tools (e.g. cursor-agent) may be
            // bash scripts or native binaries with no `.cmd` wrapper.
            //
            // Strategy: check if `<program>.cmd` exists on PATH. If yes,
            // use `cmd /C <program>.cmd <args...>`. If not, invoke the
            // program directly (works for .exe binaries and bash scripts
            // when running under MSYS2/Git Bash).
            //
            // Each arg is passed as a separate element (NOT joined into a
            // shell string) so Windows CreateProcess handles quoting correctly.
            let program = inner_cmd.get_program().to_string_lossy().to_string();

            if program.ends_with(".cmd") || program.ends_with(".exe") {
                // Already has extension — use cmd /C directly.
                let mut cmd = Command::new("cmd");
                cmd.arg("/C");
                cmd.arg(&program);
                for arg in inner_cmd.get_args() {
                    cmd.arg(arg);
                }
                cmd
            } else {
                // Check if a .cmd wrapper exists on PATH.
                let cmd_name = format!("{}.cmd", program);
                let has_cmd = std::env::var_os("PATH")
                    .map(|path| {
                        std::env::split_paths(&path)
                            .any(|dir| dir.join(&cmd_name).is_file())
                    })
                    .unwrap_or(false);

                if has_cmd {
                    let mut cmd = Command::new("cmd");
                    cmd.arg("/C");
                    cmd.arg(&cmd_name);
                    for arg in inner_cmd.get_args() {
                        cmd.arg(arg);
                    }
                    cmd
                } else {
                    // No .cmd wrapper — may be a bash script or native .exe.
                    // Windows CreateProcess can't run extensionless scripts,
                    // so we invoke via `bash -c "program arg1 arg2 ..."`.
                    let mut cmd = Command::new("bash");
                    cmd.arg("-c");
                    let mut shell_str = Self::shell_quote(&program);
                    for arg in inner_cmd.get_args() {
                        let s = arg.to_string_lossy();
                        shell_str.push(' ');
                        shell_str.push_str(&Self::shell_quote(&s));
                    }
                    cmd.arg(&shell_str);
                    cmd
                }
            }
        } else {
            inner_cmd
        }
    }

    /// Single-quote a token for POSIX shell (`bash -c`).
    pub(crate) fn shell_quote(s: &str) -> String {
        if s.is_empty() {
            return "''".to_string();
        }
        // If it contains no special chars, return as-is.
        if s.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '/') {
            return s.to_string();
        }
        // Wrap in single quotes, escaping embedded single quotes.
        format!("'{}'", s.replace('\'', "'\\''"))
    }

    /// Expose `build_command_with_options` for in-module tests only.
    #[cfg(test)]
    pub(crate) fn build_for_test(tool: CliTool, opts: &SpawnOptions) -> Command {
        Self::build_command_with_options(tool, opts)
    }

    fn reader_thread(stdout: std::process::ChildStdout, tx: Sender<String>) {
        use std::io::{BufRead, BufReader};

        let reader = BufReader::new(stdout);
        for line in reader.lines() {
            match line {
                Ok(line) => {
                    if tx.send(format!("{}\n", line)).is_err() {
                        break;
                    }
                }
                Err(_) => break,
            }
        }
    }

    /// Try to receive output (non-blocking).
    pub fn try_recv(&self) -> Option<String> {
        self.output_rx.try_recv().ok()
    }

    /// Write input to the process stdin.
    ///
    /// Returns `BrokenPipe` if stdin is closed (all supported CLIs are one-shot:
    /// Claude reads prompt from stdin then closes; Codex/Gemini/OpenCode take the
    /// prompt as argv and never open stdin at all). Callers should use
    /// `resume_session_id` to continue a prior session rather than writing again.
    pub fn write(&mut self, data: &str) -> Result<(), std::io::Error> {
        if let Some(stdin) = &mut self.stdin {
            stdin.write_all(data.as_bytes())?;
            stdin.flush()?;
            Ok(())
        } else {
            Err(std::io::Error::new(
                std::io::ErrorKind::BrokenPipe,
                "stdin is closed — process is one-shot, use resume_session_id to continue",
            ))
        }
    }

    /// Check if the process is still running.
    pub fn is_running(&mut self) -> bool {
        self.child.try_wait().ok().flatten().is_none()
    }

    /// Kill the process.
    pub fn kill(&mut self) -> Result<(), std::io::Error> {
        self.child.kill()
    }

    /// Wait for the process to exit and return its exit status.
    ///
    /// Called by the reader loop after `is_running()` returns false to collect
    /// the exit code for `SessionEnd` synthesis.
    pub fn wait(&mut self) -> Result<Option<std::process::ExitStatus>, std::io::Error> {
        self.child.try_wait()
    }

    /// Get the CLI tool type.
    pub fn tool(&self) -> CliTool {
        self.tool
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// Unit tests
// ─────────────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;

    // ── shell_quote ──────────────────────────────────────────────────────────

    #[test]
    fn shell_quote_empty_string() {
        assert_eq!(PipeProcess::shell_quote(""), "''");
    }

    #[test]
    fn shell_quote_simple_token_returned_as_is() {
        // Pure alphanumeric + allowed symbols — no quoting needed.
        assert_eq!(PipeProcess::shell_quote("hello"), "hello");
        assert_eq!(PipeProcess::shell_quote("my-flag"), "my-flag");
        assert_eq!(PipeProcess::shell_quote("path/to/file.json"), "path/to/file.json");
        assert_eq!(PipeProcess::shell_quote("arg_name"), "arg_name");
    }

    #[test]
    fn shell_quote_string_with_spaces_is_single_quoted() {
        let q = PipeProcess::shell_quote("hello world");
        assert_eq!(q, "'hello world'");
    }

    #[test]
    fn shell_quote_string_with_special_chars() {
        let q = PipeProcess::shell_quote("--output-format=stream-json");
        // Contains '=', which is not in the safe set, so must be quoted.
        assert_eq!(q, "'--output-format=stream-json'");
    }

    #[test]
    fn shell_quote_embedded_single_quote_is_escaped() {
        // Input: it's → output: 'it'\''s'
        let q = PipeProcess::shell_quote("it's");
        assert_eq!(q, "'it'\\''s'");
    }

    #[test]
    fn shell_quote_only_single_quotes() {
        let q = PipeProcess::shell_quote("'");
        assert_eq!(q, "''\\'''");
    }

    // ── Windows cmd /C wrapping (build_command_with_options) ─────────────────

    /// On Windows, a program that already ends with `.cmd` must be wrapped as
    /// `cmd /C <program>.cmd <args...>`.
    #[test]
    #[cfg(windows)]
    fn windows_cmd_extension_wraps_with_cmd_c() {
        // We cannot reach build_command_with_options directly from a test file
        // because it is private. These tests live inside the module so they can.
        use crate::transport::SpawnOptions;

        // Temporarily override the builder to return a command with a `.cmd` suffix.
        // We can verify this through build_for_test.
        // ClaudeCode's inner program is "claude" — on Windows it looks up "claude.cmd"
        // on PATH. If "claude.cmd" exists we get `cmd /C claude.cmd <args>`.
        // If not, we get the bash fallback. Either way the top-level program must be
        // either "cmd" (with /C) or "bash".
        let opts = SpawnOptions {
            prompt: "test".to_string(),
            ..Default::default()
        };
        let cmd = PipeProcess::build_for_test(CliTool::ClaudeCode, &opts);
        let prog = cmd.get_program().to_string_lossy();

        // On Windows we always get either "cmd" or "bash" as the outer wrapper.
        assert!(
            prog == "cmd" || prog == "bash",
            "On Windows, outer program must be 'cmd' or 'bash', got '{}'",
            prog
        );
    }

    /// shell_quote produces a string that, when used in `bash -c`, passes the
    /// original tokens correctly — validated by checking that the quoted forms
    /// can round-trip through a simple concatenation rule.
    #[test]
    fn shell_quote_space_prompt_has_no_unquoted_space() {
        let q = PipeProcess::shell_quote("hello world");
        // The result must not contain an unquoted space (every space is inside quotes).
        // In our single-quote scheme, the result starts and ends with `'`.
        assert!(q.starts_with('\''), "quoted string must start with single-quote");
        assert!(q.ends_with('\''), "quoted string must end with single-quote");
    }

    /// Empty-string produces `''` — the shell empty-string literal.
    #[test]
    fn shell_quote_empty_is_shell_empty_literal() {
        assert_eq!(PipeProcess::shell_quote(""), "''");
    }

    // ── Non-Windows: build_for_test returns the inner command directly ────────

    #[test]
    #[cfg(not(windows))]
    fn unix_build_command_returns_inner_command_directly() {
        use crate::transport::SpawnOptions;

        let opts = SpawnOptions {
            prompt: "hello".to_string(),
            ..Default::default()
        };
        let cmd = PipeProcess::build_for_test(CliTool::ClaudeCode, &opts);
        // On Unix, no wrapping — program is the bare CLI name.
        assert_eq!(
            cmd.get_program().to_str().unwrap(),
            "claude",
            "On Unix, build_command returns the inner command with no wrapping"
        );
    }

    #[test]
    #[cfg(not(windows))]
    fn unix_gemini_build_command_is_bare_gemini() {
        use crate::transport::SpawnOptions;

        let opts = SpawnOptions {
            prompt: "test".to_string(),
            ..Default::default()
        };
        let cmd = PipeProcess::build_for_test(CliTool::Gemini, &opts);
        assert_eq!(cmd.get_program().to_str().unwrap(), "gemini");
    }

    #[test]
    #[cfg(not(windows))]
    fn unix_codex_build_command_is_bare_codex() {
        use crate::transport::SpawnOptions;

        let opts = SpawnOptions {
            prompt: "test".to_string(),
            ..Default::default()
        };
        let cmd = PipeProcess::build_for_test(CliTool::Codex, &opts);
        assert_eq!(cmd.get_program().to_str().unwrap(), "codex");
    }
}