Skip to main content

lash_tools/shell/
mod.rs

1//! Built-in shell tool catalog (`shell.exec` / `shell.start` /
2//! `shell.write`).
3//!
4//! This module is the *surface* layer: tool definitions, argument parsing,
5//! the [`StandardShell`] executor, prompt contributions, and the plugin
6//! factory. The process-lifecycle machinery lives in [`runtime`] and the
7//! output-buffer plumbing in [`output`].
8
9mod output;
10mod runtime;
11
12use std::path::PathBuf;
13use std::sync::Arc;
14use std::time::{Duration, Instant};
15
16use serde_json::json;
17use tokio_util::sync::CancellationToken;
18
19use lash_core::plugin::{
20    PluginError, PluginFactory, PluginSessionContext, PluginSpec, PluginSpecFactory, SessionPlugin,
21};
22use lash_core::runtime::ProcessEventSemanticsSpec;
23use lash_core::{
24    PreparedToolCall, ProcessEventType, ProcessHandleDescriptor, ProcessInput, ProcessStartRequest,
25    ProgressSender, PromptContribution, SessionScope, SessionToolAccess, ToolCall, ToolDefinition,
26    ToolProvider, ToolResult, ToolScheduling,
27};
28
29use lash_tool_support::{
30    StaticToolExecute, StaticToolProvider, object_schema, parse_optional_bool,
31    parse_optional_usize_arg, require_str,
32};
33
34use crate::shell::output::{PollOutcome, shell_io_result, timed_out_shell_io_result};
35use crate::shell::runtime::{
36    CommonCommandParams, DEFAULT_EXEC_COMMAND_TIMEOUT_MS, ExecCommandParams,
37    PipeExecProcessRequest, ShellRuntime, StartCommandParams, WaitBehavior,
38};
39
40const SHELL_STDIN_SIGNAL: &str = "stdin";
41const SHELL_STDIN_SIGNAL_EVENT: &str = "signal.stdin";
42
43pub fn shell_prompt_contributions() -> Vec<PromptContribution> {
44    shell_prompt_contributions_for_access(&SessionToolAccess::default())
45}
46
47/// Returns the shell prompt contributions, gating the `shell.write`
48/// reference on whether that tool is actually callable in the current
49/// session.
50pub fn shell_prompt_contributions_for_access(
51    access: &SessionToolAccess,
52) -> Vec<PromptContribution> {
53    let mut command_execution = String::from(
54        "Use `shell.exec` for one-shot commands; it returns only after the process exits and successful results include `status: \"completed\"`, `done: true`, and `exit_code`. Use `shell.start` only for interactive or intentionally long-lived processes; it returns a process handle that is visible to `processes.list` and cancellable with `processes.cancel`.",
55    );
56    if tool_callable_from_authority(access, "write_stdin") {
57        command_execution.push_str(" Send stdin to running shell processes with `shell.write`.");
58    }
59    command_execution.push_str(
60        " For builds, installs, tests, migrations, service setup, and verification commands, use `shell.exec` and wait for completion before concluding.",
61    );
62    vec![
63        PromptContribution::guidance("Command Execution", command_execution),
64        PromptContribution::guidance(
65            "Git Safety",
66            "Avoid destructive git commands unless explicitly requested.",
67        ),
68    ]
69}
70
71fn tool_callable_from_authority(access: &SessionToolAccess, name: &str) -> bool {
72    if access.hides(name) {
73        return false;
74    }
75    access.tools.is_empty() || access.tools.iter().any(|tool| tool.name() == name)
76}
77
78pub struct StandardShell {
79    runtime: ShellRuntime,
80}
81
82impl StandardShell {
83    pub fn new() -> Self {
84        Self {
85            runtime: ShellRuntime::new(),
86        }
87    }
88
89    pub fn with_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
90        self.runtime = self.runtime.with_cwd(cwd);
91        self
92    }
93
94    fn parse_common_command_params(
95        &self,
96        args: &serde_json::Value,
97    ) -> Result<CommonCommandParams, ToolResult> {
98        let cmd = require_str(args, "cmd")?.to_string();
99        let workdir = self.runtime.resolve_workdir(
100            args.get("workdir")
101                .and_then(|value| value.as_str())
102                .filter(|value| !value.is_empty()),
103        );
104        let shell_path = args
105            .get("shell")
106            .and_then(|value| value.as_str())
107            .filter(|value| !value.is_empty())
108            .unwrap_or(&self.runtime.shell_path)
109            .to_string();
110        let login = parse_optional_bool(args, "login", false)?;
111        let allow_nonzero_exit = parse_optional_bool(args, "allow_nonzero_exit", false)?;
112        let max_output_tokens = parse_optional_usize_arg(args, "max_output_tokens", None, true, 1)?;
113
114        Ok(CommonCommandParams {
115            cmd,
116            workdir,
117            shell_path,
118            login,
119            allow_nonzero_exit,
120            max_output_tokens,
121        })
122    }
123
124    fn parse_exec_command_params(
125        &self,
126        args: &serde_json::Value,
127    ) -> Result<ExecCommandParams, ToolResult> {
128        let common = self.parse_common_command_params(args)?;
129        let timeout_ms = parse_optional_usize_arg(args, "timeout_ms", None, false, 1)?
130            .map(|value| value as u64)
131            .unwrap_or(DEFAULT_EXEC_COMMAND_TIMEOUT_MS);
132
133        Ok(ExecCommandParams {
134            cmd: common.cmd,
135            workdir: common.workdir,
136            shell_path: common.shell_path,
137            login: common.login,
138            allow_nonzero_exit: common.allow_nonzero_exit,
139            timeout_ms,
140            max_output_tokens: common.max_output_tokens,
141        })
142    }
143
144    fn parse_start_command_params(
145        &self,
146        args: &serde_json::Value,
147    ) -> Result<StartCommandParams, ToolResult> {
148        let common = self.parse_common_command_params(args)?;
149
150        Ok(StartCommandParams {
151            cmd: common.cmd,
152            workdir: common.workdir,
153            shell_path: common.shell_path,
154            login: common.login,
155            allow_nonzero_exit: common.allow_nonzero_exit,
156            max_output_tokens: common.max_output_tokens,
157        })
158    }
159
160    async fn exec_command(
161        &self,
162        params: &ExecCommandParams,
163        progress: Option<&ProgressSender>,
164        cancel: Option<CancellationToken>,
165    ) -> ToolResult {
166        let started = Instant::now();
167        let handle_id = self.runtime.allocate_handle_id();
168
169        match self
170            .runtime
171            .exec_pipe_process(PipeExecProcessRequest {
172                id: &handle_id,
173                command: &params.cmd,
174                workdir: &params.workdir,
175                login: params.login,
176                shell_path: &params.shell_path,
177                timeout: Some(Duration::from_millis(params.timeout_ms)),
178                progress,
179                max_output_tokens: params.max_output_tokens,
180                cancel,
181            })
182            .await
183        {
184            Ok(PollOutcome::Running {
185                output,
186                original_token_count,
187                full_output_path,
188                ..
189            }) => timed_out_shell_io_result(
190                &handle_id,
191                output,
192                original_token_count,
193                full_output_path.as_deref(),
194                started.elapsed().as_secs_f64(),
195                params.timeout_ms,
196                params.allow_nonzero_exit,
197            ),
198            Ok(PollOutcome::Exited {
199                output,
200                original_token_count,
201                exit_code,
202                full_output_path,
203            }) => shell_io_result(
204                &handle_id,
205                output,
206                Some(exit_code),
207                original_token_count,
208                full_output_path.as_deref(),
209                started.elapsed().as_secs_f64(),
210                params.allow_nonzero_exit,
211            ),
212            Ok(PollOutcome::Cancelled) => ToolResult::cancelled("tool call cancelled"),
213            Err(err) => ToolResult::err(json!(err)),
214        }
215    }
216
217    async fn start_command(
218        &self,
219        params: &StartCommandParams,
220        context: &lash_core::ToolContext<'_>,
221        progress: Option<&ProgressSender>,
222        cancel: Option<CancellationToken>,
223    ) -> ToolResult {
224        if let Some(process_id) = context.async_process_id() {
225            return self
226                .run_start_command_process(process_id, params, context, progress, cancel)
227                .await;
228        }
229        self.register_start_command_process(params, context).await
230    }
231
232    async fn register_start_command_process(
233        &self,
234        params: &StartCommandParams,
235        context: &lash_core::ToolContext<'_>,
236    ) -> ToolResult {
237        let process_id = context
238            .tool_call_id()
239            .filter(|id| !id.is_empty())
240            .map(str::to_string)
241            .unwrap_or_else(|| format!("shell:{}", self.runtime.allocate_handle_id()));
242        let args = start_command_process_args(params);
243        let call = PreparedToolCall::from_parts(
244            process_id.clone(),
245            "start_command",
246            args,
247            None,
248            serde_json::Value::Null,
249        );
250        let descriptor = ProcessHandleDescriptor::new(Some("shell"), Some(params.cmd.clone()));
251        let request = ProcessStartRequest::new(
252            process_id.clone(),
253            ProcessInput::ToolCall { call },
254            lash_core::ProcessOriginator::host(),
255        )
256        .with_grant(Some(lash_core::ProcessStartGrant {
257            session_scope: SessionScope::new("request-descriptor"),
258            descriptor,
259        }))
260        .with_extra_event_types([shell_signal_event_type()]);
261        match context.processes().start(request).await {
262            Ok(summary) => {
263                let mut handle = serde_json::to_value(summary).unwrap_or_else(|_| {
264                    lash_core::lashlang_bridge::process_handle_json(&process_id)
265                });
266                if let Some(object) = handle.as_object_mut() {
267                    object.insert("status".to_string(), json!("running"));
268                    object.insert("done".to_string(), json!(false));
269                    object.insert("running".to_string(), json!(true));
270                }
271                ToolResult::ok(handle)
272            }
273            Err(err) => ToolResult::err_fmt(err.to_string()),
274        }
275    }
276
277    async fn run_start_command_process(
278        &self,
279        process_id: &str,
280        params: &StartCommandParams,
281        context: &lash_core::ToolContext<'_>,
282        progress: Option<&ProgressSender>,
283        cancel: Option<CancellationToken>,
284    ) -> ToolResult {
285        let started = Instant::now();
286        let handle_id = process_id.to_string();
287
288        if let Err(err) = self.runtime.spawn_process(
289            handle_id.clone(),
290            &params.cmd,
291            &params.workdir,
292            params.login,
293            &params.shell_path,
294        ) {
295            return ToolResult::err(json!(err));
296        }
297
298        let signal_done = CancellationToken::new();
299        let signal_forwarder =
300            self.spawn_stdin_signal_forwarder(handle_id.clone(), context, signal_done.clone());
301        match self
302            .runtime
303            .wait_until_exit_or_timeout(
304                &handle_id,
305                None,
306                progress,
307                params.max_output_tokens,
308                WaitBehavior { baseline_len: 0 },
309                cancel,
310            )
311            .await
312        {
313            Ok(PollOutcome::Running { .. }) => {
314                signal_done.cancel();
315                let _ = signal_forwarder.await;
316                self.runtime.remove_process(&handle_id);
317                ToolResult::err_fmt("background shell process returned running without a timeout")
318            }
319            Ok(PollOutcome::Exited {
320                output,
321                original_token_count,
322                exit_code,
323                full_output_path,
324            }) => {
325                signal_done.cancel();
326                let _ = signal_forwarder.await;
327                self.runtime.remove_process(&handle_id);
328                shell_io_result(
329                    &handle_id,
330                    output,
331                    Some(exit_code),
332                    original_token_count,
333                    full_output_path.as_deref(),
334                    started.elapsed().as_secs_f64(),
335                    params.allow_nonzero_exit,
336                )
337            }
338            Ok(PollOutcome::Cancelled) => {
339                signal_done.cancel();
340                let _ = signal_forwarder.await;
341                self.runtime.remove_process(&handle_id);
342                ToolResult::cancelled("tool call cancelled")
343            }
344            Err(err) => {
345                signal_done.cancel();
346                let _ = signal_forwarder.await;
347                self.runtime.remove_process(&handle_id);
348                ToolResult::err(json!(err))
349            }
350        }
351    }
352
353    fn spawn_stdin_signal_forwarder(
354        &self,
355        process_id: String,
356        context: &lash_core::ToolContext<'_>,
357        done: CancellationToken,
358    ) -> tokio::task::JoinHandle<()> {
359        let runtime = self.runtime.clone();
360        let events = context.process_events();
361        tokio::spawn(async move {
362            let mut after_sequence = 0;
363            loop {
364                let event = tokio::select! {
365                    _ = done.cancelled() => break,
366                    event = events.wait_event_after(SHELL_STDIN_SIGNAL_EVENT, after_sequence) => event,
367                };
368                let Ok(event) = event else {
369                    break;
370                };
371                after_sequence = event.sequence;
372                if let Some(chars) = event.payload.get("chars").and_then(|value| value.as_str()) {
373                    let _ = runtime.write_stdin(&process_id, chars).await;
374                }
375                if event
376                    .payload
377                    .get("close_stdin")
378                    .and_then(|value| value.as_bool())
379                    .unwrap_or(false)
380                {
381                    let _ = runtime.close_stdin(&process_id).await;
382                }
383            }
384        })
385    }
386
387    async fn write_stdin_call(
388        &self,
389        args: &serde_json::Value,
390        context: &lash_core::ToolContext<'_>,
391    ) -> ToolResult {
392        let process_id = match parse_process_id(args) {
393            Ok(value) => value,
394            Err(err) => return err,
395        };
396        let chars = args
397            .get("chars")
398            .and_then(|value| value.as_str())
399            .unwrap_or("");
400        let close_stdin = match parse_optional_bool(args, "close_stdin", false) {
401            Ok(value) => value,
402            Err(err) => return err,
403        };
404        match context
405            .processes()
406            .signal(
407                &process_id,
408                SHELL_STDIN_SIGNAL,
409                json!({
410                    "chars": chars,
411                    "close_stdin": close_stdin,
412                }),
413            )
414            .await
415        {
416            Ok(event) => ToolResult::ok(json!({
417                "process_id": process_id,
418                "status": "signalled",
419                "sequence": event.sequence,
420            })),
421            Err(err) => ToolResult::err_fmt(err.to_string()),
422        }
423    }
424}
425
426fn start_command_process_args(params: &StartCommandParams) -> serde_json::Value {
427    let mut args = serde_json::Map::new();
428    args.insert("cmd".to_string(), json!(params.cmd.clone()));
429    args.insert(
430        "workdir".to_string(),
431        json!(params.workdir.to_string_lossy().to_string()),
432    );
433    args.insert("shell".to_string(), json!(params.shell_path.clone()));
434    args.insert("login".to_string(), json!(params.login));
435    args.insert(
436        "allow_nonzero_exit".to_string(),
437        json!(params.allow_nonzero_exit),
438    );
439    if let Some(max_output_tokens) = params.max_output_tokens {
440        args.insert("max_output_tokens".to_string(), json!(max_output_tokens));
441    }
442    serde_json::Value::Object(args)
443}
444
445fn shell_signal_event_type() -> ProcessEventType {
446    ProcessEventType {
447        name: SHELL_STDIN_SIGNAL_EVENT.to_string(),
448        payload_schema: lash_core::LashSchema::any(),
449        semantics: ProcessEventSemanticsSpec::default(),
450    }
451}
452
453impl Default for StandardShell {
454    fn default() -> Self {
455        Self::new()
456    }
457}
458
459/// Build the cached shell tool provider (`shell.exec` / `shell.start`).
460pub fn shell_provider(shell: StandardShell) -> StaticToolProvider<StandardShell> {
461    let definitions = shell.tool_definitions();
462    StaticToolProvider::new(definitions, shell)
463}
464
465#[async_trait::async_trait]
466impl StaticToolExecute for StandardShell {
467    async fn execute(&self, call: ToolCall<'_>) -> ToolResult {
468        let cancellation_token = call.context.cancellation_token().cloned();
469        self.dispatch(
470            call.name,
471            call.args,
472            call.context,
473            call.progress,
474            cancellation_token,
475        )
476        .await
477    }
478}
479
480impl StandardShell {
481    fn tool_definitions(&self) -> Vec<ToolDefinition> {
482        let exec_command_description = "Run a noninteractive one-shot command with stdin closed and stdout/stderr captured, then wait for it to finish. Successful results always include `status: \"completed\"`, `done: true`, `running: false`, cleaned `output`, and `exit_code`. Commands time out after 600000 ms by default; set `timeout_ms` to override the hard timeout. Timed-out commands are killed and the result has `status: \"timed_out\"`, `timed_out: true`, and no `exit_code`; by default this fails the tool. Use `shell.start` instead for interactive, TTY-dependent, or intentionally long-lived processes. Nonzero exit codes (including SIGPIPE 141 from `cmd | head`-style pipelines) fail the tool by default. Pass `allow_nonzero_exit: true` to receive the result without failure on either nonzero exit or timeout, then inspect `exit_code` and `timed_out`. ANSI/control noise is stripped from returned output. Large or truncated output may also include `full_output_path` pointing at the saved raw stream.";
483        let start_command_description = "Start an interactive or intentionally long-lived command in a PTY as a durable background process. The result is a process handle with `__handle__: \"process\"`, `id`, `process_id`, `status: \"running\"`, `done: false`, and `running: true`; use `processes.list` to see it and `processes.cancel` to stop it. Nonzero exit codes fail the eventual process output by default; pass `allow_nonzero_exit: true` only when nonzero is expected data. Use `shell.exec` for builds, installs, tests, service setup, verification, and other commands that must complete before the next step.";
484        let command_common = |command_description: &str| {
485            json!({
486                "cmd": {
487                    "type": "string",
488                    "description": command_description
489                },
490                "workdir": {
491                    "type": "string",
492                    "description": "Optional working directory to run the command in; defaults to the turn cwd."
493                },
494                "shell": {
495                    "type": "string",
496                    "description": "Shell binary to launch. Defaults to the user's default shell."
497                },
498                "login": {
499                    "type": "boolean",
500                    "default": false,
501                    "description": "Whether to run the shell with -l semantics. Defaults to false to avoid startup prompts and shell init noise."
502                },
503                "allow_nonzero_exit": {
504                    "type": "boolean",
505                    "default": false,
506                    "description": "Shell-only flag. When true, nonzero exit codes are returned as successful tool results instead of failed tool calls; inspect `exit_code` yourself. Defaults to false."
507                },
508                "max_output_tokens": {
509                    "type": "integer",
510                    "minimum": 1,
511                    "description": "Maximum number of tokens to return. Excess output will be truncated."
512                }
513            })
514        };
515        vec![
516            ToolDefinition::raw(
517                "tool:exec_command",
518                "exec_command",
519                exec_command_description,
520                {
521                    let mut properties = command_common("Shell command to execute.");
522                    properties["timeout_ms"] = json!({
523                        "type": "integer",
524                        "minimum": 1,
525                        "default": DEFAULT_EXEC_COMMAND_TIMEOUT_MS,
526                        "description": "Hard timeout in milliseconds. If reached before the command exits, the process is killed and the result has `status: \"timed_out\"` and `timed_out: true`. By default this fails the tool; pass `allow_nonzero_exit: true` to receive the timed-out result without failure. Defaults to 600000 ms."
527                    });
528                    object_schema(properties, &["cmd"])
529                },
530                shell_exec_output_schema(),
531            )
532            .with_examples(vec![
533                r#"await shell.exec({ cmd: "cargo test -p lash-protocol-rlm", timeout_ms: 600000 })?"#.into(),
534                r#"await shell.exec({ cmd: "test -f Cargo.lock", allow_nonzero_exit: true })?"#.into(),
535            ])
536            .with_lashlang_binding(lash_tool_support::lashlang_binding(
537                ["shell"],
538                "exec",
539                &["shell", "bash"],
540            ))
541            .with_scheduling(ToolScheduling::Serial),
542            ToolDefinition::raw(
543                "tool:start_command",
544                "start_command",
545                start_command_description,
546                object_schema(command_common("Shell command to start."), &["cmd"]),
547                shell_start_output_schema(),
548            )
549            .with_examples(vec![
550                r#"await shell.start({ cmd: "python -m http.server 8000" })?"#.into(),
551            ])
552            .with_lashlang_binding(lash_tool_support::lashlang_binding(
553                ["shell"],
554                "start",
555                &["long_running_command", "pty"],
556            ))
557            .with_scheduling(ToolScheduling::Serial),
558            ToolDefinition::raw(
559                "tool:write_stdin",
560                "write_stdin",
561                "Send bytes to stdin for a running shell process started by `shell.start`. Use `close_stdin: true` to send EOF. This only acknowledges delivery of the signal; use process lifecycle tools to inspect or cancel the background process.",
562                object_schema(
563                    json!({
564                        "process_id": {
565                            "type": "string",
566                            "description": "Process id returned by `shell.start`."
567                        },
568                        "chars": {
569                            "type": "string",
570                            "default": "",
571                            "description": "Bytes to write to stdin; may be empty when only closing stdin."
572                        },
573                        "close_stdin": {
574                            "type": "boolean",
575                            "default": false,
576                            "description": "Close stdin after writing to send EOF to the process."
577                        }
578                    }),
579                    &["process_id"],
580                ),
581                shell_write_output_schema(),
582            )
583            .with_examples(vec![
584                r#"await shell.write({ process_id: "call-shell-1", chars: "status\n" })?"#.into(),
585                r#"await shell.write({ process_id: "call-shell-1", chars: "", close_stdin: true })?"#.into(),
586            ])
587            .with_lashlang_binding(lash_tool_support::lashlang_binding(
588                ["shell"],
589                "write",
590                &["send_stdin", "poll_command"],
591            ))
592            .with_scheduling(ToolScheduling::Serial),
593        ]
594    }
595
596    async fn dispatch(
597        &self,
598        name: &str,
599        args: &serde_json::Value,
600        context: &lash_core::ToolContext<'_>,
601        progress: Option<&ProgressSender>,
602        cancel: Option<CancellationToken>,
603    ) -> ToolResult {
604        match name {
605            "exec_command" => {
606                let params = match self.parse_exec_command_params(args) {
607                    Ok(params) => params,
608                    Err(err) => return err,
609                };
610                self.exec_command(&params, progress, cancel).await
611            }
612            "start_command" => {
613                let params = match self.parse_start_command_params(args) {
614                    Ok(params) => params,
615                    Err(err) => return err,
616                };
617                self.start_command(&params, context, progress, cancel).await
618            }
619            "write_stdin" => self.write_stdin_call(args, context).await,
620            _ => ToolResult::err_fmt(format_args!("Unknown tool: {name}")),
621        }
622    }
623}
624
625fn shell_exec_output_schema() -> serde_json::Value {
626    json!({
627        "type": "object",
628        "properties": {
629            "output": { "type": "string" },
630            "status": { "type": "string", "enum": ["completed", "timed_out"] },
631            "done": { "type": "boolean" },
632            "running": { "type": "boolean" },
633            "wall_time_seconds": { "type": "number", "minimum": 0 },
634            "exit_code": { "type": "integer" },
635            "timed_out": { "type": "boolean" },
636            "error": { "type": "string" },
637            "original_token_count": { "type": "integer", "minimum": 0 },
638            "full_output_path": { "type": "string" }
639        },
640        "required": ["output", "status", "done", "running", "wall_time_seconds"],
641        "additionalProperties": false
642    })
643}
644
645fn shell_start_output_schema() -> serde_json::Value {
646    json!({
647        "type": "object",
648        "properties": {
649            "__handle__": { "type": "string", "enum": ["process"] },
650            "id": { "type": "string" },
651            "process_id": { "type": "string" },
652            "status": { "type": "string", "enum": ["running"] },
653            "done": { "type": "boolean" },
654            "running": { "type": "boolean" }
655        },
656        "required": ["__handle__", "id", "process_id", "status", "done", "running"],
657        "additionalProperties": false
658    })
659}
660
661fn shell_write_output_schema() -> serde_json::Value {
662    json!({
663        "type": "object",
664        "properties": {
665            "process_id": { "type": "string" },
666            "status": { "type": "string", "enum": ["signalled"] },
667            "sequence": { "type": "integer", "minimum": 0 }
668        },
669        "required": ["process_id", "status", "sequence"],
670        "additionalProperties": false
671    })
672}
673
674fn parse_process_id(args: &serde_json::Value) -> Result<String, ToolResult> {
675    require_str(args, "process_id").map(str::to_string)
676}
677
678/// PluginFactory for the built-in shell tool catalog.
679///
680/// Wires `StandardShell` into the active session with the access-gated
681/// `shell.write` mention in the prompt contribution so the model only
682/// sees that bullet when the tool is actually callable.
683#[derive(Default)]
684pub struct StandardShellPluginFactory;
685
686impl StandardShellPluginFactory {
687    pub fn new() -> Self {
688        Self
689    }
690}
691
692impl PluginFactory for StandardShellPluginFactory {
693    fn id(&self) -> &'static str {
694        "shell"
695    }
696
697    fn build(&self, ctx: &PluginSessionContext) -> Result<Arc<dyn SessionPlugin>, PluginError> {
698        let tool_access = ctx.tool_access.clone();
699        let provider = Arc::new(shell_provider(StandardShell::new())) as Arc<dyn ToolProvider>;
700        PluginSpecFactory::new(
701            "shell",
702            Arc::new(move |_ctx| {
703                let provider = Arc::clone(&provider);
704                let tool_access = tool_access.clone();
705                Ok(PluginSpec::new()
706                    .with_tool_provider(provider)
707                    .with_prompt_contributor(Arc::new(move |_ctx| {
708                        let tool_access = tool_access.clone();
709                        Box::pin(
710                            async move { Ok(shell_prompt_contributions_for_access(&tool_access)) },
711                        )
712                    })))
713            }),
714        )
715        .build(ctx)
716    }
717}
718
719include!("tests.rs");