Skip to main content

kaish_kernel/tools/
context.rs

1//! Execution context for tools.
2
3use std::collections::HashMap;
4use std::path::{Component, PathBuf};
5use std::sync::Arc;
6
7use crate::ast::Value;
8use crate::backend::{KernelBackend, LocalBackend};
9use crate::dispatch::PipelinePosition;
10use crate::ignore_config::IgnoreConfig;
11use crate::interpreter::{ExecResult, Scope};
12use crate::nonce::NonceStore;
13use crate::output_limit::OutputLimitConfig;
14use crate::scheduler::{JobManager, PipeReader, PipeWriter, StderrStream};
15use crate::tools::ToolRegistry;
16use crate::trash::TrashBackend;
17use crate::vfs::VfsRouter;
18use tokio_util::sync::CancellationToken;
19
20use crate::interpreter::OutputFormat;
21
22use super::traits::ToolSchema;
23
24/// Output context determines how command output should be formatted.
25///
26/// Different contexts prefer different output formats:
27/// - **Interactive** — Pretty columns, colors, traditional tree (TTY/REPL)
28/// - **Piped** — Raw output for pipeline processing
29/// - **Model** — Token-efficient compact formats (MCP server / agent context)
30/// - **Script** — Non-interactive script execution
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32pub enum OutputContext {
33    /// Interactive TTY/REPL - use human-friendly format with colors.
34    #[default]
35    Interactive,
36    /// Output to another command - use raw output for pipes.
37    Piped,
38    /// MCP server / agent context - use token-efficient model format.
39    Model,
40    /// Non-interactive script - use raw output.
41    Script,
42}
43
44/// Execution context passed to tools.
45///
46/// Provides access to the backend (for file operations and tool dispatch),
47/// scope, and other kernel state.
48pub struct ExecContext {
49    /// Kernel backend for I/O operations.
50    ///
51    /// This is the preferred way to access filesystem operations.
52    /// Use `backend.read()`, `backend.write()`, etc.
53    pub backend: Arc<dyn KernelBackend>,
54    /// Variable scope.
55    pub scope: Scope,
56    /// Current working directory (VFS path).
57    pub cwd: PathBuf,
58    /// Previous working directory (for `cd -`).
59    pub prev_cwd: Option<PathBuf>,
60    /// Standard input for the tool (from pipeline).
61    pub stdin: Option<String>,
62    /// Structured data from pipeline (pre-parsed JSON from previous command).
63    /// Tools can check this before parsing stdin to avoid redundant JSON parsing.
64    pub stdin_data: Option<Value>,
65    /// Streaming pipe input (set when this command is in a concurrent pipeline).
66    pub pipe_stdin: Option<PipeReader>,
67    /// Streaming pipe output (set when this command is in a concurrent pipeline).
68    pub pipe_stdout: Option<PipeWriter>,
69    /// Tool schemas for help command.
70    pub tool_schemas: Vec<ToolSchema>,
71    /// Tool registry reference (for tools that need to inspect available tools).
72    pub tools: Option<Arc<ToolRegistry>>,
73    /// Job manager for background jobs (optional).
74    pub job_manager: Option<Arc<JobManager>>,
75    /// Kernel stderr stream for real-time error output from pipeline stages.
76    ///
77    /// When set, pipeline stages write stderr here instead of buffering in
78    /// `ExecResult.err`. This allows stderr from all stages to stream to
79    /// the terminal (or other sink) concurrently, matching bash behavior.
80    pub stderr: Option<StderrStream>,
81    /// Position of this command within a pipeline (for stdio decisions).
82    pub pipeline_position: PipelinePosition,
83    /// Whether we're running in interactive (REPL) mode.
84    pub interactive: bool,
85    /// Command aliases (name → expansion string).
86    pub aliases: HashMap<String, String>,
87    /// Ignore file configuration for file-walking tools.
88    pub ignore_config: IgnoreConfig,
89    /// Output size limit configuration for agent safety.
90    pub output_limit: OutputLimitConfig,
91    /// Whether external command execution is allowed.
92    ///
93    /// When `false`, external commands (PATH lookup, `exec`, `spawn`) are blocked.
94    /// Only kaish builtins and backend-registered tools (MCP) are available.
95    pub allow_external_commands: bool,
96    /// Confirmation nonce store for latch-gated operations.
97    ///
98    /// Arc-shared across pipeline stages so nonces issued in one stage
99    /// can be validated in another.
100    pub nonce_store: NonceStore,
101    /// Trash backend for safe file deletion.
102    ///
103    /// Always present when the kernel creates the context (even if `set -o trash`
104    /// is off — the backend exists so `kaish-trash list/restore/empty` work
105    /// regardless of the trash flag).
106    pub trash_backend: Option<Arc<dyn TrashBackend>>,
107    /// Terminal state for job control (interactive mode, Unix only).
108    #[cfg(all(unix, feature = "subprocess"))]
109    pub terminal_state: Option<std::sync::Arc<crate::terminal::TerminalState>>,
110    /// Command dispatcher for re-dispatching through the full resolution chain.
111    ///
112    /// When set (via `Kernel::into_arc()`), builtins like `timeout` can dispatch
113    /// inner commands through the full chain (user tools → builtins → .kai scripts
114    /// → external commands) instead of being limited to `backend.call_tool()`.
115    ///
116    /// `None` when the Kernel was not wrapped via `into_arc()`.
117    pub dispatcher: Option<Arc<dyn crate::dispatch::CommandDispatcher>>,
118    /// Cancellation token for this execution path.
119    ///
120    /// Populated by the kernel at execute entry, then propagated through pipeline
121    /// stages, foreground forks (scatter workers, concurrent pipeline stages,
122    /// `$(...)` cmdsubs), and into spawned external children. When the token
123    /// fires, externals receive SIGTERM/SIGKILL via the `wait_or_kill` helper.
124    ///
125    /// Default for stand-alone `ExecContext` constructors is a fresh, never-fired
126    /// token so non-kernel test contexts behave as before.
127    pub cancel: CancellationToken,
128    /// Per-execution output format override set by a builtin's GlobalFlags
129    /// flatten (e.g. `--json`). The dispatcher reads this after `tool.execute()`
130    /// returns and applies the format via `apply_output_format`.
131    ///
132    /// Builtins set this via `GlobalFlags::apply(ctx)`; external commands
133    /// don't touch it.
134    pub output_format: Option<OutputFormat>,
135}
136
137impl ExecContext {
138    /// Create a new execution context with a VFS (uses LocalBackend without tools).
139    ///
140    /// This constructor is for backward compatibility and tests that don't need tool dispatch.
141    /// For full tool support, use `with_vfs_and_tools`.
142    pub fn new(vfs: Arc<VfsRouter>) -> Self {
143        Self {
144            backend: Arc::new(LocalBackend::new(vfs)),
145            scope: Scope::new(),
146            cwd: PathBuf::from("/"),
147            prev_cwd: None,
148            stdin: None,
149            stdin_data: None,
150            pipe_stdin: None,
151            pipe_stdout: None,
152            stderr: None,
153            tool_schemas: Vec::new(),
154            tools: None,
155            job_manager: None,
156            pipeline_position: PipelinePosition::Only,
157            interactive: false,
158            aliases: HashMap::new(),
159            ignore_config: IgnoreConfig::none(),
160            output_limit: OutputLimitConfig::none(),
161            allow_external_commands: true,
162            nonce_store: NonceStore::new(),
163            trash_backend: None,
164            #[cfg(all(unix, feature = "subprocess"))]
165            terminal_state: None,
166            dispatcher: None,
167            cancel: CancellationToken::new(),
168            output_format: None,
169        }
170    }
171
172    /// Create a new execution context with VFS and tool registry.
173    ///
174    /// This is the preferred constructor for full kaish operation where
175    /// tools need to be dispatched through the backend.
176    pub fn with_vfs_and_tools(vfs: Arc<VfsRouter>, tools: Arc<ToolRegistry>) -> Self {
177        Self {
178            backend: Arc::new(LocalBackend::with_tools(vfs, tools.clone())),
179            scope: Scope::new(),
180            cwd: PathBuf::from("/"),
181            prev_cwd: None,
182            stdin: None,
183            stdin_data: None,
184            pipe_stdin: None,
185            pipe_stdout: None,
186            stderr: None,
187            tool_schemas: Vec::new(),
188            tools: Some(tools),
189            job_manager: None,
190            pipeline_position: PipelinePosition::Only,
191            interactive: false,
192            aliases: HashMap::new(),
193            ignore_config: IgnoreConfig::none(),
194            output_limit: OutputLimitConfig::none(),
195            allow_external_commands: true,
196            nonce_store: NonceStore::new(),
197            trash_backend: None,
198            #[cfg(all(unix, feature = "subprocess"))]
199            terminal_state: None,
200            dispatcher: None,
201            cancel: CancellationToken::new(),
202            output_format: None,
203        }
204    }
205
206    /// Create a new execution context with a custom backend.
207    pub fn with_backend(backend: Arc<dyn KernelBackend>) -> Self {
208        Self {
209            backend,
210            scope: Scope::new(),
211            cwd: PathBuf::from("/"),
212            prev_cwd: None,
213            stdin: None,
214            stdin_data: None,
215            pipe_stdin: None,
216            pipe_stdout: None,
217            stderr: None,
218            tool_schemas: Vec::new(),
219            tools: None,
220            job_manager: None,
221            pipeline_position: PipelinePosition::Only,
222            interactive: false,
223            aliases: HashMap::new(),
224            ignore_config: IgnoreConfig::none(),
225            output_limit: OutputLimitConfig::none(),
226            allow_external_commands: true,
227            nonce_store: NonceStore::new(),
228            trash_backend: None,
229            #[cfg(all(unix, feature = "subprocess"))]
230            terminal_state: None,
231            dispatcher: None,
232            cancel: CancellationToken::new(),
233            output_format: None,
234        }
235    }
236
237    /// Create a context with VFS, tools, and a specific scope.
238    pub fn with_vfs_tools_and_scope(vfs: Arc<VfsRouter>, tools: Arc<ToolRegistry>, scope: Scope) -> Self {
239        Self {
240            backend: Arc::new(LocalBackend::with_tools(vfs, tools.clone())),
241            scope,
242            cwd: PathBuf::from("/"),
243            prev_cwd: None,
244            stdin: None,
245            stdin_data: None,
246            pipe_stdin: None,
247            pipe_stdout: None,
248            stderr: None,
249            tool_schemas: Vec::new(),
250            tools: Some(tools),
251            job_manager: None,
252            pipeline_position: PipelinePosition::Only,
253            interactive: false,
254            aliases: HashMap::new(),
255            ignore_config: IgnoreConfig::none(),
256            output_limit: OutputLimitConfig::none(),
257            allow_external_commands: true,
258            nonce_store: NonceStore::new(),
259            trash_backend: None,
260            #[cfg(all(unix, feature = "subprocess"))]
261            terminal_state: None,
262            dispatcher: None,
263            cancel: CancellationToken::new(),
264            output_format: None,
265        }
266    }
267
268    /// Create a context with a specific scope (uses LocalBackend without tools).
269    ///
270    /// For tests that don't need tool dispatch. For full tool support,
271    /// use `with_vfs_tools_and_scope`.
272    pub fn with_scope(vfs: Arc<VfsRouter>, scope: Scope) -> Self {
273        Self {
274            backend: Arc::new(LocalBackend::new(vfs)),
275            scope,
276            cwd: PathBuf::from("/"),
277            prev_cwd: None,
278            stdin: None,
279            stdin_data: None,
280            pipe_stdin: None,
281            pipe_stdout: None,
282            stderr: None,
283            tool_schemas: Vec::new(),
284            tools: None,
285            job_manager: None,
286            pipeline_position: PipelinePosition::Only,
287            interactive: false,
288            aliases: HashMap::new(),
289            ignore_config: IgnoreConfig::none(),
290            output_limit: OutputLimitConfig::none(),
291            allow_external_commands: true,
292            nonce_store: NonceStore::new(),
293            trash_backend: None,
294            #[cfg(all(unix, feature = "subprocess"))]
295            terminal_state: None,
296            dispatcher: None,
297            cancel: CancellationToken::new(),
298            output_format: None,
299        }
300    }
301
302    /// Create a context with a custom backend and scope.
303    pub fn with_backend_and_scope(backend: Arc<dyn KernelBackend>, scope: Scope) -> Self {
304        Self {
305            backend,
306            scope,
307            cwd: PathBuf::from("/"),
308            prev_cwd: None,
309            stdin: None,
310            stdin_data: None,
311            pipe_stdin: None,
312            pipe_stdout: None,
313            stderr: None,
314            tool_schemas: Vec::new(),
315            tools: None,
316            job_manager: None,
317            pipeline_position: PipelinePosition::Only,
318            interactive: false,
319            aliases: HashMap::new(),
320            ignore_config: IgnoreConfig::none(),
321            output_limit: OutputLimitConfig::none(),
322            allow_external_commands: true,
323            nonce_store: NonceStore::new(),
324            trash_backend: None,
325            #[cfg(all(unix, feature = "subprocess"))]
326            terminal_state: None,
327            dispatcher: None,
328            cancel: CancellationToken::new(),
329            output_format: None,
330        }
331    }
332
333    /// Set the available tool schemas (for help command).
334    pub fn set_tool_schemas(&mut self, schemas: Vec<ToolSchema>) {
335        self.tool_schemas = schemas;
336    }
337
338    /// Set the tool registry reference.
339    pub fn set_tools(&mut self, tools: Arc<ToolRegistry>) {
340        self.tools = Some(tools);
341    }
342
343    /// Set the job manager for background job tracking.
344    pub fn set_job_manager(&mut self, manager: Arc<JobManager>) {
345        self.job_manager = Some(manager);
346    }
347
348    /// Set the trash backend.
349    pub fn set_trash_backend(&mut self, backend: Arc<dyn TrashBackend>) {
350        self.trash_backend = Some(backend);
351    }
352
353    /// Set stdin for this execution.
354    pub fn set_stdin(&mut self, stdin: String) {
355        self.stdin = Some(stdin);
356    }
357
358    /// Get stdin, consuming it.
359    pub fn take_stdin(&mut self) -> Option<String> {
360        self.stdin.take()
361    }
362
363    /// Set both text stdin and structured data.
364    ///
365    /// Use this when passing output through a pipeline where the previous
366    /// command produced structured data (e.g., JSON from MCP tools).
367    pub fn set_stdin_with_data(&mut self, text: String, data: Option<Value>) {
368        self.stdin = Some(text);
369        self.stdin_data = data;
370    }
371
372    /// Take structured data if available, consuming it.
373    ///
374    /// Tools can use this to avoid re-parsing JSON that was already parsed
375    /// by a previous command in the pipeline.
376    pub fn take_stdin_data(&mut self) -> Option<Value> {
377        self.stdin_data.take()
378    }
379
380    /// Resolve a path relative to cwd, normalizing `.` and `..` components.
381    pub fn resolve_path(&self, path: &str) -> PathBuf {
382        let raw = if path.starts_with('/') {
383            PathBuf::from(path)
384        } else {
385            self.cwd.join(path)
386        };
387        normalize_path(&raw)
388    }
389
390    /// Change the current working directory.
391    ///
392    /// Saves the old directory for `cd -` support.
393    pub fn set_cwd(&mut self, path: PathBuf) {
394        self.prev_cwd = Some(self.cwd.clone());
395        self.cwd = path;
396    }
397
398    /// Get the previous working directory (for `cd -`).
399    pub fn get_prev_cwd(&self) -> Option<&PathBuf> {
400        self.prev_cwd.as_ref()
401    }
402
403    /// Read all stdin (pipe or buffered string) into a String.
404    ///
405    /// Prefers pipe_stdin if set (streaming pipeline), otherwise falls back
406    /// to the buffered stdin string. Consumes the source.
407    pub async fn read_stdin_to_string(&mut self) -> Option<String> {
408        if let Some(mut reader) = self.pipe_stdin.take() {
409            use tokio::io::AsyncReadExt;
410            let mut buf = Vec::new();
411            reader.read_to_end(&mut buf).await.ok()?;
412            Some(String::from_utf8_lossy(&buf).into_owned())
413        } else {
414            self.stdin.take()
415        }
416    }
417
418    /// Create a child context for a pipeline stage.
419    ///
420    /// Shares backend, tools, job_manager, aliases, cwd, and scope
421    /// but has independent stdin/stdout pipes.
422    pub fn child_for_pipeline(&self) -> Self {
423        Self {
424            backend: self.backend.clone(),
425            scope: self.scope.clone(),
426            cwd: self.cwd.clone(),
427            prev_cwd: self.prev_cwd.clone(),
428            stdin: None,
429            stdin_data: None,
430            pipe_stdin: None,
431            pipe_stdout: None,
432            stderr: self.stderr.clone(),
433            tool_schemas: self.tool_schemas.clone(),
434            tools: self.tools.clone(),
435            job_manager: self.job_manager.clone(),
436            pipeline_position: PipelinePosition::Only,
437            interactive: self.interactive,
438            aliases: self.aliases.clone(),
439            ignore_config: self.ignore_config.clone(),
440            output_limit: self.output_limit.clone(),
441            allow_external_commands: self.allow_external_commands,
442            nonce_store: self.nonce_store.clone(),
443            trash_backend: self.trash_backend.clone(),
444            #[cfg(all(unix, feature = "subprocess"))]
445            terminal_state: self.terminal_state.clone(),
446            dispatcher: self.dispatcher.clone(),
447            cancel: self.cancel.clone(),
448            // Output format is per-execution; child pipeline stages start fresh.
449            output_format: None,
450        }
451    }
452
453    /// Build an `IgnoreFilter` from the current ignore configuration.
454    ///
455    /// Returns `None` if no filtering is configured.
456    pub async fn build_ignore_filter(&self, root: &std::path::Path) -> Option<crate::walker::IgnoreFilter> {
457        use crate::backend_walker_fs::BackendWalkerFs;
458        let fs = BackendWalkerFs(self.backend.as_ref());
459        self.ignore_config.build_filter(root, &fs).await
460    }
461
462    /// Validate a confirmation nonce against a command and paths.
463    ///
464    /// Thin wrapper on `NonceStore::validate` for ergonomic use from builtins.
465    pub fn verify_nonce(&self, nonce: &str, command: &str, paths: &[&str]) -> Result<(), String> {
466        self.nonce_store.validate(nonce, command, paths)
467    }
468
469    /// Issue a nonce and build the standard exit-2 latch result.
470    ///
471    /// `reason` explains why confirmation is needed (e.g., `"latch enabled"`,
472    /// `"emptying trash is destructive"`). The `confirm_hint` closure receives
473    /// the nonce string so each tool can format its own re-run command.
474    ///
475    /// The result includes structured data in `.data` for programmatic access:
476    /// ```json
477    /// {"nonce": "a3f7b2c1", "command": "rm", "paths": [...], "hint": "rm --confirm=a3f7b2c1 file", "ttl": 60}
478    /// ```
479    pub fn latch_result(
480        &self,
481        command: &str,
482        paths: &[&str],
483        reason: &str,
484        confirm_hint: impl FnOnce(&str) -> String,
485    ) -> ExecResult {
486        let nonce = self.nonce_store.issue(command, paths);
487        let ttl = self.nonce_store.ttl().as_secs();
488        let authorized = if paths.is_empty() {
489            String::new()
490        } else {
491            format!("\nAuthorized: {}", paths.join(", "))
492        };
493        let hint = confirm_hint(&nonce);
494
495        let mut result = ExecResult::failure(2, format!(
496            "{command}: confirmation required ({reason}){authorized}\nTo confirm, run: {hint}\nNonce expires in {ttl} seconds."
497        ));
498        result.data = Some(Value::Json(serde_json::json!({
499            "nonce": nonce,
500            "command": command,
501            "paths": paths,
502            "hint": hint,
503            "ttl": ttl,
504        })));
505        result
506    }
507
508    /// Expand a glob pattern to matching file paths.
509    ///
510    /// Returns the matched paths (absolute). Used by builtins that accept glob
511    /// patterns in their path arguments (ls, cat, head, tail, wc, etc.).
512    pub async fn expand_glob(&self, pattern: &str) -> Result<Vec<PathBuf>, String> {
513        use crate::backend_walker_fs::BackendWalkerFs;
514        use crate::walker::{EntryTypes, FileWalker, GlobPath, WalkOptions};
515
516        let glob = GlobPath::new(pattern).map_err(|e| format!("invalid pattern: {}", e))?;
517
518        let root = if glob.is_anchored() {
519            self.resolve_path("/")
520        } else {
521            self.resolve_path(".")
522        };
523
524        let options = WalkOptions {
525            entry_types: EntryTypes::all(),
526            respect_gitignore: self.ignore_config.auto_gitignore(),
527            ..WalkOptions::default()
528        };
529
530        let fs = BackendWalkerFs(self.backend.as_ref());
531        let mut walker = FileWalker::new(&fs, &root)
532            .with_pattern(glob)
533            .with_options(options);
534
535        // Note: if ignore_files contains ".gitignore" AND auto_gitignore is true,
536        // the root .gitignore is loaded twice (once here, once by the walker).
537        // This is harmless — merge is additive and rules are idempotent.
538        if let Some(filter) = self.ignore_config.build_filter(&root, &fs).await {
539            walker = walker.with_ignore(filter);
540        }
541
542        walker.collect().await.map_err(|e| e.to_string())
543    }
544
545    /// Expand positional arguments, resolving glob patterns to relative paths.
546    ///
547    /// Used by file-processing builtins (cat, head, tail, wc) that accept
548    /// glob patterns in their path arguments. Non-string values are converted
549    /// to strings (matching shell conventions).
550    pub async fn expand_paths(&self, positional: &[Value]) -> Result<Vec<String>, String> {
551        let mut paths = Vec::new();
552        for arg in positional {
553            let s = match arg {
554                Value::String(s) => s.clone(),
555                Value::Int(n) => n.to_string(),
556                Value::Float(f) => f.to_string(),
557                _ => continue,
558            };
559            if crate::glob::contains_glob(&s) {
560                let expanded = self.expand_glob(&s).await?;
561                let root = self.resolve_path(".");
562                for p in expanded {
563                    let rel = p.strip_prefix(&root).unwrap_or(&p);
564                    paths.push(rel.to_string_lossy().to_string());
565                }
566            } else {
567                paths.push(s);
568            }
569        }
570        Ok(paths)
571    }
572}
573
574/// The kernel's full execution context satisfies the trimmed portable
575/// [`ToolCtx`](kaish_tool_api::ToolCtx) contract that out-of-tree tools see.
576///
577/// Trusted in-tree builtins recover the concrete `ExecContext` (job control,
578/// pipes, dispatcher) through [`ToolCtx::as_any_mut`].
579impl kaish_tool_api::ToolCtx for ExecContext {
580    fn backend(&self) -> &Arc<dyn KernelBackend> {
581        &self.backend
582    }
583
584    fn cwd(&self) -> &std::path::Path {
585        self.cwd.as_path()
586    }
587
588    fn resolve_path(&self, path: &str) -> PathBuf {
589        // Inherent methods shadow trait methods in call syntax, so the
590        // fully-qualified inherent call here is not recursive.
591        ExecContext::resolve_path(self, path)
592    }
593
594    fn var(&self, name: &str) -> Option<Value> {
595        self.scope.get(name).cloned()
596    }
597
598    fn set_var(&mut self, name: &str, value: Value) {
599        self.scope.set(name, value);
600    }
601
602    fn set_output_format(&mut self, format: OutputFormat) {
603        self.output_format = Some(format);
604    }
605
606    fn as_any(&self) -> &dyn std::any::Any {
607        self
608    }
609
610    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
611        self
612    }
613}
614
615/// Normalize a path by resolving `.` and `..` components lexically (no filesystem access).
616fn normalize_path(path: &std::path::Path) -> PathBuf {
617    let mut parts: Vec<Component> = Vec::new();
618    for component in path.components() {
619        match component {
620            Component::CurDir => {} // skip `.`
621            Component::ParentDir => {
622                // Pop the last normal component, but don't pop past root
623                if let Some(Component::Normal(_)) = parts.last() {
624                    parts.pop();
625                } else {
626                    parts.push(component);
627                }
628            }
629            _ => parts.push(component),
630        }
631    }
632    if parts.is_empty() {
633        PathBuf::from("/")
634    } else {
635        parts.iter().collect()
636    }
637}