Skip to main content

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}