1use std::{fs, path::Path};
2
3use anyhow::{bail, Context, Result};
4use serde::Serialize;
5
6use crate::model::{CrewMode, CrewOwner, CrewPolicy, CrewRouting, Plan};
7
8#[derive(Serialize)]
9struct SharedPolicy<'a> {
10 identity_source: crate::model::IdentitySource,
11 model: &'a Option<String>,
12 tools: Vec<&'static str>,
13}
14
15impl<'a> From<&'a CrewPolicy> for SharedPolicy<'a> {
16 fn from(policy: &'a CrewPolicy) -> Self {
17 Self {
18 identity_source: policy.identity_source,
19 model: &policy.model,
20 tools: policy.tools.iter().map(|tool| tool.as_str()).collect(),
21 }
22 }
23}
24
25#[derive(Serialize)]
26struct ShantytownProjection<'a> {
27 schema: &'static str,
28 shared: SharedPolicy<'a>,
29 settings_owner: &'static str,
30 durable_owner: CrewOwner,
31 routing: Option<CrewRouting>,
32 hooks: &'static str,
33 filesystem: &'static str,
34}
35
36#[derive(Serialize)]
37struct CreelProjection<'a> {
38 schema: &'static str,
39 shared: SharedPolicy<'a>,
40 settings_owner: &'static str,
41 burst_owner: CrewOwner,
42 routing: Option<CrewRouting>,
43 credential_policy: &'static str,
44 browser_permissions: &'static str,
45}
46
47pub fn write(plan: &Plan, output: &Path) -> Result<Vec<String>> {
48 plan.validate()?;
49 let crew = plan
50 .crew
51 .as_ref()
52 .context("settings projection requires a crew profile")?;
53 fs::create_dir_all(output)
54 .with_context(|| format!("create projection directory {}", output.display()))?;
55 let mut written = Vec::new();
56
57 if matches!(crew.mode, CrewMode::Shantytown | CrewMode::Both) {
58 let projection = ShantytownProjection {
59 schema: "caboodle.shantytown-settings/v1",
60 shared: (&crew.policy).into(),
61 settings_owner: "shantytown",
62 durable_owner: crew.durable_owner.context("missing durable owner")?,
63 routing: crew.routing,
64 hooks: "adapter-emitted",
65 filesystem: "host-workspace",
66 };
67 write_json(output, "shantytown.settings.json", &projection)?;
68 written.push("shantytown.settings.json".to_owned());
69 }
70
71 if matches!(crew.mode, CrewMode::Creel | CrewMode::Both) {
72 let projection = CreelProjection {
73 schema: "caboodle.creel-settings/v1",
74 shared: (&crew.policy).into(),
75 settings_owner: "creel",
76 burst_owner: crew.burst_owner.context("missing burst owner")?,
77 routing: crew.routing,
78 credential_policy: "browser-byo-write-only",
79 browser_permissions: "operator-granted",
80 };
81 write_json(output, "creel.settings.json", &projection)?;
82 written.push("creel.settings.json".to_owned());
83 }
84
85 if crew.mode == CrewMode::Standalone {
86 bail!("standalone crew mode has no harness settings to project");
87 }
88 Ok(written)
89}
90
91fn write_json<T: Serialize>(output: &Path, name: &str, value: &T) -> Result<()> {
92 let path = output.join(name);
93 let body = serde_json::to_vec_pretty(value).context("serialize settings projection")?;
94 fs::write(&path, body).with_context(|| format!("write projection {}", path.display()))
95}
96
97pub fn register(root: Option<&Path>, agent: Option<&str>, registry: &str) -> Result<()> {
100 use std::process::{Command, Stdio};
101 let mut command = Command::new("st");
102 if let Some(root) = root {
103 command.arg("--root").arg(root);
104 }
105 command.args(["--registry", registry, "ops", "provision", "--json"]);
106 if let Some(agent) = agent {
107 command.arg("--").arg(agent);
108 }
109 let output = command
110 .stderr(Stdio::inherit())
111 .output()
112 .context("run shantytown registration; install st with ops provision support first")?;
113 if !output.status.success() {
114 bail!("shantytown registration failed ({}); inspect its refusal above; no registration success claimed", output.status);
115 }
116 let receipt: serde_json::Value = serde_json::from_slice(&output.stdout)
119 .context("shantytown returned no valid registration receipt")?;
120 let agents = receipt
121 .get("agents")
122 .and_then(|v| v.as_array())
123 .context("shantytown registration receipt has no agents")?;
124 if receipt["version"] != 1 || receipt["owner"] != "shantytown" || agents.is_empty() {
125 bail!("shantytown registration receipt has an unsupported version, owner, or empty crew");
126 }
127 let mut verified = Vec::new();
128 for row in agents {
129 let name = row
130 .get("name")
131 .and_then(|v| v.as_str())
132 .filter(|v| !v.is_empty())
133 .context("registration receipt has no agent name")?;
134 let servers = row
135 .get("servers")
136 .and_then(|v| v.as_array())
137 .filter(|v| !v.is_empty())
138 .context("registration receipt has no MCP servers")?;
139 if servers
140 .iter()
141 .any(|v| v.as_str().map_or(true, str::is_empty))
142 {
143 bail!("registration receipt contains an invalid MCP server name");
144 }
145 verified.push((name, servers.len()));
146 }
147 for (name, count) in verified {
148 println!("registered: {name} ({count} MCP servers; owner: shantytown)");
149 }
150 println!("Existing sessions load registration on their next start.");
151 Ok(())
152}