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, ToolDefinitionLashlangExt, object_schema,
31    parse_optional_bool, 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            "tool:start_command",
246            "start_command",
247            args,
248            None,
249            serde_json::Value::Null,
250        );
251        let descriptor = ProcessHandleDescriptor::new(Some("shell"), Some(params.cmd.clone()));
252        let request = ProcessStartRequest::new(
253            process_id.clone(),
254            ProcessInput::ToolCall { call },
255            lash_core::ProcessOriginator::host(),
256        )
257        .with_grant(Some(lash_core::ProcessStartGrant {
258            session_scope: SessionScope::new("request-descriptor"),
259            descriptor,
260        }))
261        .with_extra_event_types([shell_signal_event_type()]);
262        match context.processes().start(request).await {
263            Ok(summary) => {
264                let mut handle = serde_json::to_value(summary).unwrap_or_else(|_| {
265                    lash_core::RuntimeExecutionContext::process_handle_json(&process_id)
266                });
267                if let Some(object) = handle.as_object_mut() {
268                    object.insert("status".to_string(), json!("running"));
269                    object.insert("done".to_string(), json!(false));
270                    object.insert("running".to_string(), json!(true));
271                }
272                ToolResult::ok(handle)
273            }
274            Err(err) => ToolResult::err_fmt(err.to_string()),
275        }
276    }
277
278    async fn run_start_command_process(
279        &self,
280        process_id: &str,
281        params: &StartCommandParams,
282        context: &lash_core::ToolContext<'_>,
283        progress: Option<&ProgressSender>,
284        cancel: Option<CancellationToken>,
285    ) -> ToolResult {
286        let started = Instant::now();
287        let handle_id = process_id.to_string();
288
289        if let Err(err) = self.runtime.spawn_process(
290            handle_id.clone(),
291            &params.cmd,
292            &params.workdir,
293            params.login,
294            &params.shell_path,
295        ) {
296            return ToolResult::err(json!(err));
297        }
298
299        let signal_done = CancellationToken::new();
300        let signal_forwarder =
301            self.spawn_stdin_signal_forwarder(handle_id.clone(), context, signal_done.clone());
302        match self
303            .runtime
304            .wait_until_exit_or_timeout(
305                &handle_id,
306                None,
307                progress,
308                params.max_output_tokens,
309                WaitBehavior { baseline_len: 0 },
310                cancel,
311            )
312            .await
313        {
314            Ok(PollOutcome::Running { .. }) => {
315                signal_done.cancel();
316                let _ = signal_forwarder.await;
317                self.runtime.remove_process(&handle_id);
318                ToolResult::err_fmt("background shell process returned running without a timeout")
319            }
320            Ok(PollOutcome::Exited {
321                output,
322                original_token_count,
323                exit_code,
324                full_output_path,
325            }) => {
326                signal_done.cancel();
327                let _ = signal_forwarder.await;
328                self.runtime.remove_process(&handle_id);
329                shell_io_result(
330                    &handle_id,
331                    output,
332                    Some(exit_code),
333                    original_token_count,
334                    full_output_path.as_deref(),
335                    started.elapsed().as_secs_f64(),
336                    params.allow_nonzero_exit,
337                )
338            }
339            Ok(PollOutcome::Cancelled) => {
340                signal_done.cancel();
341                let _ = signal_forwarder.await;
342                self.runtime.remove_process(&handle_id);
343                ToolResult::cancelled("tool call cancelled")
344            }
345            Err(err) => {
346                signal_done.cancel();
347                let _ = signal_forwarder.await;
348                self.runtime.remove_process(&handle_id);
349                ToolResult::err(json!(err))
350            }
351        }
352    }
353
354    fn spawn_stdin_signal_forwarder(
355        &self,
356        process_id: String,
357        context: &lash_core::ToolContext<'_>,
358        done: CancellationToken,
359    ) -> tokio::task::JoinHandle<()> {
360        let runtime = self.runtime.clone();
361        let events = context.process_events();
362        tokio::spawn(async move {
363            let mut after_sequence = 0;
364            loop {
365                let event = tokio::select! {
366                    _ = done.cancelled() => break,
367                    event = events.wait_event_after(SHELL_STDIN_SIGNAL_EVENT, after_sequence) => event,
368                };
369                let Ok(event) = event else {
370                    break;
371                };
372                after_sequence = event.sequence;
373                if let Some(chars) = event.payload.get("chars").and_then(|value| value.as_str()) {
374                    let _ = runtime.write_stdin(&process_id, chars).await;
375                }
376                if event
377                    .payload
378                    .get("close_stdin")
379                    .and_then(|value| value.as_bool())
380                    .unwrap_or(false)
381                {
382                    let _ = runtime.close_stdin(&process_id).await;
383                }
384            }
385        })
386    }
387
388    async fn write_stdin_call(
389        &self,
390        args: &serde_json::Value,
391        context: &lash_core::ToolContext<'_>,
392    ) -> ToolResult {
393        let process_id = match parse_process_id(args) {
394            Ok(value) => value,
395            Err(err) => return err,
396        };
397        let chars = args
398            .get("chars")
399            .and_then(|value| value.as_str())
400            .unwrap_or("");
401        let close_stdin = match parse_optional_bool(args, "close_stdin", false) {
402            Ok(value) => value,
403            Err(err) => return err,
404        };
405        match context
406            .processes()
407            .signal(
408                &process_id,
409                SHELL_STDIN_SIGNAL,
410                json!({
411                    "chars": chars,
412                    "close_stdin": close_stdin,
413                }),
414            )
415            .await
416        {
417            Ok(event) => ToolResult::ok(json!({
418                "process_id": process_id,
419                "status": "signalled",
420                "sequence": event.sequence,
421            })),
422            Err(err) => ToolResult::err_fmt(err.to_string()),
423        }
424    }
425}
426
427fn start_command_process_args(params: &StartCommandParams) -> serde_json::Value {
428    let mut args = serde_json::Map::new();
429    args.insert("cmd".to_string(), json!(params.cmd.clone()));
430    args.insert(
431        "workdir".to_string(),
432        json!(params.workdir.to_string_lossy().to_string()),
433    );
434    args.insert("shell".to_string(), json!(params.shell_path.clone()));
435    args.insert("login".to_string(), json!(params.login));
436    args.insert(
437        "allow_nonzero_exit".to_string(),
438        json!(params.allow_nonzero_exit),
439    );
440    if let Some(max_output_tokens) = params.max_output_tokens {
441        args.insert("max_output_tokens".to_string(), json!(max_output_tokens));
442    }
443    serde_json::Value::Object(args)
444}
445
446fn shell_signal_event_type() -> ProcessEventType {
447    ProcessEventType {
448        name: SHELL_STDIN_SIGNAL_EVENT.to_string(),
449        payload_schema: lash_core::LashSchema::any(),
450        semantics: ProcessEventSemanticsSpec::default(),
451    }
452}
453
454impl Default for StandardShell {
455    fn default() -> Self {
456        Self::new()
457    }
458}
459
460/// Build the cached shell tool provider (`shell.exec` / `shell.start`).
461pub fn shell_provider(shell: StandardShell) -> StaticToolProvider<StandardShell> {
462    let definitions = shell.tool_definitions();
463    StaticToolProvider::new(definitions, shell)
464}
465
466#[async_trait::async_trait]
467impl StaticToolExecute for StandardShell {
468    async fn execute(&self, call: ToolCall<'_>) -> ToolResult {
469        let cancellation_token = call.context.cancellation_token().cloned();
470        self.dispatch(
471            call.name,
472            call.args,
473            call.context,
474            call.progress,
475            cancellation_token,
476        )
477        .await
478    }
479}
480
481impl StandardShell {
482    fn tool_definitions(&self) -> Vec<ToolDefinition> {
483        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.";
484        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.";
485        let command_common = |command_description: &str| {
486            json!({
487                "cmd": {
488                    "type": "string",
489                    "description": command_description
490                },
491                "workdir": {
492                    "type": "string",
493                    "description": "Optional working directory to run the command in; defaults to the turn cwd."
494                },
495                "shell": {
496                    "type": "string",
497                    "description": "Shell binary to launch. Defaults to the user's default shell."
498                },
499                "login": {
500                    "type": "boolean",
501                    "default": false,
502                    "description": "Whether to run the shell with -l semantics. Defaults to false to avoid startup prompts and shell init noise."
503                },
504                "allow_nonzero_exit": {
505                    "type": "boolean",
506                    "default": false,
507                    "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."
508                },
509                "max_output_tokens": {
510                    "type": "integer",
511                    "minimum": 1,
512                    "description": "Maximum number of tokens to return. Excess output will be truncated."
513                }
514            })
515        };
516        vec![
517            ToolDefinition::raw(
518                "tool:exec_command",
519                "exec_command",
520                exec_command_description,
521                {
522                    let mut properties = command_common("Shell command to execute.");
523                    properties["timeout_ms"] = json!({
524                        "type": "integer",
525                        "minimum": 1,
526                        "default": DEFAULT_EXEC_COMMAND_TIMEOUT_MS,
527                        "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."
528                    });
529                    object_schema(properties, &["cmd"])
530                },
531                shell_exec_output_schema(),
532            )
533            .with_examples(vec![
534                r#"await shell.exec({ cmd: "cargo test -p lash-protocol-rlm", timeout_ms: 600000 })?"#.into(),
535                r#"await shell.exec({ cmd: "test -f Cargo.lock", allow_nonzero_exit: true })?"#.into(),
536            ])
537            .with_lashlang_binding(lash_tool_support::lashlang_binding(
538                ["shell"],
539                "exec",
540                &["shell", "bash"],
541            ))
542            .with_scheduling(ToolScheduling::Serial),
543            ToolDefinition::raw(
544                "tool:start_command",
545                "start_command",
546                start_command_description,
547                object_schema(command_common("Shell command to start."), &["cmd"]),
548                shell_start_output_schema(),
549            )
550            .with_examples(vec![
551                r#"await shell.start({ cmd: "python -m http.server 8000" })?"#.into(),
552            ])
553            .with_lashlang_binding(lash_tool_support::lashlang_binding(
554                ["shell"],
555                "start",
556                &["long_running_command", "pty"],
557            ))
558            .with_scheduling(ToolScheduling::Serial),
559            ToolDefinition::raw(
560                "tool:write_stdin",
561                "write_stdin",
562                "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.",
563                object_schema(
564                    json!({
565                        "process_id": {
566                            "type": "string",
567                            "description": "Process id returned by `shell.start`."
568                        },
569                        "chars": {
570                            "type": "string",
571                            "default": "",
572                            "description": "Bytes to write to stdin; may be empty when only closing stdin."
573                        },
574                        "close_stdin": {
575                            "type": "boolean",
576                            "default": false,
577                            "description": "Close stdin after writing to send EOF to the process."
578                        }
579                    }),
580                    &["process_id"],
581                ),
582                shell_write_output_schema(),
583            )
584            .with_examples(vec![
585                r#"await shell.write({ process_id: "call-shell-1", chars: "status\n" })?"#.into(),
586                r#"await shell.write({ process_id: "call-shell-1", chars: "", close_stdin: true })?"#.into(),
587            ])
588            .with_lashlang_binding(lash_tool_support::lashlang_binding(
589                ["shell"],
590                "write",
591                &["send_stdin", "poll_command"],
592            ))
593            .with_scheduling(ToolScheduling::Serial),
594        ]
595    }
596
597    async fn dispatch(
598        &self,
599        name: &str,
600        args: &serde_json::Value,
601        context: &lash_core::ToolContext<'_>,
602        progress: Option<&ProgressSender>,
603        cancel: Option<CancellationToken>,
604    ) -> ToolResult {
605        match name {
606            "exec_command" => {
607                let params = match self.parse_exec_command_params(args) {
608                    Ok(params) => params,
609                    Err(err) => return err,
610                };
611                self.exec_command(&params, progress, cancel).await
612            }
613            "start_command" => {
614                let params = match self.parse_start_command_params(args) {
615                    Ok(params) => params,
616                    Err(err) => return err,
617                };
618                self.start_command(&params, context, progress, cancel).await
619            }
620            "write_stdin" => self.write_stdin_call(args, context).await,
621            _ => ToolResult::err_fmt(format_args!("Unknown tool: {name}")),
622        }
623    }
624}
625
626fn shell_exec_output_schema() -> serde_json::Value {
627    json!({
628        "type": "object",
629        "properties": {
630            "output": { "type": "string" },
631            "status": { "type": "string", "enum": ["completed", "timed_out"] },
632            "done": { "type": "boolean" },
633            "running": { "type": "boolean" },
634            "wall_time_seconds": { "type": "number", "minimum": 0 },
635            "exit_code": { "type": "integer" },
636            "timed_out": { "type": "boolean" },
637            "error": { "type": "string" },
638            "original_token_count": { "type": "integer", "minimum": 0 },
639            "full_output_path": { "type": "string" }
640        },
641        "required": ["output", "status", "done", "running", "wall_time_seconds"],
642        "additionalProperties": false
643    })
644}
645
646fn shell_start_output_schema() -> serde_json::Value {
647    json!({
648        "type": "object",
649        "properties": {
650            "__handle__": { "type": "string", "enum": ["process"] },
651            "id": { "type": "string" },
652            "process_id": { "type": "string" },
653            "status": { "type": "string", "enum": ["running"] },
654            "done": { "type": "boolean" },
655            "running": { "type": "boolean" }
656        },
657        "required": ["__handle__", "id", "process_id", "status", "done", "running"],
658        "additionalProperties": false
659    })
660}
661
662fn shell_write_output_schema() -> serde_json::Value {
663    json!({
664        "type": "object",
665        "properties": {
666            "process_id": { "type": "string" },
667            "status": { "type": "string", "enum": ["signalled"] },
668            "sequence": { "type": "integer", "minimum": 0 }
669        },
670        "required": ["process_id", "status", "sequence"],
671        "additionalProperties": false
672    })
673}
674
675fn parse_process_id(args: &serde_json::Value) -> Result<String, ToolResult> {
676    require_str(args, "process_id").map(str::to_string)
677}
678
679/// PluginFactory for the built-in shell tool catalog.
680///
681/// Wires `StandardShell` into the active session with the access-gated
682/// `shell.write` mention in the prompt contribution so the model only
683/// sees that bullet when the tool is actually callable.
684#[derive(Default)]
685pub struct StandardShellPluginFactory;
686
687impl StandardShellPluginFactory {
688    pub fn new() -> Self {
689        Self
690    }
691}
692
693impl PluginFactory for StandardShellPluginFactory {
694    fn id(&self) -> &'static str {
695        "shell"
696    }
697
698    fn build(&self, ctx: &PluginSessionContext) -> Result<Arc<dyn SessionPlugin>, PluginError> {
699        let tool_access = ctx.tool_access.clone();
700        let provider = Arc::new(shell_provider(StandardShell::new())) as Arc<dyn ToolProvider>;
701        PluginSpecFactory::new(
702            "shell",
703            Arc::new(move |_ctx| {
704                let provider = Arc::clone(&provider);
705                let tool_access = tool_access.clone();
706                Ok(PluginSpec::new()
707                    .with_tool_provider(provider)
708                    .with_prompt_contributor(Arc::new(move |_ctx| {
709                        let tool_access = tool_access.clone();
710                        Box::pin(
711                            async move { Ok(shell_prompt_contributions_for_access(&tool_access)) },
712                        )
713                    })))
714            }),
715        )
716        .build(ctx)
717    }
718}
719
720include!("tests.rs");