Skip to main content

objectiveai_sdk/cli/command/laboratories/create/
mod.rs

1//! `laboratories create` — create + start a laboratory container (a podman
2//! container the conduit dials as a client-side MCP server). Inputs name the
3//! container per state (`--id`), pick its base image (`--image`), and add
4//! repeatable bind mounts (`--mount host=…,container=…`) and environment
5//! entries (`--env KEY=VALUE`). `--client` is required (a required arg-group,
6//! leaving room to add `--server` later). The leaf echoes back what it created.
7
8use std::str::FromStr;
9
10use crate::cli::command::CommandRequest;
11use crate::cli::command::path_ref::tokenize;
12
13/// Default working directory new agents start in, when `--cwd` is omitted (and
14/// `#[serde(default)]` for older clients that don't send the field).
15fn default_cwd() -> String {
16    "/".to_string()
17}
18
19#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
20#[schemars(rename = "cli.command.laboratories.create.Request")]
21pub struct Request {
22    pub path_type: Path,
23    pub kind: Kind,
24    pub id: String,
25    /// The base image, split (`registry` + `name` + tag XOR digest) —
26    /// a joined reference string is never accepted, so unqualified
27    /// short names are unrepresentable.
28    pub image: crate::laboratories::LaboratoryImage,
29    pub mounts: Vec<Mount>,
30    pub env: Vec<EnvVar>,
31    /// Default working directory new agents start in; defaults to `/`.
32    #[serde(default = "default_cwd")]
33    pub cwd: String,
34    /// The EXACT machine id (as `laboratories list` reports it) whose
35    /// laboratory host should own the container. Provided together
36    /// with `machine_state` or not at all; neither ⇒ the current
37    /// machine + the daemon's own state.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    #[schemars(extend("omitempty" = true))]
40    pub machine: Option<String>,
41    /// The state (on `machine`) whose laboratory host should own the
42    /// container. Paired with `machine` — both or neither.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    #[schemars(extend("omitempty" = true))]
45    pub machine_state: Option<String>,
46    #[serde(flatten)]
47    pub base: crate::cli::command::RequestBase,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
51#[schemars(rename = "cli.command.laboratories.create.Path")]
52pub enum Path {
53    #[serde(rename = "laboratories/create")]
54    LaboratoriesCreate,
55}
56
57/// Which side of the conduit the laboratory serves. Only `Client` (a
58/// client-side MCP server the conduit dials) exists today; the tag-by-`by`
59/// shape leaves room to add `Server` later.
60#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
61#[serde(tag = "by", rename_all = "snake_case")]
62#[schemars(rename = "cli.command.laboratories.create.Kind")]
63pub enum Kind {
64    // No variant-level `#[schemars(title = "...")]`: a single-variant enum
65    // collapses and hoists the variant title to the schema's top-level
66    // `title`, which the JS codegen then uses as the module path — a title
67    // of "Client" would clobber `src/client.ts`. Let `rename` drive it.
68    Client,
69}
70
71/// One host→container bind mount. CLI wire form is the docker-style
72/// `host=…,container=…` (both keys required).
73#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
74#[schemars(rename = "cli.command.laboratories.create.Mount")]
75pub struct Mount {
76    pub host: String,
77    pub container: String,
78}
79
80impl FromStr for Mount {
81    type Err = String;
82    /// Parse a `--mount` arg. Accepted keys: `host`, `container` (both
83    /// required). Anything else is an unknown-key error.
84    fn from_str(s: &str) -> Result<Self, Self::Err> {
85        let mut host: Option<String> = None;
86        let mut container: Option<String> = None;
87        for (k, v) in tokenize(s)? {
88            match k {
89                "host" => host = Some(v.to_string()),
90                "container" => container = Some(v.to_string()),
91                other => return Err(format!("unknown key: {other}")),
92            }
93        }
94        match (host, container) {
95            (Some(host), Some(container)) => Ok(Mount { host, container }),
96            (None, _) => Err("host is required".to_string()),
97            (_, None) => Err("container is required".to_string()),
98        }
99    }
100}
101
102/// One environment entry. CLI wire form is `KEY=VALUE`, split on the first
103/// `=` so values may contain `=`.
104#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
105#[schemars(rename = "cli.command.laboratories.create.EnvVar")]
106pub struct EnvVar {
107    pub key: String,
108    pub value: String,
109}
110
111impl FromStr for EnvVar {
112    type Err = String;
113    /// Parse an `--env` arg. Splits on the FIRST `=`; a missing `=` is an
114    /// error.
115    fn from_str(s: &str) -> Result<Self, Self::Err> {
116        match s.split_once('=') {
117            Some((key, value)) => Ok(EnvVar {
118                key: key.to_string(),
119                value: value.to_string(),
120            }),
121            None => Err(format!("expected KEY=VALUE, got: {s}")),
122        }
123    }
124}
125
126impl CommandRequest for Request {
127    fn request_base(&self) -> &crate::cli::command::RequestBase {
128        &self.base
129    }
130
131    fn request_base_mut(&mut self) -> Option<&mut crate::cli::command::RequestBase> {
132        Some(&mut self.base)
133    }
134}
135
136/// Echo of the created laboratory, from the owning host's reply.
137#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
138#[schemars(rename = "cli.command.laboratories.create.Response")]
139pub struct Response {
140    pub id: String,
141    pub image: crate::laboratories::LaboratoryImage,
142    pub mounts: Vec<Mount>,
143    pub env: Vec<EnvVar>,
144    pub cwd: String,
145    /// Unix seconds when the container was created, from podman's own
146    /// record on the owning host. `None` when the host didn't report
147    /// it.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    #[schemars(extend("omitempty" = true))]
150    pub created_at: Option<i64>,
151    /// The machine whose laboratory host owns the container.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    #[schemars(extend("omitempty" = true))]
154    pub machine: Option<crate::machine::MachineIdentity>,
155    /// The state (on that machine) whose laboratory host owns the
156    /// container.
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    #[schemars(extend("omitempty" = true))]
159    pub machine_state: Option<String>,
160}
161
162#[derive(clap::Args)]
163#[command(
164    group(clap::ArgGroup::new("side").required(true).args(["client"])),
165    group(clap::ArgGroup::new("id_required").required(true).args(["id"])),
166    group(clap::ArgGroup::new("image_source").required(true).args(["registry", "image_inline"])),
167)]
168pub struct Args {
169    /// Create a client-side laboratory (an MCP server the conduit dials).
170    /// Required (part of the `side` arg-group, which will gain `--server`).
171    #[arg(long)]
172    pub client: bool,
173    /// Laboratory id — names the per-state container.
174    #[arg(long)]
175    pub id: Option<String>,
176    /// The image host — e.g. `docker.io`, `ghcr.io`,
177    /// `registry.example.com:5000`. Must be unambiguously a registry
178    /// (domain, host:port, or localhost) — short names are refused.
179    /// Mutually exclusive with `--image-inline`.
180    #[arg(long, requires = "name")]
181    pub registry: Option<String>,
182    /// The image repository path — e.g. `library/bash`. Requires
183    /// `--registry`.
184    #[arg(long, requires = "registry")]
185    pub name: Option<String>,
186    /// Image tag (mutually exclusive with `--digest`; requires
187    /// `--registry`).
188    #[arg(long, conflicts_with_all = ["digest", "image_inline"], requires = "registry")]
189    pub tag: Option<String>,
190    /// Image digest, `<algorithm>:<64 hex>` (mutually exclusive with
191    /// `--tag`; requires `--registry`).
192    #[arg(long, conflicts_with = "image_inline", requires = "registry")]
193    pub digest: Option<String>,
194    /// The image spec INLINE, as a JSON string literal of
195    /// Containerfile content (quoted + escaped on the command line;
196    /// e.g. `--image-inline "\"FROM docker.io/library/bash:latest\n\""`).
197    /// The host builds it on every create against an empty context.
198    /// Mutually exclusive with the `--registry` family.
199    #[arg(long)]
200    pub image_inline: Option<String>,
201    /// Repeatable `--mount host=…,container=…` bind mount.
202    #[arg(long = "mount")]
203    pub mounts: Vec<String>,
204    /// Repeatable `--env KEY=VALUE` environment entry.
205    #[arg(long = "env")]
206    pub env: Vec<String>,
207    /// Default working directory new agents start in; defaults to `/`.
208    #[arg(long)]
209    pub cwd: Option<String>,
210    /// The EXACT machine id (from `laboratories list`) whose host
211    /// should own the container. Requires `--machine-state`; neither ⇒
212    /// the current machine + the daemon's own state.
213    #[arg(long, requires = "machine_state")]
214    pub machine: Option<String>,
215    /// The state (on `--machine`) whose host should own the container.
216    /// Requires `--machine` — both or neither.
217    #[arg(long, requires = "machine")]
218    pub machine_state: Option<String>,
219    #[command(flatten)]
220    pub base: crate::cli::command::RequestBaseArgs,
221}
222
223#[derive(clap::Args)]
224#[command(args_conflicts_with_subcommands = true)]
225pub struct Command {
226    #[command(flatten)]
227    pub args: Args,
228    #[command(subcommand)]
229    pub schema: Option<Schema>,
230}
231
232#[derive(clap::Subcommand)]
233pub enum Schema {
234    /// Emit the JSON Schema for this leaf's `Request` type and exit.
235    RequestSchema(request_schema::Args),
236    /// Emit the JSON Schema for this leaf's `Response` type and exit.
237    ResponseSchema(response_schema::Args),
238}
239
240impl TryFrom<Args> for Request {
241    type Error = crate::cli::command::FromArgsError;
242    fn try_from(args: Args) -> Result<Self, Self::Error> {
243        let id = args.id.ok_or_else(|| {
244            crate::cli::command::FromArgsError::path_parse("id", "--id is required".to_string())
245        })?;
246        // The image source: --image-inline XOR the --registry family.
247        // Clap enforces the coarse exclusivity; this is the friendly
248        // authoritative pass.
249        let image = match (args.image_inline, args.registry) {
250            (Some(inline_json), None) => {
251                // The ARGV value is a JSON string literal; the typed
252                // Request holds the PLAIN decoded Containerfile text.
253                let containerfile: String = serde_json::from_str(&inline_json)
254                    .map_err(|e| {
255                        crate::cli::command::FromArgsError::path_parse(
256                            "image-inline",
257                            format!(
258                                "--image-inline must be a JSON string literal \
259                                 (quoted + escaped): {e}"
260                            ),
261                        )
262                    })?;
263                crate::laboratories::LaboratoryImage::Inline(
264                    crate::laboratories::InlineLaboratoryImage { containerfile },
265                )
266            }
267            (None, Some(registry)) => {
268                let name = args.name.ok_or_else(|| {
269                    crate::cli::command::FromArgsError::path_parse(
270                        "name",
271                        "--name is required with --registry".to_string(),
272                    )
273                })?;
274                let pin = match (args.tag, args.digest) {
275                    (Some(tag), None) => {
276                        crate::laboratories::LaboratoryImagePin::Tag(tag)
277                    }
278                    (None, Some(digest)) => {
279                        crate::laboratories::LaboratoryImagePin::Digest(digest)
280                    }
281                    _ => {
282                        return Err(crate::cli::command::FromArgsError::path_parse(
283                            "image",
284                            "exactly one of --tag, --digest is required \
285                             with --registry"
286                                .to_string(),
287                        ));
288                    }
289                };
290                crate::laboratories::LaboratoryImage::Registry(
291                    crate::laboratories::RegistryLaboratoryImage {
292                        registry,
293                        name,
294                        pin,
295                    },
296                )
297            }
298            _ => {
299                return Err(crate::cli::command::FromArgsError::path_parse(
300                    "image",
301                    "exactly one of --image-inline, --registry is required"
302                        .to_string(),
303                ));
304            }
305        };
306        if !args.client {
307            return Err(crate::cli::command::FromArgsError::path_parse(
308                "client",
309                "--client is required".to_string(),
310            ));
311        }
312        let mounts = args
313            .mounts
314            .iter()
315            .map(|s| {
316                s.parse::<Mount>()
317                    .map_err(|m| crate::cli::command::FromArgsError::path_parse("mount", m))
318            })
319            .collect::<Result<Vec<_>, _>>()?;
320        let env = args
321            .env
322            .iter()
323            .map(|s| {
324                s.parse::<EnvVar>()
325                    .map_err(|m| crate::cli::command::FromArgsError::path_parse("env", m))
326            })
327            .collect::<Result<Vec<_>, _>>()?;
328        let cwd = args.cwd.unwrap_or_else(default_cwd);
329        // Both-or-neither, re-validated beyond clap's mutual
330        // `requires` (which only runs for argv-built requests).
331        if args.machine.is_some() != args.machine_state.is_some() {
332            return Err(crate::cli::command::FromArgsError::path_parse(
333                "machine",
334                "--machine and --machine-state must be provided together".to_string(),
335            ));
336        }
337        Ok(Self {
338            path_type: Path::LaboratoriesCreate,
339            kind: Kind::Client,
340            id,
341            image,
342            mounts,
343            env,
344            cwd,
345            machine: args.machine,
346            machine_state: args.machine_state,
347            base: args.base.into(),
348        })
349    }
350}
351
352#[cfg(feature = "cli-executor")]
353pub async fn execute<E: crate::cli::command::CommandExecutor>(
354    executor: &E,
355    mut request: Request,
356    agent_arguments: Option<&crate::cli::command::AgentArguments>,
357) -> Result<Response, E::Error> {
358    request.base.clear_transform();
359    executor.execute_one(request, agent_arguments).await
360}
361
362#[cfg(feature = "cli-executor")]
363pub async fn execute_transform<E: crate::cli::command::CommandExecutor>(
364    executor: &E,
365    mut request: Request,
366    transform: crate::cli::command::Transform,
367    agent_arguments: Option<&crate::cli::command::AgentArguments>,
368) -> Result<serde_json::Value, E::Error> {
369    request.base.set_transform(transform);
370    executor.execute_one(request, agent_arguments).await
371}
372
373#[cfg(feature = "mcp")]
374impl crate::cli::command::CommandResponse for Response {
375    fn into_mcp(self) -> crate::cli::command::McpResponseItem {
376        crate::cli::command::McpResponseItem::JSONL(serde_json::to_value(self).unwrap())
377    }
378}
379
380pub mod request_schema;
381
382pub mod response_schema;
383
384/// One `/listen` broadcast run of `laboratories create`: the actual
385/// [`Request`], the producer's
386/// [`AgentArguments`](crate::cli::command::AgentArguments), and the
387/// unary response future. See [`crate::cli::broadcast_listener`].
388#[cfg(feature = "cli-listener")]
389pub struct ListenerExecution {
390    pub request: Request,
391    pub agent_arguments: crate::cli::command::AgentArguments,
392    pub response: crate::cli::broadcast_listener::UnaryResponse<Response>,
393}