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