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    pub image: String,
26    pub mounts: Vec<Mount>,
27    pub env: Vec<EnvVar>,
28    /// Default working directory new agents start in; defaults to `/`.
29    #[serde(default = "default_cwd")]
30    pub cwd: String,
31    #[serde(flatten)]
32    pub base: crate::cli::command::RequestBase,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
36#[schemars(rename = "cli.command.laboratories.create.Path")]
37pub enum Path {
38    #[serde(rename = "laboratories/create")]
39    LaboratoriesCreate,
40}
41
42/// Which side of the conduit the laboratory serves. Only `Client` (a
43/// client-side MCP server the conduit dials) exists today; the tag-by-`by`
44/// shape leaves room to add `Server` later.
45#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
46#[serde(tag = "by", rename_all = "snake_case")]
47#[schemars(rename = "cli.command.laboratories.create.Kind")]
48pub enum Kind {
49    // No variant-level `#[schemars(title = "...")]`: a single-variant enum
50    // collapses and hoists the variant title to the schema's top-level
51    // `title`, which the JS codegen then uses as the module path — a title
52    // of "Client" would clobber `src/client.ts`. Let `rename` drive it.
53    Client,
54}
55
56/// One host→container bind mount. CLI wire form is the docker-style
57/// `host=…,container=…` (both keys required).
58#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
59#[schemars(rename = "cli.command.laboratories.create.Mount")]
60pub struct Mount {
61    pub host: String,
62    pub container: String,
63}
64
65impl FromStr for Mount {
66    type Err = String;
67    /// Parse a `--mount` arg. Accepted keys: `host`, `container` (both
68    /// required). Anything else is an unknown-key error.
69    fn from_str(s: &str) -> Result<Self, Self::Err> {
70        let mut host: Option<String> = None;
71        let mut container: Option<String> = None;
72        for (k, v) in tokenize(s)? {
73            match k {
74                "host" => host = Some(v.to_string()),
75                "container" => container = Some(v.to_string()),
76                other => return Err(format!("unknown key: {other}")),
77            }
78        }
79        match (host, container) {
80            (Some(host), Some(container)) => Ok(Mount { host, container }),
81            (None, _) => Err("host is required".to_string()),
82            (_, None) => Err("container is required".to_string()),
83        }
84    }
85}
86
87/// One environment entry. CLI wire form is `KEY=VALUE`, split on the first
88/// `=` so values may contain `=`.
89#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
90#[schemars(rename = "cli.command.laboratories.create.EnvVar")]
91pub struct EnvVar {
92    pub key: String,
93    pub value: String,
94}
95
96impl FromStr for EnvVar {
97    type Err = String;
98    /// Parse an `--env` arg. Splits on the FIRST `=`; a missing `=` is an
99    /// error.
100    fn from_str(s: &str) -> Result<Self, Self::Err> {
101        match s.split_once('=') {
102            Some((key, value)) => Ok(EnvVar {
103                key: key.to_string(),
104                value: value.to_string(),
105            }),
106            None => Err(format!("expected KEY=VALUE, got: {s}")),
107        }
108    }
109}
110
111impl CommandRequest for Request {
112    fn request_base(&self) -> &crate::cli::command::RequestBase {
113        &self.base
114    }
115
116    fn request_base_mut(&mut self) -> Option<&mut crate::cli::command::RequestBase> {
117        Some(&mut self.base)
118    }
119}
120
121/// Echo of the created laboratory.
122#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
123#[schemars(rename = "cli.command.laboratories.create.Response")]
124pub struct Response {
125    pub id: String,
126    pub image: String,
127    pub mounts: Vec<Mount>,
128    pub env: Vec<EnvVar>,
129    pub cwd: String,
130}
131
132#[derive(clap::Args)]
133#[command(
134    group(clap::ArgGroup::new("side").required(true).args(["client"])),
135    group(clap::ArgGroup::new("id_required").required(true).args(["id"])),
136    group(clap::ArgGroup::new("image_required").required(true).args(["image"])),
137)]
138pub struct Args {
139    /// Create a client-side laboratory (an MCP server the conduit dials).
140    /// Required (part of the `side` arg-group, which will gain `--server`).
141    #[arg(long)]
142    pub client: bool,
143    /// Laboratory id — names the per-state container.
144    #[arg(long)]
145    pub id: Option<String>,
146    /// Container image to base the laboratory on.
147    #[arg(long)]
148    pub image: Option<String>,
149    /// Repeatable `--mount host=…,container=…` bind mount.
150    #[arg(long = "mount")]
151    pub mounts: Vec<String>,
152    /// Repeatable `--env KEY=VALUE` environment entry.
153    #[arg(long = "env")]
154    pub env: Vec<String>,
155    /// Default working directory new agents start in; defaults to `/`.
156    #[arg(long)]
157    pub cwd: Option<String>,
158    #[command(flatten)]
159    pub base: crate::cli::command::RequestBaseArgs,
160}
161
162#[derive(clap::Args)]
163#[command(args_conflicts_with_subcommands = true)]
164pub struct Command {
165    #[command(flatten)]
166    pub args: Args,
167    #[command(subcommand)]
168    pub schema: Option<Schema>,
169}
170
171#[derive(clap::Subcommand)]
172pub enum Schema {
173    /// Emit the JSON Schema for this leaf's `Request` type and exit.
174    RequestSchema(request_schema::Args),
175    /// Emit the JSON Schema for this leaf's `Response` type and exit.
176    ResponseSchema(response_schema::Args),
177}
178
179impl TryFrom<Args> for Request {
180    type Error = crate::cli::command::FromArgsError;
181    fn try_from(args: Args) -> Result<Self, Self::Error> {
182        let id = args.id.ok_or_else(|| {
183            crate::cli::command::FromArgsError::path_parse("id", "--id is required".to_string())
184        })?;
185        let image = args.image.ok_or_else(|| {
186            crate::cli::command::FromArgsError::path_parse(
187                "image",
188                "--image is required".to_string(),
189            )
190        })?;
191        if !args.client {
192            return Err(crate::cli::command::FromArgsError::path_parse(
193                "client",
194                "--client is required".to_string(),
195            ));
196        }
197        let mounts = args
198            .mounts
199            .iter()
200            .map(|s| {
201                s.parse::<Mount>()
202                    .map_err(|m| crate::cli::command::FromArgsError::path_parse("mount", m))
203            })
204            .collect::<Result<Vec<_>, _>>()?;
205        let env = args
206            .env
207            .iter()
208            .map(|s| {
209                s.parse::<EnvVar>()
210                    .map_err(|m| crate::cli::command::FromArgsError::path_parse("env", m))
211            })
212            .collect::<Result<Vec<_>, _>>()?;
213        let cwd = args.cwd.unwrap_or_else(default_cwd);
214        Ok(Self {
215            path_type: Path::LaboratoriesCreate,
216            kind: Kind::Client,
217            id,
218            image,
219            mounts,
220            env,
221            cwd,
222            base: args.base.into(),
223        })
224    }
225}
226
227#[cfg(feature = "cli-executor")]
228pub async fn execute<E: crate::cli::command::CommandExecutor>(
229    executor: &E,
230    mut request: Request,
231    agent_arguments: Option<&crate::cli::command::AgentArguments>,
232) -> Result<Response, E::Error> {
233    request.base.clear_transform();
234    executor.execute_one(request, agent_arguments).await
235}
236
237#[cfg(feature = "cli-executor")]
238pub async fn execute_transform<E: crate::cli::command::CommandExecutor>(
239    executor: &E,
240    mut request: Request,
241    transform: crate::cli::command::Transform,
242    agent_arguments: Option<&crate::cli::command::AgentArguments>,
243) -> Result<serde_json::Value, E::Error> {
244    request.base.set_transform(transform);
245    executor.execute_one(request, agent_arguments).await
246}
247
248#[cfg(feature = "mcp")]
249impl crate::cli::command::CommandResponse for Response {
250    fn into_mcp(self) -> crate::cli::command::McpResponseItem {
251        crate::cli::command::McpResponseItem::JSONL(serde_json::to_value(self).unwrap())
252    }
253}
254
255pub mod request_schema;
256
257pub mod response_schema;