objectiveai_cli/run.rs
1use std::pin::Pin;
2
3use envconfig::Envconfig;
4use futures::Stream;
5use objectiveai_sdk::cli::command::{CommandRequest, ResponseItem, parse_request};
6
7use crate::context::Context;
8use crate::error::Error;
9
10/// Windows-only: clear `HANDLE_FLAG_INHERIT` on this process's
11/// stdin/stdout/stderr handles. Called at the top of the instance
12/// subprocess fast-path so plugin spawns (and any other later
13/// `Stdio::piped()` spawn that triggers `bInheritHandles=TRUE`)
14/// don't leak this process's stdio write ends to those children.
15///
16/// Without this, on Windows a plugin spawned by the instance
17/// subprocess inherits the instance's stdout/stderr write ends.
18/// The plugin keeps those handles open for its whole lifetime
19/// (e.g. an `axum::serve` that runs forever). When the instance
20/// exits, the cli outer's reads of the instance's stdout/stderr
21/// don't see EOF — the plugin is still holding the write ends —
22/// and the cli outer hangs forever waiting for an EOF that never
23/// arrives.
24#[cfg(windows)]
25pub fn clear_stdio_inheritance() {
26 use windows_sys::Win32::Foundation::{HANDLE_FLAG_INHERIT, SetHandleInformation};
27 use windows_sys::Win32::System::Console::{
28 GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE,
29 };
30 // SAFETY: GetStdHandle is sound to call from any thread; the
31 // returned HANDLE is process-global and survives. SetHandleInformation
32 // mutates only the flags on the HANDLE, never the underlying
33 // file/pipe. Failure is best-effort (e.g. handle was already
34 // INVALID_HANDLE_VALUE) and silently ignored — clearing the
35 // inheritance flag is an optimization for child spawns, not a
36 // correctness requirement for our own stdio reads/writes.
37 unsafe {
38 for std_id in [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE] {
39 let h = GetStdHandle(std_id);
40 // GetStdHandle returns INVALID_HANDLE_VALUE on error and
41 // 0 / NULL when the stream isn't attached; skip both.
42 if !h.is_null() && h as isize != -1 {
43 let _ = SetHandleInformation(h, HANDLE_FLAG_INHERIT, 0);
44 }
45 }
46 }
47}
48
49#[derive(Envconfig)]
50struct EnvConfigBuilder {
51 #[envconfig(from = "CONFIG_SET_FORBIDDEN")]
52 config_set_forbidden: Option<String>,
53 #[envconfig(from = "OBJECTIVEAI_DIR")]
54 objectiveai_dir: Option<String>,
55 #[envconfig(from = "OBJECTIVEAI_STATE")]
56 objectiveai_state: Option<String>,
57 #[envconfig(from = "COMMIT_AUTHOR_NAME")]
58 commit_author_name: Option<String>,
59 #[envconfig(from = "COMMIT_AUTHOR_EMAIL")]
60 commit_author_email: Option<String>,
61 #[envconfig(from = "OBJECTIVEAI_AGENT_INSTANCE_HIERARCHY")]
62 agent_instance_hierarchy: Option<String>,
63 #[envconfig(from = "OBJECTIVEAI_AGENT_ID")]
64 agent_id: Option<String>,
65 #[envconfig(from = "OBJECTIVEAI_AGENT_FULL_ID")]
66 agent_full_id: Option<String>,
67 #[envconfig(from = "OBJECTIVEAI_AGENT_REMOTE")]
68 agent_remote: Option<String>,
69 #[envconfig(from = "OBJECTIVEAI_RESPONSE_ID")]
70 response_id: Option<String>,
71 #[envconfig(from = "OBJECTIVEAI_RESPONSE_IDS")]
72 response_ids: Option<String>,
73 #[envconfig(from = "MCP_SESSION_ID")]
74 mcp_session_id: Option<String>,
75 #[envconfig(from = "OBJECTIVEAI_PLUGIN_OWNER")]
76 plugin_owner: Option<String>,
77 #[envconfig(from = "OBJECTIVEAI_PLUGIN_REPOSITORY")]
78 plugin_repository: Option<String>,
79 #[envconfig(from = "OBJECTIVEAI_PLUGIN_VERSION")]
80 plugin_version: Option<String>,
81}
82
83impl EnvConfigBuilder {
84 pub fn build(self) -> ConfigBuilder {
85 fn parse_bool(s: &str) -> bool {
86 let v = s.trim();
87 !v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false")
88 }
89 ConfigBuilder {
90 config_set_forbidden: self.config_set_forbidden.map(|s| parse_bool(&s)),
91 objectiveai_dir: self.objectiveai_dir,
92 objectiveai_state: self.objectiveai_state,
93 commit_author_name: self.commit_author_name,
94 commit_author_email: self.commit_author_email,
95 agent_instance_hierarchy: self.agent_instance_hierarchy,
96 agent_id: self.agent_id,
97 agent_full_id: self.agent_full_id,
98 agent_remote: self.agent_remote,
99 response_id: self.response_id,
100 response_ids: self.response_ids,
101 mcp_session_id: self.mcp_session_id,
102 plugin_owner: self.plugin_owner,
103 plugin_repository: self.plugin_repository,
104 plugin_version: self.plugin_version,
105 }
106 }
107}
108
109#[derive(Default)]
110pub struct ConfigBuilder {
111 pub config_set_forbidden: Option<bool>,
112 pub objectiveai_dir: Option<String>,
113 pub objectiveai_state: Option<String>,
114 pub commit_author_name: Option<String>,
115 pub commit_author_email: Option<String>,
116 pub agent_instance_hierarchy: Option<String>,
117 pub agent_id: Option<String>,
118 pub agent_full_id: Option<String>,
119 pub agent_remote: Option<String>,
120 pub response_id: Option<String>,
121 pub response_ids: Option<String>,
122 pub mcp_session_id: Option<String>,
123 pub plugin_owner: Option<String>,
124 pub plugin_repository: Option<String>,
125 pub plugin_version: Option<String>,
126}
127
128impl Envconfig for ConfigBuilder {
129 #[allow(deprecated)]
130 fn init() -> Result<Self, envconfig::Error> {
131 EnvConfigBuilder::init().map(|e| e.build())
132 }
133
134 fn init_from_env() -> Result<Self, envconfig::Error> {
135 EnvConfigBuilder::init_from_env().map(|e| e.build())
136 }
137
138 fn init_from_hashmap(
139 hashmap: &std::collections::HashMap<String, String>,
140 ) -> Result<Self, envconfig::Error> {
141 EnvConfigBuilder::init_from_hashmap(hashmap).map(|e| e.build())
142 }
143}
144
145impl ConfigBuilder {
146 pub fn build(self) -> Config {
147 Config {
148 config_set_forbidden: self.config_set_forbidden.unwrap_or(false),
149 objectiveai_dir: self.objectiveai_dir,
150 objectiveai_state: self.objectiveai_state,
151 commit_author_name: self.commit_author_name,
152 commit_author_email: self.commit_author_email,
153 agent_instance_hierarchy: self
154 .agent_instance_hierarchy
155 .unwrap_or_else(|| "cli".to_string()),
156 agent_id: self.agent_id,
157 agent_full_id: self.agent_full_id,
158 agent_remote: self.agent_remote,
159 response_id: self.response_id,
160 response_ids: self.response_ids,
161 mcp_session_id: self.mcp_session_id,
162 plugin_owner: self.plugin_owner,
163 plugin_repository: self.plugin_repository,
164 plugin_version: self.plugin_version,
165 }
166 }
167}
168
169#[derive(Debug, Clone)]
170pub struct Config {
171 pub config_set_forbidden: bool,
172 /// Layout root override (`OBJECTIVEAI_DIR`); None = ~/.objectiveai.
173 pub objectiveai_dir: Option<String>,
174 /// State name (`OBJECTIVEAI_STATE`); None = "default".
175 pub objectiveai_state: Option<String>,
176 pub commit_author_name: Option<String>,
177 pub commit_author_email: Option<String>,
178 pub agent_instance_hierarchy: String,
179 pub agent_id: Option<String>,
180 /// WF-level agent identity from `OBJECTIVEAI_AGENT_FULL_ID` / the
181 /// `X-OBJECTIVEAI-AGENT-FULL-ID` reverse-attach header. Propagated
182 /// onto spawned plugin subprocesses by the conduit so plugin-side
183 /// code can stamp it on outbound calls.
184 pub agent_full_id: Option<String>,
185 /// JSON-encoded `RemotePath` from `OBJECTIVEAI_AGENT_REMOTE` /
186 /// the `X-OBJECTIVEAI-AGENT-REMOTE` reverse-attach header. Empty
187 /// when the WF was inline. Propagated onto spawned plugins.
188 pub agent_remote: Option<String>,
189 /// Current response id from `OBJECTIVEAI_RESPONSE_ID` / the
190 /// `X-OBJECTIVEAI-RESPONSE-ID` reverse-attach header. Propagated
191 /// onto spawned plugins so plugin-side code can stamp it on
192 /// outbound calls.
193 pub response_id: Option<String>,
194 /// Comma-separated response id chain from
195 /// `OBJECTIVEAI_RESPONSE_IDS` / `X-OBJECTIVEAI-RESPONSE-IDS`.
196 /// Propagated onto spawned plugins.
197 pub response_ids: Option<String>,
198 pub mcp_session_id: Option<String>,
199 /// Plugin coordinate (`OBJECTIVEAI_PLUGIN_OWNER` / `_REPOSITORY` /
200 /// `_VERSION`) of the plugin a command is running on behalf of.
201 /// Set by `plugins run` on the config used to launch nested
202 /// (plugin-originated) commands; assembled into
203 /// [`crate::context::Context::plugin`] at startup.
204 pub plugin_owner: Option<String>,
205 pub plugin_repository: Option<String>,
206 pub plugin_version: Option<String>,
207}
208
209/// What [`run`] yields, mirroring the two SDK root dispatch entry
210/// points. The arm is chosen by whether the parsed request carries an
211/// output transform (`RequestBase::transform`):
212///
213/// - [`RunStream::Execute`]: no transform — the typed root
214/// [`ResponseItem`] stream (identical to what `run` used to return).
215/// - [`RunStream::ExecuteTransform`]: a transform is set — each item is
216/// the transformed JSON output (`serde_json::Value`).
217pub enum RunStream {
218 Execute(Pin<Box<dyn Stream<Item = Result<ResponseItem, Error>> + Send>>),
219 ExecuteTransform(Pin<Box<dyn Stream<Item = Result<serde_json::Value, Error>> + Send>>),
220}
221
222/// Build the top-level CLI config from the process environment.
223pub fn load_config() -> Config {
224 ConfigBuilder::init_from_env().unwrap_or_default().build()
225}
226
227/// Did clap exit with one of the "successful informational output"
228/// variants? `--help`, `--version`, or a missing-subcommand bail.
229pub fn is_informational(e: &clap::Error) -> bool {
230 use clap::error::ErrorKind;
231 matches!(
232 e.kind(),
233 ErrorKind::DisplayHelp
234 | ErrorKind::DisplayVersion
235 | ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
236 )
237}
238
239/// Run the CLI command tree.
240///
241/// Clap-parse argv against the SDK's top-level command surface;
242/// `TryFrom` it into [`Request`]; resolve [`Context`] (caller-
243/// supplied or built from env); dispatch through the in-process
244/// [`crate::executor::CliCommandExecutor`] via the SDK root
245/// `execute` / `execute_transform` (the latter when the request
246/// carries an output transform). Returns a [`RunStream`] whose
247/// variant reflects that choice.
248///
249/// The `instance` subprocess subcommand is **not** handled here
250/// — it has its own entry point at [`crate::instance::run`]
251/// because its wire shape (`InstanceEmission`) differs from
252/// [`ResponseItem`]. `main.rs` routes argv[1] == "instance" to
253/// that entry directly and writes its own ndjson; ordinary
254/// command flows go through this function.
255///
256/// Pre-dispatch failures (clap parse error, arg-conversion error,
257/// context build) and child-function errors propagate as the outer
258/// `Err`. On success the caller consumes the stream.
259// NOTE: explicit boxed future (not `async fn`). `run` calls the SDK
260// root dispatch through `CliCommandExecutor`, and `tasks run` /
261// `plugins run` re-enter `run` — so `run`, the executor, and the local
262// command dispatch are mutually recursive. An `async fn`'s opaque
263// return type can't be computed through that cycle (E0391); an explicit
264// `Pin<Box<dyn Future + Send>>` return gives the recursion a known type
265// to terminate on.
266pub fn run(
267 args: Vec<String>,
268 ctx: Option<Context>,
269) -> Pin<Box<dyn std::future::Future<Output = Result<RunStream, Error>> + Send>> {
270 Box::pin(async move {
271 // Windows: clear the inheritance flag on this process's
272 // stdin/stdout/stderr handles. They were marked inheritable by
273 // whoever spawned us via `Stdio::piped()` — necessary so we
274 // inherit them at all — but if we leave the flag set, every
275 // grandchild process we (or any of our descendants) spawn with
276 // `Stdio::piped()` (which sets `bInheritHandles=TRUE`) inherits
277 // OUR stdio handles in addition to its own new pipes. That
278 // grandchild (e.g. a plugin RMCP server living for the whole
279 // agent completion) then holds our stdio write ends open even
280 // after we exit, leaving our parent's reads of our stdout/stderr
281 // hanging forever instead of EOF'ing.
282 //
283 // Applies to BOTH branches:
284 // - The `instance` branch (called from a parent cli) so plugin
285 // grandchildren don't inherit the instance's stdio.
286 // - The non-instance branch (the outer cli, called from a test
287 // harness or another shell) so the instance subprocess
288 // doesn't inherit the outer cli's stdio — otherwise an
289 // orphan plugin grandchild keeps the outer cli's stdout
290 // pipe alive after the instance dies, and the harness
291 // hangs waiting for stdout EOF.
292 //
293 // Clearing the flag is a no-op for our own use of std{in,out,err}
294 // — we keep using them normally. It only affects what gets
295 // propagated on subsequent `CreateProcessW` calls.
296 #[cfg(windows)]
297 clear_stdio_inheritance();
298
299 // `Context::new` is synchronous and IO-free; the API client,
300 // viewer client, and db pool connect lazily on first use via
301 // `ctx.{api,viewer,db}_client()`. No explicit setup call needed
302 // here.
303 let ctx = match ctx {
304 Some(c) => c,
305 None => Context::new(load_config()),
306 };
307
308 // `args[0]` is the program name however the binary was invoked —
309 // bare name from PATH, full path from a test harness or a
310 // `current_exe()` self-respawn — never part of the command.
311 // Strip it unconditionally; `parse_request` prepends its own
312 // canonical bin name. (Matching on the literal `"objectiveai"`
313 // inside `parse_request` is NOT enough: a full-path argv[0] like
314 // `C:\...\objectiveai-cli.exe` would be parsed as a subcommand.)
315 let request = parse_request(args.get(1..).unwrap_or_default()).map_err(|e| match e {
316 objectiveai_sdk::cli::command::ParseError::Clap(e) => Error::ClapParse(e),
317 objectiveai_sdk::cli::command::ParseError::FromArgs(e) => Error::FromArgs(e),
318 })?;
319
320 // Drive the request through the in-process executor, picking the
321 // SDK root dispatch entry by whether the request carries an output
322 // transform. The executor applies the transform / token / timeout
323 // adapters; `execute_transform` additionally sets the transform on
324 // the leaf request and yields the post-transform JSON, whereas
325 // `execute` yields the typed root items.
326 let transform = request.request_base().transform();
327 let executor = crate::executor::CliCommandExecutor::new(ctx);
328 match transform {
329 Some(transform) => {
330 let stream =
331 objectiveai_sdk::cli::command::execute_transform(&executor, request, transform, None)
332 .await?;
333 Ok(RunStream::ExecuteTransform(stream))
334 }
335 None => {
336 let stream = objectiveai_sdk::cli::command::execute(&executor, request, None).await?;
337 Ok(RunStream::Execute(stream))
338 }
339 }
340 })
341}