1use std::str::FromStr;
9
10use crate::cli::command::CommandRequest;
11use crate::cli::command::path_ref::tokenize;
12
13fn 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: crate::laboratories::LaboratoryImage,
29 pub mounts: Vec<Mount>,
30 pub env: Vec<EnvVar>,
31 #[serde(default = "default_cwd")]
33 pub cwd: String,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
39 #[schemars(extend("omitempty" = true))]
40 pub machine: Option<String>,
41 #[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#[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 Client,
69}
70
71#[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 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#[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 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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
149 #[schemars(extend("omitempty" = true))]
150 pub created_at: Option<i64>,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
153 #[schemars(extend("omitempty" = true))]
154 pub machine: Option<crate::machine::MachineIdentity>,
155 #[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 #[arg(long)]
172 pub client: bool,
173 #[arg(long)]
175 pub id: Option<String>,
176 #[arg(long, requires = "name")]
181 pub registry: Option<String>,
182 #[arg(long, requires = "registry")]
185 pub name: Option<String>,
186 #[arg(long, conflicts_with_all = ["digest", "image_inline"], requires = "registry")]
189 pub tag: Option<String>,
190 #[arg(long, conflicts_with = "image_inline", requires = "registry")]
193 pub digest: Option<String>,
194 #[arg(long)]
200 pub image_inline: Option<String>,
201 #[arg(long = "mount")]
203 pub mounts: Vec<String>,
204 #[arg(long = "env")]
206 pub env: Vec<String>,
207 #[arg(long)]
209 pub cwd: Option<String>,
210 #[arg(long, requires = "machine_state")]
214 pub machine: Option<String>,
215 #[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 RequestSchema(request_schema::Args),
236 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 let image = match (args.image_inline, args.registry) {
250 (Some(inline_json), None) => {
251 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 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#[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}