Skip to main content

lucy/
app.rs

1use std::io::{self, BufRead, IsTerminal, Write};
2use std::path::{Path, PathBuf};
3
4use serde::Deserialize;
5use serde_json::{Map, Value};
6
7use crate::config::{Config, DEFAULT_API_KEY_ENV};
8use crate::context::resolve_boot_context_with_api_key_env;
9use crate::model::{ChatMessage, ChatToolCall};
10use crate::protocol::{EventSink, ProtocolEvent, ProtocolWriter};
11use crate::provider::{Provider, ProviderTurn};
12use crate::redaction::{
13    conflicts_with_protected_literal, conflicts_with_tui_literal, is_structural_key,
14    redact_secret, redaction_marker,
15};
16use crate::session::Session;
17
18#[derive(Debug)]
19struct CliOptions {
20    session: Option<String>,
21    list_sessions: bool,
22    jsonl: bool,
23    tui: bool,
24}
25
26#[derive(Debug, Deserialize)]
27struct InputRecord {
28    #[serde(rename = "type")]
29    record_type: String,
30    text: Option<String>,
31}
32
33const MAX_TOOL_ROUNDS: usize = 32;
34const MAX_TOOL_CALLS_PER_MESSAGE: usize = 64;
35const USER_CANCEL_REASON: &str = "user_cancelled";
36const PROVIDER_PHASE: &str = "provider_stream";
37const COMMAND_PHASE: &str = "cmd";
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum FrontendMode {
41    Jsonl,
42    Tui,
43}
44
45pub fn run_cli<R, W, E>(args: &[String], input: R, output: W, diagnostics: E) -> i32
46where
47    R: BufRead,
48    W: Write,
49    E: Write,
50{
51    let home = match home_directory() {
52        Ok(home) => home,
53        Err(error) => {
54            let mut diagnostics = diagnostics;
55            write_diagnostic(&mut diagnostics, &error);
56            return 1;
57        }
58    };
59    let cwd = match std::env::current_dir() {
60        Ok(cwd) => cwd,
61        Err(_error) => {
62            let mut diagnostics = diagnostics;
63            write_diagnostic(&mut diagnostics, "unable to resolve cwd");
64            return 1;
65        }
66    };
67    run_cli_at_home_with_terminals(
68        args,
69        input,
70        output,
71        diagnostics,
72        &home,
73        &cwd,
74        io::stdin().is_terminal(),
75        io::stdout().is_terminal(),
76    )
77}
78
79pub fn run_cli_at_home<R, W, E>(
80    args: &[String],
81    input: R,
82    output: W,
83    diagnostics: E,
84    home: &Path,
85    cwd: &Path,
86) -> i32
87where
88    R: BufRead,
89    W: Write,
90    E: Write,
91{
92    // The generic test/library entry point has no terminal handles. The real
93    // binary uses run_cli, which supplies the actual stdio terminal state.
94    run_cli_at_home_with_terminals(args, input, output, diagnostics, home, cwd, false, false)
95}
96
97#[allow(clippy::too_many_arguments)]
98fn run_cli_at_home_with_terminals<R, W, E>(
99    args: &[String],
100    input: R,
101    output: W,
102    mut diagnostics: E,
103    home: &Path,
104    cwd: &Path,
105    stdin_is_tty: bool,
106    stdout_is_tty: bool,
107) -> i32
108where
109    R: BufRead,
110    W: Write,
111    E: Write,
112{
113    let options = match parse_args(args) {
114        Ok(options) => options,
115        Err(error) => {
116            let mut diagnostics = diagnostics;
117            write_diagnostic(&mut diagnostics, &error);
118            return 2;
119        }
120    };
121    let mode = match resolve_mode(args, stdin_is_tty, stdout_is_tty) {
122        Ok(mode) => mode,
123        Err(error) => {
124            write_diagnostic(&mut diagnostics, &error);
125            return 2;
126        }
127    };
128
129    if options.list_sessions {
130        let mut protocol = ProtocolWriter::new(output);
131        if let Err(error) = Config::ensure_exists(home) {
132            write_diagnostic(&mut diagnostics, &error.to_string());
133            return 1;
134        }
135        return match Session::list(home) {
136            Ok(sessions) => {
137                for session in sessions {
138                    if let Err(error) = protocol.emit_serializable(&session) {
139                        write_diagnostic(
140                            &mut diagnostics,
141                            &format!("unable to write session metadata: {error}"),
142                        );
143                        return 1;
144                    }
145                }
146                0
147            }
148            Err(error) => {
149                write_diagnostic(&mut diagnostics, &error.to_string());
150                1
151            }
152        };
153    }
154
155    let (session, provider, resumed) = if let Some(id) = options.session.as_deref() {
156        let session = match Session::resume(home, id) {
157            Ok(session) => session,
158            Err(error) => {
159                write_diagnostic(&mut diagnostics, &error.to_string());
160                return 1;
161            }
162        };
163        let provider = match Provider::new(&session.llm) {
164            Ok(provider) => provider,
165            Err(error) => {
166                write_diagnostic(&mut diagnostics, &error.to_string());
167                return 1;
168            }
169        };
170        if mode == FrontendMode::Tui && conflicts_with_tui_literal(provider.api_key()) {
171            write_diagnostic_safe(
172                &mut diagnostics,
173                "API key conflicts with terminal UI literals",
174                Some(provider.api_key()),
175            );
176            return 1;
177        }
178        (session, provider, true)
179    } else {
180        let config = match Config::load_or_create(home) {
181            Ok(config) => config,
182            Err(error) => {
183                write_diagnostic(&mut diagnostics, &error.to_string());
184                return 1;
185            }
186        };
187        let configured_secret = configured_api_key(&config);
188        let api_key_env = configured_api_key_env(&config);
189        let llm = match config.resolved_llm() {
190            Ok(llm) => llm,
191            Err(error) => {
192                write_diagnostic_safe(
193                    &mut diagnostics,
194                    &error.to_string(),
195                    configured_secret.as_deref(),
196                );
197                return 1;
198            }
199        };
200        let provider = match Provider::new(&llm) {
201            Ok(provider) => provider,
202            Err(error) => {
203                write_diagnostic_safe(
204                    &mut diagnostics,
205                    &error.to_string(),
206                    configured_secret.as_deref(),
207                );
208                return 1;
209            }
210        };
211        if mode == FrontendMode::Tui && conflicts_with_tui_literal(provider.api_key()) {
212            write_diagnostic_safe(
213                &mut diagnostics,
214                "API key conflicts with terminal UI literals",
215                Some(provider.api_key()),
216            );
217            return 1;
218        }
219        let safe_cwd = match std::fs::canonicalize(cwd) {
220            Ok(cwd) if !cwd.display().to_string().contains(provider.api_key()) => cwd,
221            Ok(_) => {
222                write_diagnostic_safe(
223                    &mut diagnostics,
224                    "session header rejected",
225                    Some(provider.api_key()),
226                );
227                return 1;
228            }
229            Err(_) => {
230                write_diagnostic_safe(
231                    &mut diagnostics,
232                    "unable to resolve session cwd",
233                    Some(provider.api_key()),
234                );
235                return 1;
236            }
237        };
238        let context = match resolve_boot_context_with_api_key_env(
239            home,
240            &safe_cwd,
241            &config.system_prompt,
242            api_key_env.as_deref(),
243        ) {
244            Ok(context) => context,
245            Err(error) => {
246                write_diagnostic_safe(
247                    &mut diagnostics,
248                    &error.to_string(),
249                    configured_secret.as_deref(),
250                );
251                return 1;
252            }
253        };
254        let boot_system_prompt = redact_secret(&context.system_prompt, Some(provider.api_key()));
255        let session = match Session::create_with_secret(
256            home,
257            &safe_cwd,
258            boot_system_prompt,
259            llm,
260            Some(provider.api_key()),
261        ) {
262            Ok(session) => session,
263            Err(error) => {
264                write_diagnostic_safe(
265                    &mut diagnostics,
266                    &error.to_string(),
267                    Some(provider.api_key()),
268                );
269                return 1;
270            }
271        };
272        (session, provider, false)
273    };
274
275    let harness = Harness { session, provider };
276    if mode == FrontendMode::Tui {
277        return match crate::tui::run(harness, resumed, output) {
278            Ok(()) => 0,
279            Err(error) => {
280                write_diagnostic(&mut diagnostics, &error);
281                1
282            }
283        };
284    }
285
286    let mut protocol = ProtocolWriter::new(output);
287    let mut harness = harness;
288    if let Err(error) = protocol.session(&harness.session.id, resumed) {
289        write_diagnostic_safe(
290            &mut diagnostics,
291            &format!("unable to write session event: {error}"),
292            Some(harness.provider.api_key()),
293        );
294        return 1;
295    }
296
297    for line in input.lines() {
298        let line = match line {
299            Ok(line) => line,
300            Err(error) => {
301                write_diagnostic_safe(
302                    &mut diagnostics,
303                    &format!("unable to read stdin: {error}"),
304                    Some(harness.provider.api_key()),
305                );
306                return 1;
307            }
308        };
309        if line.trim().is_empty() {
310            continue;
311        }
312        let text = match parse_input_message(&line) {
313            Ok(text) => text,
314            Err(error) => {
315                let error = redact_secret(&error, Some(harness.provider.api_key()));
316                if let Err(write_error) = protocol.error(&error) {
317                    write_diagnostic_safe(
318                        &mut diagnostics,
319                        &format!("unable to write protocol error: {write_error}"),
320                        Some(harness.provider.api_key()),
321                    );
322                    return 1;
323                }
324                continue;
325            }
326        };
327        if let Err(error) = harness.handle_message(&text, &mut protocol, None) {
328            let error = redact_secret(&error, Some(harness.provider.api_key()));
329            if let Err(write_error) = protocol.error(&error) {
330                write_diagnostic_safe(
331                    &mut diagnostics,
332                    &format!("unable to write protocol error: {write_error}"),
333                    Some(harness.provider.api_key()),
334                );
335                return 1;
336            }
337        }
338    }
339
340    0
341}
342
343pub fn resolve_mode(
344    args: &[String],
345    stdin_is_tty: bool,
346    stdout_is_tty: bool,
347) -> Result<FrontendMode, String> {
348    let options = parse_args(args)?;
349    if options.list_sessions {
350        if options.tui {
351            return Err("--tui cannot be combined with --list-sessions".to_owned());
352        }
353        return Ok(FrontendMode::Jsonl);
354    }
355    if options.tui && !(stdin_is_tty && stdout_is_tty) {
356        return Err("--tui requires a terminal on stdin and stdout".to_owned());
357    }
358    if options.tui {
359        Ok(FrontendMode::Tui)
360    } else if options.jsonl || !(stdin_is_tty && stdout_is_tty) {
361        Ok(FrontendMode::Jsonl)
362    } else {
363        Ok(FrontendMode::Tui)
364    }
365}
366
367pub(crate) struct Harness {
368    pub(crate) session: Session,
369    pub(crate) provider: Provider,
370}
371
372impl Harness {
373    pub(crate) fn handle_message<S: EventSink>(
374        &mut self,
375        text: &str,
376        sink: &mut S,
377        cancellation: Option<&crate::cancellation::CancellationToken>,
378    ) -> Result<(), String> {
379        let secret = self.provider.api_key().to_owned();
380        let user_message = ChatMessage::user(redact_secret(text, Some(&secret)));
381        if let Err(error) = self.session.append_message(user_message) {
382            if cancellation.is_some_and(|token| token.is_cancelled()) {
383                let interruption = self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
384                return interruption
385                    .map_err(|interrupt_error| format!("{error}; {interrupt_error}"));
386            }
387            return Err(error.to_string());
388        }
389
390        let mut tool_rounds = 0;
391        let mut tool_calls: usize = 0;
392        loop {
393            if cancellation.is_some_and(|token| token.is_cancelled()) {
394                return self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
395            }
396            if tool_rounds >= MAX_TOOL_ROUNDS {
397                return Err(format!(
398                    "tool loop exceeded maximum of {MAX_TOOL_ROUNDS} provider rounds"
399                ));
400            }
401            let messages = self.session.provider_messages();
402            let mut raw_content = String::new();
403            let mut redactor = SecretRedactor::new(&secret);
404            let stream_result = {
405                let mut on_text = |delta: &str| {
406                    raw_content.push_str(delta);
407                    redactor.push(delta, |safe_delta| {
408                        sink.emit_event(&ProtocolEvent::AssistantDelta {
409                            text: safe_delta.to_owned(),
410                        })
411                    })
412                };
413                match cancellation {
414                    Some(token) => {
415                        self.provider
416                            .stream_chat_cancellable(&messages, &mut on_text, token)
417                    }
418                    None => self.provider.stream_chat(&messages, &mut on_text),
419                }
420            };
421            redactor
422                .finish(|safe_delta| {
423                    sink.emit_event(&ProtocolEvent::AssistantDelta {
424                        text: safe_delta.to_owned(),
425                    })
426                })
427                .map_err(|error| format!("unable to write assistant delta: {error}"))?;
428            let turn = match stream_result {
429                Ok(turn) => turn,
430                Err(error)
431                    if cancellation.is_some_and(|token| token.is_cancelled())
432                        || error.is_cancelled() =>
433                {
434                    let partial = error.partial_turn().cloned().unwrap_or(ProviderTurn {
435                        content: raw_content,
436                        tool_calls: Vec::new(),
437                        reasoning_details: Vec::new(),
438                    });
439                    return self.interrupt(
440                        sink,
441                        PROVIDER_PHASE,
442                        &partial.content,
443                        &partial.tool_calls,
444                        Vec::new(),
445                    );
446                }
447                Err(error) => return Err(error.to_string()),
448            };
449            let canceled_after_stream = cancellation.is_some_and(|token| token.is_cancelled());
450
451            if turn.tool_calls.iter().any(|call| call.name != "cmd") {
452                if canceled_after_stream {
453                    return self.interrupt(sink, PROVIDER_PHASE, &turn.content, &[], Vec::new());
454                }
455                return Err("provider requested an unsupported tool".to_owned());
456            }
457            if tool_calls.saturating_add(turn.tool_calls.len()) > MAX_TOOL_CALLS_PER_MESSAGE {
458                if canceled_after_stream {
459                    return self.interrupt(sink, PROVIDER_PHASE, &turn.content, &[], Vec::new());
460                }
461                return Err(format!(
462                    "tool call budget exceeded maximum of {MAX_TOOL_CALLS_PER_MESSAGE} calls per input message"
463                ));
464            }
465
466            let safe_tool_calls = turn
467                .tool_calls
468                .iter()
469                .map(|call| ChatToolCall {
470                    id: redact_secret(&call.id, Some(&secret)),
471                    name: redact_secret(&call.name, Some(&secret)),
472                    arguments: redact_tool_arguments(&call.arguments, &secret),
473                })
474                .collect::<Vec<_>>();
475            let assistant_content = redact_secret(&turn.content, Some(&secret));
476            let safe_reasoning_details = redact_reasoning_details(&turn.reasoning_details, &secret);
477            let mut assistant =
478                ChatMessage::assistant(assistant_content.clone(), safe_tool_calls.clone());
479            assistant.reasoning_details = safe_reasoning_details;
480            if let Err(error) = self.session.append_message(assistant) {
481                if cancellation.is_some_and(|token| token.is_cancelled()) {
482                    let interruption = self.interrupt(
483                        sink,
484                        PROVIDER_PHASE,
485                        &assistant_content,
486                        &turn.tool_calls,
487                        Vec::new(),
488                    );
489                    return interruption
490                        .map_err(|interrupt_error| format!("{error}; {interrupt_error}"));
491                }
492                return Err(error.to_string());
493            }
494
495            if safe_tool_calls.is_empty() {
496                if canceled_after_stream || cancellation.is_some_and(|token| !token.try_complete())
497                {
498                    return self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
499                }
500                sink.emit_event(&ProtocolEvent::TurnEnd)
501                    .map_err(|error| format!("unable to write turn end: {error}"))?;
502                return Ok(());
503            }
504
505            tool_rounds += 1;
506            tool_calls += safe_tool_calls.len();
507            for safe_call in &safe_tool_calls {
508                sink.emit_event(&ProtocolEvent::ToolCall {
509                    id: safe_call.id.clone(),
510                    name: safe_call.name.clone(),
511                    arguments: safe_call.arguments.clone(),
512                })
513                .map_err(|error| format!("unable to write tool call: {error}"))?;
514            }
515            for index in 0..turn.tool_calls.len() {
516                let raw_call = &turn.tool_calls[index];
517                let safe_call = &safe_tool_calls[index];
518                let result = if cancellation.is_some_and(|token| token.is_cancelled()) {
519                    crate::command::canceled_result(&safe_call.arguments, &secret)
520                } else {
521                    crate::command::execute_with_cancellation(
522                        &raw_call.arguments,
523                        &self.session.cwd,
524                        self.provider.api_key_env(),
525                        Some(&secret),
526                        cancellation,
527                    )
528                };
529                let result = redact_json_value(
530                    serde_json::to_value(result)
531                        .map_err(|error| format!("unable to encode cmd result: {error}"))?,
532                    &secret,
533                );
534                let tool_content = serde_json::to_string(&result)
535                    .map_err(|error| format!("unable to encode tool result: {error}"))?;
536                let tool_message = ChatMessage::tool(
537                    safe_call.id.clone(),
538                    safe_call.name.clone(),
539                    redact_secret(&tool_content, Some(&secret)),
540                );
541                let observation = crate::session::SessionToolResult {
542                    id: safe_call.id.clone(),
543                    name: safe_call.name.clone(),
544                    result: result.clone(),
545                };
546                if let Err(error) = self.session.append_message(tool_message) {
547                    if cancellation.is_some_and(|token| token.is_cancelled()) {
548                        let interruption =
549                            self.interrupt(sink, COMMAND_PHASE, "", &[], vec![observation]);
550                        return interruption
551                            .map_err(|interrupt_error| format!("{error}; {interrupt_error}"));
552                    }
553                    return Err(error.to_string());
554                }
555                sink.emit_event(&ProtocolEvent::ToolResult {
556                    id: safe_call.id.clone(),
557                    name: safe_call.name.clone(),
558                    result: result.clone(),
559                })
560                .map_err(|error| format!("unable to write tool result: {error}"))?;
561                if cancellation.is_some_and(|token| token.is_cancelled()) {
562                    for pending_call in safe_tool_calls.iter().skip(index + 1) {
563                        let pending_result = redact_json_value(
564                            serde_json::to_value(crate::command::canceled_result(
565                                &pending_call.arguments,
566                                &secret,
567                            ))
568                            .map_err(|error| format!("unable to encode cmd result: {error}"))?,
569                            &secret,
570                        );
571                        let pending_content = serde_json::to_string(&pending_result)
572                            .map_err(|error| format!("unable to encode tool result: {error}"))?;
573                        let pending_message = ChatMessage::tool(
574                            pending_call.id.clone(),
575                            pending_call.name.clone(),
576                            redact_secret(&pending_content, Some(&secret)),
577                        );
578                        let pending_observation = crate::session::SessionToolResult {
579                            id: pending_call.id.clone(),
580                            name: pending_call.name.clone(),
581                            result: pending_result.clone(),
582                        };
583                        if let Err(error) = self.session.append_message(pending_message) {
584                            if cancellation.is_some_and(|token| token.is_cancelled()) {
585                                let interruption = self.interrupt(
586                                    sink,
587                                    COMMAND_PHASE,
588                                    "",
589                                    &[],
590                                    vec![pending_observation],
591                                );
592                                return interruption.map_err(|interrupt_error| {
593                                    format!("{error}; {interrupt_error}")
594                                });
595                            }
596                            return Err(error.to_string());
597                        }
598                        sink.emit_event(&ProtocolEvent::ToolResult {
599                            id: pending_call.id.clone(),
600                            name: pending_call.name.clone(),
601                            result: pending_result.clone(),
602                        })
603                        .map_err(|error| format!("unable to write tool result: {error}"))?;
604                    }
605                    return self.interrupt(sink, COMMAND_PHASE, "", &[], Vec::new());
606                }
607            }
608        }
609    }
610
611    fn interrupt<S: EventSink>(
612        &mut self,
613        sink: &mut S,
614        phase: &str,
615        assistant_text: &str,
616        tool_calls: &[ChatToolCall],
617        tool_results: Vec<crate::session::SessionToolResult>,
618    ) -> Result<(), String> {
619        let secret = self.provider.api_key();
620        let safe_tool_calls = tool_calls
621            .iter()
622            .filter(|call| call.name == "cmd")
623            .map(|call| safe_partial_tool_call(call, secret))
624            .collect::<Vec<_>>();
625        let safe_tool_results = tool_results.clone();
626        let interruption = crate::session::InterruptionRecord {
627            timestamp: 0,
628            reason: USER_CANCEL_REASON.to_owned(),
629            phase: phase.to_owned(),
630            assistant_text: redact_secret(assistant_text, Some(secret)),
631            tool_calls: safe_tool_calls.clone(),
632            tool_results,
633        };
634        let persistence_error = self.session.append_interruption(interruption).err();
635        let mut event_error = None;
636        for call in &safe_tool_calls {
637            if let Err(error) = sink.emit_event(&ProtocolEvent::ToolCall {
638                id: call.id.clone(),
639                name: call.name.clone(),
640                arguments: call.arguments.clone(),
641            }) {
642                event_error.get_or_insert(error);
643            }
644        }
645        for observation in &safe_tool_results {
646            if let Err(error) = sink.emit_event(&ProtocolEvent::ToolResult {
647                id: observation.id.clone(),
648                name: observation.name.clone(),
649                result: observation.result.clone(),
650            }) {
651                event_error.get_or_insert(error);
652            }
653        }
654        if let Err(error) = sink.emit_event(&ProtocolEvent::TurnInterrupted {
655            reason: USER_CANCEL_REASON.to_owned(),
656            phase: phase.to_owned(),
657        }) {
658            event_error.get_or_insert(error);
659        }
660        match (persistence_error, event_error) {
661            (None, None) => Ok(()),
662            (Some(error), None) => Err(format!("unable to persist interruption: {error}")),
663            (None, Some(error)) => Err(format!("unable to write interruption event: {error}")),
664            (Some(persistence), Some(event)) => Err(format!(
665                "unable to persist interruption: {persistence}; unable to write interruption event: {event}"
666            )),
667        }
668    }
669}
670
671struct SecretRedactor {
672    secret_text: String,
673    secret: Vec<char>,
674    marker: String,
675    pending: String,
676}
677
678impl SecretRedactor {
679    fn new(secret: &str) -> Self {
680        Self {
681            secret_text: secret.to_owned(),
682            secret: secret.chars().collect(),
683            marker: redaction_marker(secret).unwrap_or_default(),
684            pending: String::new(),
685        }
686    }
687
688    fn push<F>(&mut self, text: &str, mut emit: F) -> io::Result<()>
689    where
690        F: FnMut(&str) -> io::Result<()>,
691    {
692        if self.secret.is_empty() {
693            return emit(text);
694        }
695
696        let mut output = String::new();
697        for character in text.chars() {
698            self.pending.push(character);
699            if self.pending.chars().eq(self.secret.iter().copied()) {
700                self.pending.clear();
701                output.push_str(&self.marker);
702                continue;
703            }
704            if self.pending_is_secret_prefix() {
705                continue;
706            }
707
708            let pending = self.pending.chars().collect::<Vec<_>>();
709            let suffix_len = (1..pending.len())
710                .rev()
711                .find(|length| {
712                    pending[pending.len() - length..].iter().copied().eq(self
713                        .secret
714                        .iter()
715                        .copied()
716                        .take(*length))
717                })
718                .unwrap_or(0);
719            let safe_len = pending.len() - suffix_len;
720            output.extend(pending[..safe_len].iter());
721            self.pending = pending[safe_len..].iter().collect();
722        }
723
724        if output.is_empty() {
725            Ok(())
726        } else {
727            let safe_output = redact_secret(&output, Some(&self.secret_text));
728            emit(&safe_output)
729        }
730    }
731
732    fn finish<F>(&mut self, mut emit: F) -> io::Result<()>
733    where
734        F: FnMut(&str) -> io::Result<()>,
735    {
736        let pending = std::mem::take(&mut self.pending);
737        if pending.is_empty() {
738            return Ok(());
739        }
740        let safe_pending = redact_secret(&pending, Some(&self.secret_text));
741        emit(&safe_pending)
742    }
743
744    fn pending_is_secret_prefix(&self) -> bool {
745        let length = self.pending.chars().count();
746        length < self.secret.len()
747            && self
748                .pending
749                .chars()
750                .zip(self.secret.iter().copied())
751                .all(|(pending, secret)| pending == secret)
752    }
753}
754
755fn redact_tool_arguments(arguments: &str, secret: &str) -> String {
756    let fallback = || "{}".to_owned();
757    let Ok(value) = serde_json::from_str::<Value>(arguments) else {
758        return fallback();
759    };
760    let Some(object) = value.as_object() else {
761        return fallback();
762    };
763    if object.len() != 1 || !object.get("command").is_some_and(Value::is_string) {
764        return fallback();
765    }
766    let redacted = redact_json_value(value, secret);
767    serde_json::to_string(&redacted).unwrap_or_else(|_| fallback())
768}
769
770fn safe_partial_tool_call(call: &ChatToolCall, secret: &str) -> ChatToolCall {
771    let arguments = if serde_json::from_str::<Value>(&call.arguments)
772        .ok()
773        .and_then(|value| value.as_object().cloned())
774        .is_some_and(|object| object.len() == 1 && object.contains_key("command"))
775    {
776        redact_tool_arguments(&call.arguments, secret)
777    } else {
778        // An incomplete argument fragment is an observation only. Do not
779        // preserve malformed provider JSON: decoding it later could expose a
780        // credential that was hidden by the outer JSON string.
781        "{}".to_owned()
782    };
783    ChatToolCall {
784        id: redact_secret(&call.id, Some(secret)),
785        name: redact_secret(&call.name, Some(secret)),
786        arguments,
787    }
788}
789
790fn redact_json_value(value: Value, secret: &str) -> Value {
791    match value {
792        Value::String(text) => Value::String(redact_secret(&text, Some(secret))),
793        Value::Array(values) => Value::Array(
794            values
795                .into_iter()
796                .map(|value| redact_json_value(value, secret))
797                .collect(),
798        ),
799        Value::Object(object) => {
800            let marker = redaction_marker(secret).unwrap_or_default();
801            let mut redacted = Map::new();
802            for (key, value) in object {
803                let mut safe_key = if is_structural_key(&key) {
804                    key
805                } else {
806                    redact_secret(&key, Some(secret))
807                };
808                if redacted.contains_key(&safe_key) {
809                    if marker.is_empty() {
810                        continue;
811                    }
812                    while redacted.contains_key(&safe_key) {
813                        safe_key.push_str(&marker);
814                    }
815                }
816                redacted.insert(safe_key, redact_json_value(value, secret));
817            }
818            Value::Object(redacted)
819        }
820        value => value,
821    }
822}
823
824fn redact_reasoning_details(details: &[Value], secret: &str) -> Option<Vec<Value>> {
825    if details.is_empty() {
826        return None;
827    }
828    match redact_json_value(Value::Array(details.to_vec()), secret) {
829        Value::Array(details) => Some(details),
830        _ => None,
831    }
832}
833
834fn parse_args(args: &[String]) -> Result<CliOptions, String> {
835    let mut options = CliOptions {
836        session: None,
837        list_sessions: false,
838        jsonl: false,
839        tui: false,
840    };
841    let mut index = 0;
842    while index < args.len() {
843        match args[index].as_str() {
844            "--session" => {
845                if options.list_sessions || options.session.is_some() {
846                    return Err("--session cannot be combined or repeated".to_owned());
847                }
848                index += 1;
849                let Some(id) = args.get(index) else {
850                    return Err("--session requires an id".to_owned());
851                };
852                options.session = Some(id.clone());
853            }
854            "--list-sessions" => {
855                if options.session.is_some() || options.list_sessions {
856                    return Err("--list-sessions cannot be combined or repeated".to_owned());
857                }
858                options.list_sessions = true;
859            }
860            "--jsonl" => {
861                if options.jsonl || options.tui {
862                    return Err("--jsonl cannot be combined or repeated".to_owned());
863                }
864                options.jsonl = true;
865            }
866            "--tui" => {
867                if options.tui || options.jsonl {
868                    return Err("--tui cannot be combined or repeated".to_owned());
869                }
870                options.tui = true;
871            }
872            "--help" | "-h" => {
873                return Err(
874                    "usage: lucy [--jsonl|--tui] [--session <id>] [--list-sessions]".to_owned(),
875                );
876            }
877            _ => return Err("unknown argument".to_owned()),
878        }
879        index += 1;
880    }
881    Ok(options)
882}
883
884fn parse_input_message(line: &str) -> Result<String, String> {
885    let record: InputRecord = serde_json::from_str(line)
886        .map_err(|_| "input must be a JSONL message record".to_owned())?;
887    if record.record_type != "message" {
888        return Err("input record type must be message".to_owned());
889    }
890    record
891        .text
892        .ok_or_else(|| "message record requires a text string".to_owned())
893}
894
895fn home_directory() -> Result<PathBuf, String> {
896    std::env::var_os("HOME")
897        .map(PathBuf::from)
898        .ok_or_else(|| "HOME is not set; Lucy needs a user home directory".to_owned())
899}
900
901fn configured_api_key_env(config: &Config) -> Option<String> {
902    let api_key_env = config
903        .llm
904        .api_key_env
905        .as_deref()
906        .unwrap_or(DEFAULT_API_KEY_ENV)
907        .trim();
908    (!api_key_env.is_empty()).then(|| api_key_env.to_owned())
909}
910
911fn configured_api_key(config: &Config) -> Option<String> {
912    configured_api_key_env(config)
913        .and_then(|api_key_env| std::env::var(api_key_env).ok())
914        .filter(|secret| !secret.is_empty())
915}
916
917fn write_diagnostic_safe<W: Write>(diagnostics: &mut W, message: &str, secret: Option<&str>) {
918    write_diagnostic_safe_with_environment(
919        diagnostics,
920        message,
921        secret,
922        std::env::vars().map(|(_, value)| value),
923    );
924}
925
926fn write_diagnostic_safe_with_environment<W, I>(
927    diagnostics: &mut W,
928    message: &str,
929    secret: Option<&str>,
930    environment_values: I,
931) where
932    W: Write,
933    I: IntoIterator<Item = String>,
934{
935    let mut safe_line = format!("!: {message}");
936    safe_line = redact_secret(&safe_line, secret);
937    let mut environment_secrets = environment_values
938        .into_iter()
939        .filter(|value| !value.is_empty() && !conflicts_with_protected_literal(value))
940        .collect::<Vec<_>>();
941    environment_secrets.sort_by_key(|value| std::cmp::Reverse(value.len()));
942    for environment_secret in environment_secrets {
943        safe_line = redact_secret(&safe_line, Some(&environment_secret));
944    }
945    let _ = writeln!(diagnostics, "{safe_line}");
946}
947
948fn write_diagnostic<W: Write>(diagnostics: &mut W, message: &str) {
949    write_diagnostic_safe(diagnostics, message, None);
950}
951
952#[cfg(test)]
953mod tests {
954    use super::*;
955    use std::io::Cursor;
956
957    #[test]
958    fn parses_only_message_records() {
959        assert_eq!(
960            parse_input_message(r#"{"type":"message","text":"hello"}"#).expect("message"),
961            "hello"
962        );
963        assert!(parse_input_message(r#"{"type":"event","text":"hello"}"#).is_err());
964        assert_eq!(
965            parse_input_message(r#"{"type":"message","text":""}"#).expect("empty message"),
966            ""
967        );
968    }
969
970    #[test]
971    fn resolves_terminal_and_forced_modes() {
972        assert_eq!(
973            resolve_mode(&[], true, true).expect("default TUI"),
974            FrontendMode::Tui
975        );
976        assert_eq!(
977            resolve_mode(&[], true, false).expect("automatic JSONL"),
978            FrontendMode::Jsonl
979        );
980        assert_eq!(
981            resolve_mode(&["--jsonl".to_owned()], true, true).expect("forced JSONL"),
982            FrontendMode::Jsonl
983        );
984        assert!(resolve_mode(&["--tui".to_owned()], true, false).is_err());
985    }
986
987    #[test]
988    fn redactor_does_not_leak_a_secret_across_deltas() {
989        let mut redactor = SecretRedactor::new("secret");
990        let mut output = Vec::new();
991        redactor
992            .push("prefix sec", |text| {
993                output.push(text.to_owned());
994                Ok(())
995            })
996            .expect("push");
997        redactor
998            .push("ret suffix", |text| {
999                output.push(text.to_owned());
1000                Ok(())
1001            })
1002            .expect("push");
1003        redactor
1004            .finish(|text| {
1005                output.push(text.to_owned());
1006                Ok(())
1007            })
1008            .expect("finish");
1009        let output = output.join("");
1010        assert_eq!(
1011            output,
1012            format!("prefix {} suffix", redaction_marker("secret").unwrap())
1013        );
1014        assert!(!output.contains("secret"));
1015    }
1016
1017    #[test]
1018    fn redactor_handles_secrets_introduced_by_protocol_json_escaping() {
1019        let mut redactor = SecretRedactor::new("n0");
1020        let mut output = String::new();
1021        redactor
1022            .push("\n0", |text| {
1023                output.push_str(text);
1024                Ok(())
1025            })
1026            .expect("push");
1027        redactor
1028            .finish(|text| {
1029                output.push_str(text);
1030                Ok(())
1031            })
1032            .expect("finish");
1033        assert!(!output.contains("n0"));
1034        assert_eq!(output, redaction_marker("n0").unwrap());
1035    }
1036
1037    #[test]
1038    fn redactor_does_not_emit_a_secret_when_it_completes_at_a_delta_boundary() {
1039        let mut redactor = SecretRedactor::new("secret");
1040        let mut output = Vec::new();
1041        redactor
1042            .push("xsecre", |text| {
1043                output.push(text.to_owned());
1044                Ok(())
1045            })
1046            .expect("first delta");
1047        redactor
1048            .push("t", |text| {
1049                output.push(text.to_owned());
1050                Ok(())
1051            })
1052            .expect("second delta");
1053        redactor
1054            .finish(|text| {
1055                output.push(text.to_owned());
1056                Ok(())
1057            })
1058            .expect("finish");
1059        let output = output.join("");
1060        assert_eq!(output, format!("x{}", redaction_marker("secret").unwrap()));
1061        assert!(!output.contains("secret"));
1062    }
1063
1064    #[test]
1065    fn streaming_redaction_handles_marker_collision_keys_at_delta_boundaries() {
1066        for secret in ["REDACTED", "[REDACTED]"] {
1067            let mut redactor = SecretRedactor::new(secret);
1068            let split = secret.len() / 2;
1069            let (first, second) = secret.split_at(split);
1070            let mut output = String::new();
1071            redactor
1072                .push(first, |text| {
1073                    output.push_str(text);
1074                    Ok(())
1075                })
1076                .expect("first delta");
1077            redactor
1078                .push(second, |text| {
1079                    output.push_str(text);
1080                    Ok(())
1081                })
1082                .expect("second delta");
1083            redactor
1084                .finish(|text| {
1085                    output.push_str(text);
1086                    Ok(())
1087                })
1088                .expect("finish");
1089            assert!(!output.contains(secret));
1090            assert!(output.len() <= secret.len());
1091        }
1092    }
1093
1094    #[test]
1095    fn malformed_tool_arguments_use_a_safe_copy() {
1096        let secret = "provider-secret";
1097        let escaped = secret
1098            .chars()
1099            .map(|character| format!(r#"\u{:04x}"#, character as u32))
1100            .collect::<String>();
1101        let arguments = format!(r#"{{"command":"{escaped}""#);
1102        let safe = redact_tool_arguments(&arguments, secret);
1103        assert_eq!(safe, "{}");
1104        serde_json::from_str::<Value>(&safe).expect("safe arguments JSON");
1105        assert!(!safe.contains(secret));
1106        assert!(!safe.contains(&escaped));
1107        for invalid in ["[]", "{\"command\":1}", "{\"other\":\"value\"}"] {
1108            assert_eq!(redact_tool_arguments(invalid, secret), "{}");
1109        }
1110    }
1111
1112    #[test]
1113    fn structured_redaction_preserves_tool_and_result_schema_keys() {
1114        let secret = "provider-secret";
1115        let value = serde_json::json!({
1116            "command": "printf provider-secret",
1117            "stdout": "provider-secret",
1118            "stderr": "ordinary",
1119            "exit_code": 0,
1120            "timed_out": false,
1121            "stdout_truncated": false,
1122            "stderr_truncated": false,
1123            "unknown-provider-secret": "provider-secret"
1124        });
1125        let redacted = redact_json_value(value, secret);
1126        for key in [
1127            "command",
1128            "stdout",
1129            "stderr",
1130            "exit_code",
1131            "timed_out",
1132            "stdout_truncated",
1133            "stderr_truncated",
1134        ] {
1135            assert!(redacted.get(key).is_some(), "missing schema key: {key}");
1136        }
1137        let encoded = serde_json::to_string(&redacted).expect("redacted JSON");
1138        assert!(!encoded.contains(secret));
1139        assert!(redacted.get("unknown-provider-secret").is_none());
1140    }
1141
1142    #[test]
1143    fn structured_redaction_preserves_typed_values_even_for_a_pathological_key() {
1144        let value = serde_json::json!({
1145            "exit_code": 0,
1146            "timed_out": false,
1147            "stdout_truncated": true,
1148            "error": null,
1149        });
1150        let redacted = redact_json_value(value, "0");
1151        assert!(redacted["exit_code"].is_number());
1152        assert!(redacted["timed_out"].is_boolean());
1153        assert!(redacted["stdout_truncated"].is_boolean());
1154        assert!(redacted["error"].is_null());
1155    }
1156
1157    #[test]
1158    fn reasoning_details_are_recursively_redacted_before_persistence() {
1159        let details = vec![serde_json::json!({
1160            "type": "reasoning.text",
1161            "text": "provider-secret",
1162            "nested": [{"value": "provider-secret"}],
1163            "provider-secret": "provider-secret"
1164        })];
1165        let redacted = redact_reasoning_details(&details, "provider-secret")
1166            .expect("non-empty reasoning details");
1167        let redacted = Value::Array(redacted);
1168        let encoded = serde_json::to_string(&redacted).expect("reasoning details JSON");
1169        assert!(!encoded.contains("provider-secret"));
1170        assert_eq!(redacted[0]["type"], "reasoning.text");
1171        assert_eq!(redacted[0]["text"], "[REDACTED]");
1172        assert_eq!(redacted[0]["nested"][0]["value"], "[REDACTED]");
1173        assert!(redacted[0].get("provider-secret").is_none());
1174    }
1175
1176    #[test]
1177    fn malformed_input_error_does_not_echo_secret_bearing_input() {
1178        let error =
1179            parse_input_message(r#"{"type":"message","text":"provider-secret","unexpected":}"#)
1180                .expect_err("invalid input");
1181        assert!(!error.contains("provider-secret"));
1182    }
1183
1184    #[test]
1185    fn malformed_input_is_an_error_event_and_not_diagnostic_json() {
1186        let mut output = Vec::new();
1187        let error = parse_input_message("not json").expect_err("invalid input");
1188        let mut protocol = ProtocolWriter::new(&mut output);
1189        protocol.error(&error).expect("error event");
1190        assert_eq!(String::from_utf8_lossy(&output).lines().count(), 1);
1191        let _ = Cursor::new("");
1192    }
1193
1194    #[test]
1195    fn early_diagnostic_scrubbing_removes_short_values_from_the_complete_line() {
1196        let secret = "lucy";
1197        let mut diagnostics = Vec::new();
1198        write_diagnostic_safe_with_environment(
1199            &mut diagnostics,
1200            secret,
1201            None,
1202            vec![secret.to_owned()],
1203        );
1204        let diagnostics = String::from_utf8(diagnostics).expect("diagnostic UTF-8");
1205        assert!(!diagnostics.contains(secret));
1206    }
1207}