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::Scope;
12use crate::nonce::NonceStore;
13use crate::output_limit::OutputLimitConfig;
14use crate::scheduler::{JobManager, PipeReader, PipeWriter, StderrStream};
15use crate::tools::ToolRegistry;
16use crate::vfs::VfsRouter;
17
18use super::traits::ToolSchema;
19
20/// Output context determines how command output should be formatted.
21///
22/// Different contexts prefer different output formats:
23/// - **Interactive** — Pretty columns, colors, traditional tree (TTY/REPL)
24/// - **Piped** — Raw output for pipeline processing
25/// - **Model** — Token-efficient compact formats (MCP server / agent context)
26/// - **Script** — Non-interactive script execution
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub enum OutputContext {
29    /// Interactive TTY/REPL - use human-friendly format with colors.
30    #[default]
31    Interactive,
32    /// Output to another command - use raw output for pipes.
33    Piped,
34    /// MCP server / agent context - use token-efficient model format.
35    Model,
36    /// Non-interactive script - use raw output.
37    Script,
38}
39
40/// Execution context passed to tools.
41///
42/// Provides access to the backend (for file operations and tool dispatch),
43/// scope, and other kernel state.
44pub struct ExecContext {
45    /// Kernel backend for I/O operations.
46    ///
47    /// This is the preferred way to access filesystem operations.
48    /// Use `backend.read()`, `backend.write()`, etc.
49    pub backend: Arc<dyn KernelBackend>,
50    /// Variable scope.
51    pub scope: Scope,
52    /// Current working directory (VFS path).
53    pub cwd: PathBuf,
54    /// Previous working directory (for `cd -`).
55    pub prev_cwd: Option<PathBuf>,
56    /// Standard input for the tool (from pipeline).
57    pub stdin: Option<String>,
58    /// Structured data from pipeline (pre-parsed JSON from previous command).
59    /// Tools can check this before parsing stdin to avoid redundant JSON parsing.
60    pub stdin_data: Option<Value>,
61    /// Streaming pipe input (set when this command is in a concurrent pipeline).
62    pub pipe_stdin: Option<PipeReader>,
63    /// Streaming pipe output (set when this command is in a concurrent pipeline).
64    pub pipe_stdout: Option<PipeWriter>,
65    /// Tool schemas for help command.
66    pub tool_schemas: Vec<ToolSchema>,
67    /// Tool registry reference (for tools that need to inspect available tools).
68    pub tools: Option<Arc<ToolRegistry>>,
69    /// Job manager for background jobs (optional).
70    pub job_manager: Option<Arc<JobManager>>,
71    /// Kernel stderr stream for real-time error output from pipeline stages.
72    ///
73    /// When set, pipeline stages write stderr here instead of buffering in
74    /// `ExecResult.err`. This allows stderr from all stages to stream to
75    /// the terminal (or other sink) concurrently, matching bash behavior.
76    pub stderr: Option<StderrStream>,
77    /// Position of this command within a pipeline (for stdio decisions).
78    pub pipeline_position: PipelinePosition,
79    /// Whether we're running in interactive (REPL) mode.
80    pub interactive: bool,
81    /// Command aliases (name → expansion string).
82    pub aliases: HashMap<String, String>,
83    /// Ignore file configuration for file-walking tools.
84    pub ignore_config: IgnoreConfig,
85    /// Output size limit configuration for agent safety.
86    pub output_limit: OutputLimitConfig,
87    /// Whether external command execution is allowed.
88    ///
89    /// When `false`, external commands (PATH lookup, `exec`, `spawn`) are blocked.
90    /// Only kaish builtins and backend-registered tools (MCP) are available.
91    pub allow_external_commands: bool,
92    /// Confirmation nonce store for latch-gated operations.
93    ///
94    /// Arc-shared across pipeline stages so nonces issued in one stage
95    /// can be validated in another.
96    pub nonce_store: NonceStore,
97    /// Terminal state for job control (interactive mode, Unix only).
98    #[cfg(unix)]
99    pub terminal_state: Option<std::sync::Arc<crate::terminal::TerminalState>>,
100}
101
102impl ExecContext {
103    /// Create a new execution context with a VFS (uses LocalBackend without tools).
104    ///
105    /// This constructor is for backward compatibility and tests that don't need tool dispatch.
106    /// For full tool support, use `with_vfs_and_tools`.
107    pub fn new(vfs: Arc<VfsRouter>) -> Self {
108        Self {
109            backend: Arc::new(LocalBackend::new(vfs)),
110            scope: Scope::new(),
111            cwd: PathBuf::from("/"),
112            prev_cwd: None,
113            stdin: None,
114            stdin_data: None,
115            pipe_stdin: None,
116            pipe_stdout: None,
117            stderr: None,
118            tool_schemas: Vec::new(),
119            tools: None,
120            job_manager: None,
121            pipeline_position: PipelinePosition::Only,
122            interactive: false,
123            aliases: HashMap::new(),
124            ignore_config: IgnoreConfig::none(),
125            output_limit: OutputLimitConfig::none(),
126            allow_external_commands: true,
127            nonce_store: NonceStore::new(),
128            #[cfg(unix)]
129            terminal_state: None,
130        }
131    }
132
133    /// Create a new execution context with VFS and tool registry.
134    ///
135    /// This is the preferred constructor for full kaish operation where
136    /// tools need to be dispatched through the backend.
137    pub fn with_vfs_and_tools(vfs: Arc<VfsRouter>, tools: Arc<ToolRegistry>) -> Self {
138        Self {
139            backend: Arc::new(LocalBackend::with_tools(vfs, tools.clone())),
140            scope: Scope::new(),
141            cwd: PathBuf::from("/"),
142            prev_cwd: None,
143            stdin: None,
144            stdin_data: None,
145            pipe_stdin: None,
146            pipe_stdout: None,
147            stderr: None,
148            tool_schemas: Vec::new(),
149            tools: Some(tools),
150            job_manager: None,
151            pipeline_position: PipelinePosition::Only,
152            interactive: false,
153            aliases: HashMap::new(),
154            ignore_config: IgnoreConfig::none(),
155            output_limit: OutputLimitConfig::none(),
156            allow_external_commands: true,
157            nonce_store: NonceStore::new(),
158            #[cfg(unix)]
159            terminal_state: None,
160        }
161    }
162
163    /// Create a new execution context with a custom backend.
164    pub fn with_backend(backend: Arc<dyn KernelBackend>) -> Self {
165        Self {
166            backend,
167            scope: Scope::new(),
168            cwd: PathBuf::from("/"),
169            prev_cwd: None,
170            stdin: None,
171            stdin_data: None,
172            pipe_stdin: None,
173            pipe_stdout: None,
174            stderr: None,
175            tool_schemas: Vec::new(),
176            tools: None,
177            job_manager: None,
178            pipeline_position: PipelinePosition::Only,
179            interactive: false,
180            aliases: HashMap::new(),
181            ignore_config: IgnoreConfig::none(),
182            output_limit: OutputLimitConfig::none(),
183            allow_external_commands: true,
184            nonce_store: NonceStore::new(),
185            #[cfg(unix)]
186            terminal_state: None,
187        }
188    }
189
190    /// Create a context with VFS, tools, and a specific scope.
191    pub fn with_vfs_tools_and_scope(vfs: Arc<VfsRouter>, tools: Arc<ToolRegistry>, scope: Scope) -> Self {
192        Self {
193            backend: Arc::new(LocalBackend::with_tools(vfs, tools.clone())),
194            scope,
195            cwd: PathBuf::from("/"),
196            prev_cwd: None,
197            stdin: None,
198            stdin_data: None,
199            pipe_stdin: None,
200            pipe_stdout: None,
201            stderr: None,
202            tool_schemas: Vec::new(),
203            tools: Some(tools),
204            job_manager: None,
205            pipeline_position: PipelinePosition::Only,
206            interactive: false,
207            aliases: HashMap::new(),
208            ignore_config: IgnoreConfig::none(),
209            output_limit: OutputLimitConfig::none(),
210            allow_external_commands: true,
211            nonce_store: NonceStore::new(),
212            #[cfg(unix)]
213            terminal_state: None,
214        }
215    }
216
217    /// Create a context with a specific scope (uses LocalBackend without tools).
218    ///
219    /// For tests that don't need tool dispatch. For full tool support,
220    /// use `with_vfs_tools_and_scope`.
221    pub fn with_scope(vfs: Arc<VfsRouter>, scope: Scope) -> Self {
222        Self {
223            backend: Arc::new(LocalBackend::new(vfs)),
224            scope,
225            cwd: PathBuf::from("/"),
226            prev_cwd: None,
227            stdin: None,
228            stdin_data: None,
229            pipe_stdin: None,
230            pipe_stdout: None,
231            stderr: None,
232            tool_schemas: Vec::new(),
233            tools: None,
234            job_manager: None,
235            pipeline_position: PipelinePosition::Only,
236            interactive: false,
237            aliases: HashMap::new(),
238            ignore_config: IgnoreConfig::none(),
239            output_limit: OutputLimitConfig::none(),
240            allow_external_commands: true,
241            nonce_store: NonceStore::new(),
242            #[cfg(unix)]
243            terminal_state: None,
244        }
245    }
246
247    /// Create a context with a custom backend and scope.
248    pub fn with_backend_and_scope(backend: Arc<dyn KernelBackend>, scope: Scope) -> Self {
249        Self {
250            backend,
251            scope,
252            cwd: PathBuf::from("/"),
253            prev_cwd: None,
254            stdin: None,
255            stdin_data: None,
256            pipe_stdin: None,
257            pipe_stdout: None,
258            stderr: None,
259            tool_schemas: Vec::new(),
260            tools: None,
261            job_manager: None,
262            pipeline_position: PipelinePosition::Only,
263            interactive: false,
264            aliases: HashMap::new(),
265            ignore_config: IgnoreConfig::none(),
266            output_limit: OutputLimitConfig::none(),
267            allow_external_commands: true,
268            nonce_store: NonceStore::new(),
269            #[cfg(unix)]
270            terminal_state: None,
271        }
272    }
273
274    /// Set the available tool schemas (for help command).
275    pub fn set_tool_schemas(&mut self, schemas: Vec<ToolSchema>) {
276        self.tool_schemas = schemas;
277    }
278
279    /// Set the tool registry reference.
280    pub fn set_tools(&mut self, tools: Arc<ToolRegistry>) {
281        self.tools = Some(tools);
282    }
283
284    /// Set the job manager for background job tracking.
285    pub fn set_job_manager(&mut self, manager: Arc<JobManager>) {
286        self.job_manager = Some(manager);
287    }
288
289    /// Set stdin for this execution.
290    pub fn set_stdin(&mut self, stdin: String) {
291        self.stdin = Some(stdin);
292    }
293
294    /// Get stdin, consuming it.
295    pub fn take_stdin(&mut self) -> Option<String> {
296        self.stdin.take()
297    }
298
299    /// Set both text stdin and structured data.
300    ///
301    /// Use this when passing output through a pipeline where the previous
302    /// command produced structured data (e.g., JSON from MCP tools).
303    pub fn set_stdin_with_data(&mut self, text: String, data: Option<Value>) {
304        self.stdin = Some(text);
305        self.stdin_data = data;
306    }
307
308    /// Take structured data if available, consuming it.
309    ///
310    /// Tools can use this to avoid re-parsing JSON that was already parsed
311    /// by a previous command in the pipeline.
312    pub fn take_stdin_data(&mut self) -> Option<Value> {
313        self.stdin_data.take()
314    }
315
316    /// Resolve a path relative to cwd, normalizing `.` and `..` components.
317    pub fn resolve_path(&self, path: &str) -> PathBuf {
318        let raw = if path.starts_with('/') {
319            PathBuf::from(path)
320        } else {
321            self.cwd.join(path)
322        };
323        normalize_path(&raw)
324    }
325
326    /// Change the current working directory.
327    ///
328    /// Saves the old directory for `cd -` support.
329    pub fn set_cwd(&mut self, path: PathBuf) {
330        self.prev_cwd = Some(self.cwd.clone());
331        self.cwd = path;
332    }
333
334    /// Get the previous working directory (for `cd -`).
335    pub fn get_prev_cwd(&self) -> Option<&PathBuf> {
336        self.prev_cwd.as_ref()
337    }
338
339    /// Read all stdin (pipe or buffered string) into a String.
340    ///
341    /// Prefers pipe_stdin if set (streaming pipeline), otherwise falls back
342    /// to the buffered stdin string. Consumes the source.
343    pub async fn read_stdin_to_string(&mut self) -> Option<String> {
344        if let Some(mut reader) = self.pipe_stdin.take() {
345            use tokio::io::AsyncReadExt;
346            let mut buf = Vec::new();
347            reader.read_to_end(&mut buf).await.ok()?;
348            Some(String::from_utf8_lossy(&buf).into_owned())
349        } else {
350            self.stdin.take()
351        }
352    }
353
354    /// Create a child context for a pipeline stage.
355    ///
356    /// Shares backend, tools, job_manager, aliases, cwd, and scope
357    /// but has independent stdin/stdout pipes.
358    pub fn child_for_pipeline(&self) -> Self {
359        Self {
360            backend: self.backend.clone(),
361            scope: self.scope.clone(),
362            cwd: self.cwd.clone(),
363            prev_cwd: self.prev_cwd.clone(),
364            stdin: None,
365            stdin_data: None,
366            pipe_stdin: None,
367            pipe_stdout: None,
368            stderr: self.stderr.clone(),
369            tool_schemas: self.tool_schemas.clone(),
370            tools: self.tools.clone(),
371            job_manager: self.job_manager.clone(),
372            pipeline_position: PipelinePosition::Only,
373            interactive: self.interactive,
374            aliases: self.aliases.clone(),
375            ignore_config: self.ignore_config.clone(),
376            output_limit: self.output_limit.clone(),
377            allow_external_commands: self.allow_external_commands,
378            nonce_store: self.nonce_store.clone(),
379            #[cfg(unix)]
380            terminal_state: self.terminal_state.clone(),
381        }
382    }
383
384    /// Build an `IgnoreFilter` from the current ignore configuration.
385    ///
386    /// Returns `None` if no filtering is configured.
387    pub async fn build_ignore_filter(&self, root: &std::path::Path) -> Option<crate::walker::IgnoreFilter> {
388        use crate::backend_walker_fs::BackendWalkerFs;
389        let fs = BackendWalkerFs(self.backend.as_ref());
390        self.ignore_config.build_filter(root, &fs).await
391    }
392
393    /// Expand a glob pattern to matching file paths.
394    ///
395    /// Returns the matched paths (absolute). Used by builtins that accept glob
396    /// patterns in their path arguments (ls, cat, head, tail, wc, etc.).
397    pub async fn expand_glob(&self, pattern: &str) -> Result<Vec<PathBuf>, String> {
398        use crate::backend_walker_fs::BackendWalkerFs;
399        use crate::walker::{EntryTypes, FileWalker, GlobPath, WalkOptions};
400
401        let glob = GlobPath::new(pattern).map_err(|e| format!("invalid pattern: {}", e))?;
402
403        let root = if glob.is_anchored() {
404            self.resolve_path("/")
405        } else {
406            self.resolve_path(".")
407        };
408
409        let options = WalkOptions {
410            entry_types: EntryTypes::all(),
411            respect_gitignore: self.ignore_config.auto_gitignore(),
412            ..WalkOptions::default()
413        };
414
415        let fs = BackendWalkerFs(self.backend.as_ref());
416        let mut walker = FileWalker::new(&fs, &root)
417            .with_pattern(glob)
418            .with_options(options);
419
420        // Note: if ignore_files contains ".gitignore" AND auto_gitignore is true,
421        // the root .gitignore is loaded twice (once here, once by the walker).
422        // This is harmless — merge is additive and rules are idempotent.
423        if let Some(filter) = self.ignore_config.build_filter(&root, &fs).await {
424            walker = walker.with_ignore(filter);
425        }
426
427        walker.collect().await.map_err(|e| e.to_string())
428    }
429}
430
431/// Normalize a path by resolving `.` and `..` components lexically (no filesystem access).
432fn normalize_path(path: &std::path::Path) -> PathBuf {
433    let mut parts: Vec<Component> = Vec::new();
434    for component in path.components() {
435        match component {
436            Component::CurDir => {} // skip `.`
437            Component::ParentDir => {
438                // Pop the last normal component, but don't pop past root
439                if let Some(Component::Normal(_)) = parts.last() {
440                    parts.pop();
441                } else {
442                    parts.push(component);
443                }
444            }
445            _ => parts.push(component),
446        }
447    }
448    if parts.is_empty() {
449        PathBuf::from("/")
450    } else {
451        parts.iter().collect()
452    }
453}