saya-cli 0.4.1

Database-aware AI agent for the terminal: full-screen TUI, schema discovery, and bounded read-only SQL over PostgreSQL, MySQL, SQLite, DuckDB, and Snowflake.
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
//! The `/run` child: one nested `saya run` invocation, streamed unmangled.
//!
//! The session's `/run <tail…>` starts a headless run by spawning the real
//! `saya run` command as a child process and handing it the slash tail after
//! its small no-shell argv tokenizer. The child's own CLI parser stays the
//! authority on `--allow`, `--budget`, and the `resume`/`show`/`log`/`list`
//! subcommands — the slash adapter parses no command grammar twice, so the two
//! surfaces cannot drift.
//!
//! **The dual-tag hazard, and what this module does about it.** A nested
//! `saya` emits its own event stream — `TerminalEvent` lines tagged
//! `"event"` and `RunEvent` lines tagged `"type"` — inside the parent's
//! stdout. When the parent session is itself on a wire (`--format ndjson`),
//! that puts a complete child stream inside a parent stream. The parent must
//! neither re-tag the child's lines (wrapping them in the parent's envelope
//! would change bytes a harness has already learned to parse) nor swallow
//! them (a parent-side filter that drops what it does not recognize would
//! lose the run's record). The answer here is to pass the file descriptors
//! through untouched: the child's stdout and stderr are `Stdio::inherit`, so
//! its bytes land on the parent's real stdout byte-for-byte, unobserved by
//! the parent's renderer. The parent's own lines simply continue when the
//! child exits. The cost is deliberate: the parent cannot decorate, filter,
//! or re-render a nested run — a run's stream is the run's own.
//!
//! The child reads the same process env (config home, state database, runs
//! root, provider settings), so `/runs` in the session and the child's
//! journal describe the same runs on disk. Explicit `--config` /
//! `--connections` overrides and the session's active profile and approval
//! mode are forwarded so the child resolves what the session resolved.

use crate::config::runtime::RuntimeConfig;
use crate::interactive::session_state::SessionState;
use crate::render::RenderFormat;
use clap::Parser as _;
use std::process::{Command, Stdio};

/// The run subcommands the child's CLI recognizes after `run`. A tail whose
/// first token is one of these passes through verbatim (subcommand form);
/// anything else is the goal.
const SUBCOMMAND_WORDS: [&str; 5] = ["cancel", "resume", "show", "log", "list"];

/// The slash adapter's own word: `/run --seed-grants <tail…>` seeds the
/// child's `--allow` from this session's grants on request. The flag is the
/// tail's first token and never reaches the child; anything else passes
/// through verbatim, and the child's own parser refuses it as the unknown
/// flag it is — the adapter parses one word, the child's parser stays the
/// authority on the rest.
pub(crate) fn separate_seed_flag(tail: &str) -> (bool, &str) {
    let Some(rest) = tail.strip_prefix("--seed-grants") else {
        return (false, tail);
    };
    if !rest.is_empty() && !rest.starts_with(char::is_whitespace) {
        // A longer flag that merely starts with the word is the child's
        // business, not a seed request.
        return (false, tail);
    }
    (true, rest.trim_start())
}

/// Spawns the nested `saya run <tail…>` child, streams its output through
/// unmangled, and waits for it. The child's exit code is its own: it already
/// said why the run ended (its settle message names completed, paused, the
/// resume hint, or the failure), and the parent adds nothing to the stream —
/// any echo here would be exactly the re-tagging the module doc refuses.
/// `seed_forwarded` is the session grants a requested seed forwards: appended
/// as one more `--allow` group, the child's parser the authority on every
/// token.
pub(crate) fn spawn_run_child(
    runtime: &RuntimeConfig,
    format: RenderFormat,
    state: &SessionState,
    seed_forwarded: &[String],
    tail: &str,
) -> std::io::Result<()> {
    let exe = std::env::current_exe()?;
    let mut command = Command::new(exe);
    command
        .arg("--non-interactive")
        .arg("--format")
        .arg(format_flag(format))
        .arg("run")
        .args(
            child_argv(tail)
                .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error))?,
        );
    if !seed_forwarded.is_empty() {
        command.arg("--allow").args(seed_forwarded);
    }
    // Forward the config sources the session actually loaded, so the child
    // resolves the same profiles, provider, and secrets — a run must not
    // silently run against a different configuration than the session's.
    if let Some(config) = &runtime.config_path {
        command.arg("--config").arg(config);
    }
    if let Some(connections) = &runtime.connections_path {
        command.arg("--connections").arg(connections);
    }
    if let Some(profile) = state.profile.as_deref() {
        command.arg("--profile").arg(profile);
    }
    // Forward the session's approval mode so the child resolves what the
    // session resolved — except bypass: a run's approval is its `--allow`
    // scopes (`commands/run/start.rs`), so a bypass session's blanket
    // per-call consent never reaches the child, which takes its own default
    // (read-only). The session's bypass never claimed to reach the child;
    // the run states its own scopes.
    if let Some(mode) = forwarded_approval_mode(state.approval_mode.as_str()) {
        command.arg("--approval-mode").arg(mode);
    }
    // The child never reads stdin (a headless run prompts for nothing), and
    // it must never consume the session's remaining input lines: null stdin.
    command
        .stdin(Stdio::null())
        .stdout(Stdio::inherit())
        .stderr(Stdio::inherit());
    command.status().map(|_| ())
}

