objectiveai_cli/command/plugins/
run.rs1use std::pin::Pin;
16use std::process::Stdio;
17use std::sync::Arc;
18
19use futures::{Stream, StreamExt};
20use objectiveai_sdk::cli::command::plugins::run::{Request, ResponseItem};
21use objectiveai_sdk::cli::plugins::Output as PluginOutput;
22use objectiveai_sdk::cli::{Error as CliError, ErrorType as CliErrorType};
23use serde::Serialize;
24use tokio::io::AsyncWriteExt;
25use tokio::process::{ChildStdin, Command};
26use tokio::sync::Mutex;
27use tokio::task::JoinHandle;
28
29use crate::child_io::{PipeEvent, spawn_pipe_reader};
30use crate::context::Context;
31use crate::error::Error;
32
33type ItemStream = Pin<Box<dyn Stream<Item = Result<ResponseItem, Error>> + Send>>;
34
35pub async fn execute(ctx: &Context, request: Request) -> Result<ItemStream, Error> {
36 let coord = format!("{}/{}/{}", request.owner, request.name, request.version);
37 let (exec, cli_dir) = ctx
38 .filesystem
39 .resolve_plugin(&request.owner, &request.name, &request.version)
40 .await
41 .ok_or_else(|| Error::PluginNotFound(coord.clone()))?;
42
43 let mut argv = exec;
49 argv.extend(request.args.iter().cloned());
50 let mut argv = argv.into_iter();
51 let program = argv
52 .next()
53 .ok_or_else(|| Error::PluginNotFound(format!("{coord} (empty exec)")))?;
54 let program = crate::spawn::resolve_program(program, &cli_dir);
55
56 let state_dir = ctx
60 .filesystem
61 .state_dir()
62 .join("plugins")
63 .join(&request.owner)
64 .join(&request.name)
65 .join(&request.version);
66 tokio::fs::create_dir_all(&state_dir)
67 .await
68 .map_err(Error::PluginSpawn)?;
69
70 let postgres_url = crate::db::compartment::ensure(
76 ctx.db_handle().await?,
77 crate::db::compartment::Kind::Plugin,
78 &request.owner,
79 &request.name,
80 &request.version,
81 )
82 .await?;
83
84 let mut nested_ctx = ctx.clone();
93 nested_ctx.config.plugin_owner = Some(request.owner.clone());
94 nested_ctx.config.plugin_repository = Some(request.name.clone());
95 nested_ctx.config.plugin_version = Some(request.version.clone());
96 nested_ctx.plugin = Some(crate::plugin_path::PluginPath {
97 owner: request.owner.clone(),
98 repository: request.name.clone(),
99 version: request.version.clone(),
100 });
101
102 let mut cmd = Command::new(&program);
103 cmd.args(argv)
104 .current_dir(&cli_dir)
105 .env("OBJECTIVEAI_STATE_DIR", &state_dir)
106 .env("OBJECTIVEAI_BIN_DIR", &cli_dir)
107 .env("OBJECTIVEAI_POSTGRES_URL", postgres_url)
108 .stdin(Stdio::piped())
109 .stdout(Stdio::piped())
110 .stderr(Stdio::piped())
111 .kill_on_drop(true);
112 crate::spawn::apply_config_env(&mut cmd, &nested_ctx.config);
113
114 let mut child = cmd.spawn().map_err(Error::PluginSpawn)?;
115 let stdout = child.stdout.take().expect("stdout was piped");
116 let stderr = child.stderr.take().expect("stderr was piped");
117 let stdin = child.stdin.take().expect("stdin was piped");
118 let plugin_stdin: Arc<Mutex<ChildStdin>> = Arc::new(Mutex::new(stdin));
119
120 let mut events = spawn_pipe_reader(stdout, stderr);
121
122 let stream = async_stream::stream! {
123 let mut command_tasks: Vec<(Option<String>, JoinHandle<i32>)> = Vec::new();
124 while let Some(event) = events.recv().await {
125 match event {
126 PipeEvent::Stderr(_) => {
127 yield Ok(ResponseItem::Error(CliError {
131 r#type: CliErrorType::Error,
132 level: None,
133 fatal: None,
134 message: serde_json::Value::Null,
135 }));
136 }
137 PipeEvent::Stdout(trimmed) => {
138 match serde_json::from_str::<PluginOutput>(&trimmed) {
139 Ok(PluginOutput::Error(e)) => {
140 yield Ok(ResponseItem::Error(e));
141 }
142 Ok(PluginOutput::Mcp(mcp)) => {
143 yield Ok(ResponseItem::Mcp(mcp));
144 }
145 Ok(PluginOutput::Command(c)) => {
146 let task_id = Some(c.id);
153 let task = run_nested_command(
154 nested_ctx.clone(),
155 c.command,
156 plugin_stdin.clone(),
157 task_id.clone(),
158 );
159 command_tasks.push((task_id, task));
160 }
161 Ok(PluginOutput::Notification(value)) => {
162 yield Ok(ResponseItem::Notification(value));
163 }
164 Err(_) => {
165 yield Ok(ResponseItem::Notification(
170 serde_json::Value::String(trimmed),
171 ));
172 }
173 }
174 }
175 PipeEvent::StdoutEof | PipeEvent::StderrEof => {}
176 PipeEvent::StdoutErr(e) | PipeEvent::StderrErr(e) => {
177 yield Err(Error::PluginRead(e));
178 return;
179 }
180 }
181 }
182
183 for (id, task) in command_tasks {
188 let exit_code = task.await.unwrap_or(-1);
189 let envelope = PluginCommandResponse {
190 id: id.as_deref(),
191 value: CommandComplete {
192 kind: "command_complete",
193 exit_code,
194 },
195 };
196 let _ = write_envelope(&plugin_stdin, &envelope).await;
197 }
198
199 drop(plugin_stdin);
202
203 match child.wait().await {
204 Ok(status) if status.success() => {}
205 Ok(status) => {
206 yield Err(Error::PluginExit(status.code().unwrap_or(1)));
207 }
208 Err(e) => {
209 yield Err(Error::PluginRead(e));
210 }
211 }
212 };
213
214 Ok(Box::pin(stream))
215}
216
217fn run_nested_command(
229 ctx: Context,
230 command: Vec<String>,
231 plugin_stdin: Arc<Mutex<ChildStdin>>,
232 id: Option<String>,
233) -> JoinHandle<i32> {
234 tokio::spawn(async move {
235 let id = id.as_deref();
236 let tokens: Vec<String> = command;
240
241 let forbidden = match tokens.first().map(String::as_str) {
245 Some("plugins") => Some("plugins"),
246 Some("tools") => Some("tools"),
247 _ => None,
248 };
249 if let Some(kind) = forbidden {
250 let _ = forward_error(
251 &plugin_stdin,
252 id,
253 &Error::PluginCommandForbidden(kind),
254 Some(true),
255 )
256 .await;
257 return 1;
258 }
259
260 let mut args: Vec<String> = vec!["objectiveai-cli".to_string()];
264 args.extend(tokens);
265
266 let run_stream = match crate::run(args, Some(ctx)).await {
270 Ok(s) => s,
271 Err(e) => {
272 if let Error::ClapParse(ref clap_err) = e {
273 if crate::is_informational(clap_err) {
274 let _ = forward_help(&plugin_stdin, id, &clap_err.to_string()).await;
275 return 0;
276 }
277 }
278 let _ = forward_error(&plugin_stdin, id, &e, Some(true)).await;
279 return match e {
280 Error::ToolExit(code) => code,
281 _ => 1,
282 };
283 }
284 };
285 let last_tool_exit = match run_stream {
289 crate::RunStream::Execute(stream) => drain(&plugin_stdin, id, stream).await,
290 crate::RunStream::ExecuteTransform(stream) => drain(&plugin_stdin, id, stream).await,
291 };
292 last_tool_exit.unwrap_or(0)
293 })
294}
295
296async fn drain<S, T>(
302 plugin_stdin: &Arc<Mutex<ChildStdin>>,
303 id: Option<&str>,
304 mut stream: S,
305) -> Option<i32>
306where
307 S: Stream<Item = Result<T, Error>> + Unpin,
308 T: Serialize,
309{
310 let mut last_tool_exit: Option<i32> = None;
311 while let Some(item) = stream.next().await {
312 let written = match item {
313 Ok(value) => forward_line(plugin_stdin, id, &value).await,
314 Err(e) => {
315 if let Error::ToolExit(code) = &e {
316 last_tool_exit = Some(*code);
317 }
318 forward_error(plugin_stdin, id, &e, None).await
319 }
320 };
321 if written.is_err() {
322 break;
324 }
325 }
326 last_tool_exit
327}
328
329async fn forward_line<T: Serialize>(
332 plugin_stdin: &Arc<Mutex<ChildStdin>>,
333 id: Option<&str>,
334 value: &T,
335) -> std::io::Result<()> {
336 write_envelope(plugin_stdin, &PluginCommandResponse { id, value }).await
337}
338
339async fn forward_error(
341 plugin_stdin: &Arc<Mutex<ChildStdin>>,
342 id: Option<&str>,
343 e: &Error,
344 fatal: Option<bool>,
345) -> std::io::Result<()> {
346 let payload = CliError {
347 r#type: CliErrorType::Error,
348 level: Some(objectiveai_sdk::cli::Level::Error),
349 fatal,
350 message: e.output_message(),
351 };
352 forward_line(plugin_stdin, id, &payload).await
353}
354
355async fn forward_help(
357 plugin_stdin: &Arc<Mutex<ChildStdin>>,
358 id: Option<&str>,
359 help: &str,
360) -> std::io::Result<()> {
361 let payload = serde_json::json!({ "type": "help", "help": help });
362 forward_line(plugin_stdin, id, &payload).await
363}
364
365async fn write_envelope<T: Serialize>(
366 stdin: &Arc<Mutex<ChildStdin>>,
367 envelope: &T,
368) -> std::io::Result<()> {
369 let line = serde_json::to_string(envelope).expect("envelope serializes");
370 let mut guard = stdin.lock().await;
371 guard.write_all(line.as_bytes()).await?;
372 guard.write_all(b"\n").await?;
373 guard.flush().await?;
374 Ok(())
375}
376
377#[derive(Serialize)]
382struct PluginCommandResponse<'a, T> {
383 #[serde(skip_serializing_if = "Option::is_none")]
384 id: Option<&'a str>,
385 value: T,
386}
387
388#[derive(Serialize)]
391struct CommandComplete {
392 #[serde(rename = "type")]
393 kind: &'static str,
394 exit_code: i32,
395}
396
397pub mod request_schema {
398 use objectiveai_sdk::cli::command::plugins::run as sdk;
399 use objectiveai_sdk::cli::command::plugins::run::request_schema::{Request, Response};
400
401 use crate::context::Context;
402 use crate::error::Error;
403
404 pub async fn execute(_ctx: &Context, _request: Request) -> Result<Response, Error> {
405 Ok(objectiveai_sdk::cli::command::ResponseSchema(schemars::schema_for!(sdk::Request)))
406 }
407}
408
409pub mod response_schema {
410 use objectiveai_sdk::cli::command::plugins::run as sdk;
411 use objectiveai_sdk::cli::command::plugins::run::response_schema::{Request, Response};
412
413 use crate::context::Context;
414 use crate::error::Error;
415
416 pub async fn execute(_ctx: &Context, _request: Request) -> Result<Response, Error> {
417 Ok(objectiveai_sdk::cli::command::ResponseSchema(schemars::schema_for!(sdk::ResponseItem)))
418 }
419}