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 crate::spawn::apply_config_env(&mut cmd, &nested_ctx.config);
112
113 let mut child =
120 objectiveai_sdk::subprocess_reaper::spawn(&mut cmd).map_err(Error::PluginSpawn)?;
121 let stdout = child.stdout.take().expect("stdout was piped");
122 let stderr = child.stderr.take().expect("stderr was piped");
123 let stdin = child.stdin.take().expect("stdin was piped");
124 let plugin_stdin: Arc<Mutex<ChildStdin>> = Arc::new(Mutex::new(stdin));
125
126 let mut events = spawn_pipe_reader(stdout, stderr);
127
128 let stream = async_stream::stream! {
129 let mut command_tasks: Vec<(Option<String>, JoinHandle<i32>)> = Vec::new();
130 while let Some(event) = events.recv().await {
131 match event {
132 PipeEvent::Stderr(_) => {
133 yield Ok(ResponseItem::Error(CliError {
137 r#type: CliErrorType::Error,
138 level: None,
139 fatal: None,
140 message: serde_json::Value::Null,
141 }));
142 }
143 PipeEvent::Stdout(trimmed) => {
144 match serde_json::from_str::<PluginOutput>(&trimmed) {
145 Ok(PluginOutput::Error(e)) => {
146 yield Ok(ResponseItem::Error(e));
147 }
148 Ok(PluginOutput::Mcp(mcp)) => {
149 yield Ok(ResponseItem::Mcp(mcp));
150 }
151 Ok(PluginOutput::Command(c)) => {
152 let task_id = Some(c.id);
159 let task = run_nested_command(
160 nested_ctx.clone(),
161 c.command,
162 plugin_stdin.clone(),
163 task_id.clone(),
164 );
165 command_tasks.push((task_id, task));
166 }
167 Ok(PluginOutput::Notification(value)) => {
168 yield Ok(ResponseItem::Notification(value));
169 }
170 Err(_) => {
171 yield Ok(ResponseItem::Notification(
176 serde_json::Value::String(trimmed),
177 ));
178 }
179 }
180 }
181 PipeEvent::StdoutEof | PipeEvent::StderrEof => {}
182 PipeEvent::StdoutErr(e) | PipeEvent::StderrErr(e) => {
183 yield Err(Error::PluginRead(e));
184 return;
185 }
186 }
187 }
188
189 for (id, task) in command_tasks {
194 let exit_code = task.await.unwrap_or(-1);
195 let envelope = PluginCommandResponse {
196 id: id.as_deref(),
197 value: CommandComplete {
198 kind: "command_complete",
199 exit_code,
200 },
201 };
202 let _ = write_envelope(&plugin_stdin, &envelope).await;
203 }
204
205 drop(plugin_stdin);
208
209 match child.wait().await {
210 Ok(status) if status.success() => {}
211 Ok(status) => {
212 yield Err(Error::PluginExit(status.code().unwrap_or(1)));
213 }
214 Err(e) => {
215 yield Err(Error::PluginRead(e));
216 }
217 }
218 };
219
220 Ok(Box::pin(stream))
221}
222
223fn run_nested_command(
235 ctx: Context,
236 command: Vec<String>,
237 plugin_stdin: Arc<Mutex<ChildStdin>>,
238 id: Option<String>,
239) -> JoinHandle<i32> {
240 tokio::spawn(async move {
241 let id = id.as_deref();
242 let tokens: Vec<String> = command;
246
247 let forbidden = match tokens.first().map(String::as_str) {
251 Some("plugins") => Some("plugins"),
252 Some("tools") => Some("tools"),
253 _ => None,
254 };
255 if let Some(kind) = forbidden {
256 let _ = forward_error(
257 &plugin_stdin,
258 id,
259 &Error::PluginCommandForbidden(kind),
260 Some(true),
261 )
262 .await;
263 return 1;
264 }
265
266 let mut args: Vec<String> = vec!["objectiveai-cli".to_string()];
270 args.extend(tokens);
271
272 let run_stream = match crate::run(args, Some(ctx)).await {
276 Ok(s) => s,
277 Err(e) => {
278 if let Error::ClapParse(ref clap_err) = e {
279 if crate::is_informational(clap_err) {
280 let _ = forward_help(&plugin_stdin, id, &clap_err.to_string()).await;
281 return 0;
282 }
283 }
284 let _ = forward_error(&plugin_stdin, id, &e, Some(true)).await;
285 return match e {
286 Error::ToolExit(code) => code,
287 _ => 1,
288 };
289 }
290 };
291 let last_tool_exit = match run_stream {
295 crate::RunStream::Execute(stream) => drain(&plugin_stdin, id, stream).await,
296 crate::RunStream::ExecuteTransform(stream) => drain(&plugin_stdin, id, stream).await,
297 };
298 last_tool_exit.unwrap_or(0)
299 })
300}
301
302async fn drain<S, T>(
308 plugin_stdin: &Arc<Mutex<ChildStdin>>,
309 id: Option<&str>,
310 mut stream: S,
311) -> Option<i32>
312where
313 S: Stream<Item = Result<T, Error>> + Unpin,
314 T: Serialize,
315{
316 let mut last_tool_exit: Option<i32> = None;
317 while let Some(item) = stream.next().await {
318 let written = match item {
319 Ok(value) => forward_line(plugin_stdin, id, &value).await,
320 Err(e) => {
321 if let Error::ToolExit(code) = &e {
322 last_tool_exit = Some(*code);
323 }
324 forward_error(plugin_stdin, id, &e, None).await
325 }
326 };
327 if written.is_err() {
328 break;
330 }
331 }
332 last_tool_exit
333}
334
335async fn forward_line<T: Serialize>(
338 plugin_stdin: &Arc<Mutex<ChildStdin>>,
339 id: Option<&str>,
340 value: &T,
341) -> std::io::Result<()> {
342 write_envelope(plugin_stdin, &PluginCommandResponse { id, value }).await
343}
344
345async fn forward_error(
347 plugin_stdin: &Arc<Mutex<ChildStdin>>,
348 id: Option<&str>,
349 e: &Error,
350 fatal: Option<bool>,
351) -> std::io::Result<()> {
352 let payload = CliError {
353 r#type: CliErrorType::Error,
354 level: Some(objectiveai_sdk::cli::Level::Error),
355 fatal,
356 message: e.output_message(),
357 };
358 forward_line(plugin_stdin, id, &payload).await
359}
360
361async fn forward_help(
363 plugin_stdin: &Arc<Mutex<ChildStdin>>,
364 id: Option<&str>,
365 help: &str,
366) -> std::io::Result<()> {
367 let payload = serde_json::json!({ "type": "help", "help": help });
368 forward_line(plugin_stdin, id, &payload).await
369}
370
371async fn write_envelope<T: Serialize>(
372 stdin: &Arc<Mutex<ChildStdin>>,
373 envelope: &T,
374) -> std::io::Result<()> {
375 let line = serde_json::to_string(envelope).expect("envelope serializes");
376 let mut guard = stdin.lock().await;
377 guard.write_all(line.as_bytes()).await?;
378 guard.write_all(b"\n").await?;
379 guard.flush().await?;
380 Ok(())
381}
382
383#[derive(Serialize)]
388struct PluginCommandResponse<'a, T> {
389 #[serde(skip_serializing_if = "Option::is_none")]
390 id: Option<&'a str>,
391 value: T,
392}
393
394#[derive(Serialize)]
397struct CommandComplete {
398 #[serde(rename = "type")]
399 kind: &'static str,
400 exit_code: i32,
401}
402
403pub mod request_schema {
404 use objectiveai_sdk::cli::command::plugins::run as sdk;
405 use objectiveai_sdk::cli::command::plugins::run::request_schema::{Request, Response};
406
407 use crate::context::Context;
408 use crate::error::Error;
409
410 pub async fn execute(_ctx: &Context, _request: Request) -> Result<Response, Error> {
411 Ok(objectiveai_sdk::cli::command::ResponseSchema(schemars::schema_for!(sdk::Request)))
412 }
413}
414
415pub mod response_schema {
416 use objectiveai_sdk::cli::command::plugins::run as sdk;
417 use objectiveai_sdk::cli::command::plugins::run::response_schema::{Request, Response};
418
419 use crate::context::Context;
420 use crate::error::Error;
421
422 pub async fn execute(_ctx: &Context, _request: Request) -> Result<Response, Error> {
423 Ok(objectiveai_sdk::cli::command::ResponseSchema(schemars::schema_for!(sdk::ResponseItem)))
424 }
425}