/// Shapes the child's `run` argument vector from the slash tail:
/// a subcommand tail passes through as tokenized argv (the child's parser reads
/// its id), and a goal tail rejoins its leading words into the single
/// positional the CLI declares — a goal is one string, then the flags.
fn child_argv(tail: &str) -> Result<Vec<String>, String> {
    let tokens = tokenize_tail(tail)?;
    if matches!(tokens.first(), Some(first) if SUBCOMMAND_WORDS.contains(&first.as_str())) {
        return Ok(tokens);
    }
    let boundary = tokens
        .iter()
        .position(|token| token.starts_with("--"))
        .unwrap_or(tokens.len());
    let mut argv = vec![tokens[..boundary].join(" ")];
    argv.extend(tokens[boundary..].iter().cloned());
    Ok(argv)
}

/// Splits a slash tail into argv words without invoking a shell. Quotes group
/// whitespace and backslashes escape the next character; both are removed.
/// Shell expansion and metacharacter handling do not exist here.
fn tokenize_tail(tail: &str) -> Result<Vec<String>, String> {
    let mut tokens = Vec::new();
    let mut token = String::new();
    let mut quote = None;
    let mut escaped = false;
    let mut started = false;

    for character in tail.chars() {
        if escaped {
            token.push(character);
            escaped = false;
            started = true;
            continue;
        }
        match quote {
            Some(delimiter) if character == delimiter => quote = None,
            Some(_) => token.push(character),
            None if character == '\\' => {
                escaped = true;
                started = true;
            }
            None if matches!(character, '\'' | '"') => {
                quote = Some(character);
                started = true;
            }
            None if character.is_whitespace() => {
                if started {
                    tokens.push(std::mem::take(&mut token));
                    started = false;
                }
            }
            None => {
                token.push(character);
                started = true;
            }
        }
    }
    if escaped {
        return Err("unmatched escape in /run tail".to_string());
    }
    if quote.is_some() {
        return Err("unmatched quote in /run tail".to_string());
    }
    if started {
        tokens.push(token);
    }
    Ok(tokens)
}

/// The approval mode forwarded to a nested `saya run` child: the session's
/// mode verbatim — except bypass. A run's approval is its `--allow` scopes;
/// a bypass session's blanket consent never reaches the child, which states
/// its own scopes or takes the run default (`app.rs`). `None` forwards
/// nothing.
fn forwarded_approval_mode(mode: &str) -> Option<&str> {
    (mode != "bypass").then_some(mode)
}

/// The format flag the child inherits, so a piped session's `/run` speaks the
/// same wire the session itself speaks.
fn format_flag(format: RenderFormat) -> &'static str {
    match format {
        RenderFormat::Text => "text",
        RenderFormat::Json => "json",
        RenderFormat::Ndjson => "ndjson",
    }
}

/// What a `/run <tail>` tail means once the child's own grammar has parsed
/// it. The same clap grammar `saya run` uses parses the tail in-process, so
/// the TUI's panel adapter parses nothing twice — the parser stays the
/// authority on `--allow`, `--budget`, and the subcommands.
#[derive(Debug)]
pub(crate) enum RunTail {
    /// A fresh run: goal, scopes, and budgets. The run panel drives it.
    Start {
        goal: Option<String>,
        allow: Vec<String>,
        budget: Vec<String>,
    },
    /// A management subcommand (`show`/`log`/`list`/`cancel`) — the shared
    /// dispatcher handles it, the same path `saya run` and `/runs` take.
    Manage(crate::cli::RunCommand),
    /// `resume` — the resume drive streams to the real stdout, which the TUI
    /// does not own while the alternate screen is up; a shell hosts it.
    Resume(String),
}

/// Parses a `/run <tail>` tail through the child's own grammar. The TUI
/// routes `Start` tails to the run panel and `Manage` tails through the
/// shared dispatcher; `resume` is declined (see [`RunTail::Resume`]).
pub(crate) fn parse_run_tail(tail: &str) -> Result<RunTail, String> {
    let mut argv = vec!["saya".to_string(), "--non-interactive".to_string()];
    argv.push("run".to_string());
    argv.extend(child_argv(tail)?);
    match crate::cli::Cli::try_parse_from(argv) {
        Ok(cli) => match cli.command {
            Some(crate::cli::Command::Run {
                prompt,
                allow,
                budget,
                command,
            }) => match command {
                Some(crate::cli::RunCommand::Resume { run_id }) => Ok(RunTail::Resume(run_id)),
                Some(other) => Ok(RunTail::Manage(other)),
                None => Ok(RunTail::Start {
                    goal: prompt,
                    allow,
                    budget,
                }),
            },
            _ => Err("expected a run command: /run <goal> --allow <scopes>".to_string()),
        },
        Err(error) => Err(error.to_string()),
    }
}

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

    /// A goal tail rejoins its words into the single positional the headless
    /// CLI declares, and passes the flags verbatim — the child's parser stays
    /// the authority on them.
    #[test]
    fn a_goal_tail_becomes_one_positional_then_flags() {
        assert_eq!(
            child_argv("survey the data --allow workspace-write").unwrap(),
            ["survey the data", "--allow", "workspace-write"]
        );
        assert_eq!(child_argv("one goal").unwrap(), ["one goal"]);
        // No goal words at all: an empty positional, which the child's own
        // parser refuses — the adapter does not pre-validate what the child
        // rejects.
        assert_eq!(
            child_argv("--allow workspace-write").unwrap(),
            ["", "--allow", "workspace-write"]
        );
    }

    #[test]
    fn quoted_and_escaped_tail_words_become_literal_argv() {
        assert_eq!(
            child_argv(r#""survey the data" --allow workspace-write"#).unwrap(),
            ["survey the data", "--allow", "workspace-write"]
        );
        assert_eq!(
            child_argv(r#"survey\ the\ data --allow workspace-write"#).unwrap(),
            ["survey the data", "--allow", "workspace-write"]
        );
        assert_eq!(
            child_argv(r#"echo '$(touch pwned)' --allow 'runner:echo'"#).unwrap(),
            ["echo $(touch pwned)", "--allow", "runner:echo"]
        );
        match parse_run_tail(r#""survey the data" --allow workspace-write"#) {
            Ok(RunTail::Start { goal, allow, .. }) => {
                assert_eq!(goal.as_deref(), Some("survey the data"));
                assert_eq!(allow, ["workspace-write"]);
            }
            other => panic!("quoted tail parses to Start, got {other:?}"),
        }
        let tail = match crate::slash::parse_slash_command(
            r#"/run "survey the data" --allow workspace-write"#,
        )
        .unwrap()
        {
            Some(crate::slash::SlashCommand::Run(tail)) => tail,
            other => panic!("quoted slash line parses to Run, got {other:?}"),
        };
        assert!(matches!(parse_run_tail(&tail), Ok(RunTail::Start { .. })));
    }

    #[test]
    fn unmatched_quotes_and_escapes_are_rejected_before_clap() {
        assert!(child_argv(r#""unfinished goal"#).is_err());
        assert!(child_argv("unfinished\\").is_err());
        assert!(parse_run_tail(r#""unfinished goal"#).is_err());
    }

    /// A subcommand tail passes through verbatim, word by word, so
    /// `/run resume <id>` and `/run list` reach the child exactly as typed.
    #[test]
    fn subcommand_tails_pass_through_verbatim() {
        assert_eq!(child_argv("resume r-1").unwrap(), ["resume", "r-1"]);
        assert_eq!(child_argv("list").unwrap(), ["list"]);
    }

    /// The panel path parses the tail through the same grammar the child
    /// gets: a goal tail becomes one positional plus flags, a management
    /// subcommand maps to the shared `RunCommand`, and `resume` is surfaced
    /// as its own decline (the resume drive streams to the real stdout).
    #[test]
    fn the_panel_parses_the_tail_through_the_child_grammar() {
        match parse_run_tail("survey the data --allow workspace-write") {
            Ok(RunTail::Start {
                goal,
                allow,
                budget,
            }) => {
                assert_eq!(goal.as_deref(), Some("survey the data"));
                assert_eq!(allow, vec!["workspace-write".to_string()]);
                assert!(budget.is_empty());
            }
            other => panic!("a goal tail parses to Start, got {other:?}"),
        }
        match parse_run_tail("show r-1") {
            Ok(RunTail::Manage(crate::cli::RunCommand::Show { run_id })) => {
                assert_eq!(run_id, "r-1");
            }
            other => panic!("a show tail parses to Manage, got {other:?}"),
        }
        match parse_run_tail("log r-1") {
            Ok(RunTail::Manage(crate::cli::RunCommand::Log { run_id })) => {
                assert_eq!(run_id, "r-1");
            }
            other => panic!("a log tail parses to Manage, got {other:?}"),
        }
        match parse_run_tail("resume r-1") {
            Ok(RunTail::Resume(run_id)) => assert_eq!(run_id, "r-1"),
            other => panic!("a resume tail parses to Resume, got {other:?}"),
        }
        // The parser is the authority: an unknown flag fails the way the
        // child's own parse would. (A budget *value* is validated later, by
        // the run surface — the grammar itself accepts it.)
        assert!(parse_run_tail("--nonsense").is_err());
        match parse_run_tail("--budget nonsense") {
            Ok(RunTail::Start { budget, .. }) => {
                assert_eq!(budget, vec!["nonsense".to_string()]);
            }
            other => panic!("a budget tail parses to Start, got {other:?}"),
        }
    }

    /// A bypass session's nested `saya run` child gets no `--approval-mode`
    /// flag: a run's approval is its `--allow` scopes, and a bypass session's
    /// blanket per-call consent never claimed to reach the child. Every other
    /// mode is forwarded verbatim, as before.
    #[test]
    fn a_bypass_session_s_nested_run_child_gets_no_bypass_mode() {
        assert_eq!(forwarded_approval_mode("bypass"), None);
        for (mode, expected) in [
            ("ask", Some("ask")),
            ("read-only", Some("read-only")),
            ("never", Some("never")),
        ] {
            assert_eq!(
                forwarded_approval_mode(mode),
                expected,
                "a {mode} session's child resolves what the session resolved"
            );
        }
        // The guard is the vocabulary's own word, not a prefix match: a mode
        // that merely contains "bypass" is not bypass.
        assert!(
            forwarded_approval_mode("bypassish").is_some(),
            "an unknown mode word is forwarded verbatim, never treated as bypass"
        );
    }

    /// `--seed-grants` is the slash adapter's own word: it must be the tail's
    /// first token, and it never reaches the child. A tail that merely
    /// contains it — or a longer flag spelling it — passes through verbatim,
    /// and the child's own parser refuses it as the unknown flag it is.
    #[test]
    fn the_seed_flag_is_the_slash_adapter_s_first_token_only() {
        use super::separate_seed_flag;
        let (requested, rest) = separate_seed_flag("--seed-grants survey --allow workspace-write");
        assert!(requested, "the leading flag is the adapter's request");
        assert_eq!(
            rest, "survey --allow workspace-write",
            "the tail loses only the flag"
        );
        let (requested, rest) = separate_seed_flag("--seed-grants");
        assert!(
            requested && rest.is_empty(),
            "a bare request seeds an empty tail"
        );
        let (requested, rest) = separate_seed_flag("survey --seed-grants --allow x");
        assert!(
            !requested && rest == "survey --seed-grants --allow x",
            "mid-tail, the word passes to the child verbatim — its parser is the \
             authority and refuses it"
        );
        let (requested, rest) = separate_seed_flag("--seed-grants-only survey");
        assert!(
            !requested && rest == "--seed-grants-only survey",
            "a longer flag spelling is the child's word, never the adapter's request"
        );
        let (requested, rest) = separate_seed_flag("--allow x");
        assert!(!requested && rest == "--allow x");
    }
}