@team-agent/installer 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/Cargo.lock +34 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/Cargo.toml +1 -1
  4. package/crates/team-agent/src/cli/adapters.rs +234 -26
  5. package/crates/team-agent/src/cli/diagnose.rs +144 -10
  6. package/crates/team-agent/src/cli/emit.rs +289 -54
  7. package/crates/team-agent/src/cli/leader.rs +37 -8
  8. package/crates/team-agent/src/cli/mod.rs +1281 -196
  9. package/crates/team-agent/src/cli/status_port.rs +195 -46
  10. package/crates/team-agent/src/cli/tests/divergence.rs +1 -2
  11. package/crates/team-agent/src/cli/tests/lane_c.rs +23 -13
  12. package/crates/team-agent/src/cli/tests/main_preserved.rs +2 -0
  13. package/crates/team-agent/src/cli/tests/run_delegation.rs +59 -3
  14. package/crates/team-agent/src/cli/types.rs +18 -0
  15. package/crates/team-agent/src/compiler.rs +15 -5
  16. package/crates/team-agent/src/coordinator/health.rs +95 -17
  17. package/crates/team-agent/src/coordinator/mod.rs +4 -0
  18. package/crates/team-agent/src/coordinator/runtime_detectors.rs +500 -0
  19. package/crates/team-agent/src/coordinator/runtime_observation.rs +58 -0
  20. package/crates/team-agent/src/coordinator/tick.rs +222 -69
  21. package/crates/team-agent/src/coordinator/types.rs +15 -3
  22. package/crates/team-agent/src/db/schema.rs +37 -2
  23. package/crates/team-agent/src/diagnose/comms.rs +226 -0
  24. package/crates/team-agent/src/diagnose/mod.rs +45 -0
  25. package/crates/team-agent/src/diagnose/orphans.rs +658 -0
  26. package/crates/team-agent/src/fake_worker.rs +146 -3
  27. package/crates/team-agent/src/leader/start.rs +121 -23
  28. package/crates/team-agent/src/leader/types.rs +44 -1
  29. package/crates/team-agent/src/lib.rs +3 -0
  30. package/crates/team-agent/src/lifecycle/display.rs +645 -47
  31. package/crates/team-agent/src/lifecycle/launch.rs +1061 -146
  32. package/crates/team-agent/src/lifecycle/mod.rs +2 -0
  33. package/crates/team-agent/src/lifecycle/profile_launch.rs +810 -0
  34. package/crates/team-agent/src/lifecycle/profile_smoke.rs +522 -0
  35. package/crates/team-agent/src/lifecycle/restart/agent.rs +99 -23
  36. package/crates/team-agent/src/lifecycle/restart/common.rs +183 -24
  37. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +498 -22
  38. package/crates/team-agent/src/lifecycle/restart/remove.rs +27 -7
  39. package/crates/team-agent/src/lifecycle/restart/team_state.rs +19 -0
  40. package/crates/team-agent/src/lifecycle/restart.rs +24 -1
  41. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +5 -5
  42. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +37 -7
  43. package/crates/team-agent/src/lifecycle/types.rs +19 -0
  44. package/crates/team-agent/src/mcp_server/helpers.rs +1 -0
  45. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +341 -0
  46. package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +10 -0
  47. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +158 -0
  48. package/crates/team-agent/src/mcp_server/mod.rs +3 -74
  49. package/crates/team-agent/src/mcp_server/tests/scoped.rs +1 -1
  50. package/crates/team-agent/src/mcp_server/tests/send.rs +6 -5
  51. package/crates/team-agent/src/mcp_server/tools.rs +312 -111
  52. package/crates/team-agent/src/mcp_server/types.rs +6 -4
  53. package/crates/team-agent/src/mcp_server/wire.rs +19 -7
  54. package/crates/team-agent/src/message_store.rs +21 -4
  55. package/crates/team-agent/src/messaging/delivery.rs +470 -59
  56. package/crates/team-agent/src/messaging/mod.rs +9 -6
  57. package/crates/team-agent/src/messaging/results.rs +353 -63
  58. package/crates/team-agent/src/messaging/selftest.rs +199 -12
  59. package/crates/team-agent/src/messaging/send.rs +35 -3
  60. package/crates/team-agent/src/messaging/tests/runtime.rs +19 -4
  61. package/crates/team-agent/src/messaging/types.rs +11 -3
  62. package/crates/team-agent/src/os_probe.rs +119 -0
  63. package/crates/team-agent/src/packaging/migrate.rs +10 -2
  64. package/crates/team-agent/src/packaging/tests.rs +23 -0
  65. package/crates/team-agent/src/provider/adapter.rs +564 -63
  66. package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +1 -7
  67. package/crates/team-agent/src/provider/classify.rs +51 -4
  68. package/crates/team-agent/src/provider/helpers.rs +10 -1
  69. package/crates/team-agent/src/provider/startup_prompt.rs +94 -0
  70. package/crates/team-agent/src/provider/types.rs +47 -0
  71. package/crates/team-agent/src/session_capture.rs +616 -0
  72. package/crates/team-agent/src/state/persist.rs +170 -1
  73. package/crates/team-agent/src/state/projection.rs +141 -8
  74. package/crates/team-agent/src/state/selector.rs +5 -2
  75. package/crates/team-agent/src/tmux_backend.rs +161 -64
  76. package/crates/team-agent/src/transport/test_support.rs +9 -0
  77. package/crates/team-agent/src/transport/tests/wire.rs +4 -0
  78. package/crates/team-agent/src/transport.rs +13 -2
  79. package/package.json +4 -4
@@ -0,0 +1,810 @@
1
+ use std::collections::{BTreeMap, BTreeSet};
2
+ use std::path::{Path, PathBuf};
3
+
4
+ use crate::lifecycle::LifecycleError;
5
+ use crate::model::enums::{AuthMode, Provider};
6
+ use crate::model::yaml::Value as YamlValue;
7
+ use crate::provider::{McpConfig, ProviderCommandOverrides, ProviderProfileLaunch};
8
+
9
+ const COMPATIBLE_NETWORK_ENV_KEYS: &[&str] = &[
10
+ "HTTPS_PROXY",
11
+ "HTTP_PROXY",
12
+ "ALL_PROXY",
13
+ "https_proxy",
14
+ "http_proxy",
15
+ "all_proxy",
16
+ "NO_PROXY",
17
+ "no_proxy",
18
+ "NODE_EXTRA_CA_CERTS",
19
+ "SSL_CERT_FILE",
20
+ "REQUESTS_CA_BUNDLE",
21
+ ];
22
+
23
+ #[derive(Debug, Clone)]
24
+ pub(crate) struct ProfileValues {
25
+ pub(crate) path: PathBuf,
26
+ pub(crate) values: BTreeMap<String, String>,
27
+ }
28
+
29
+ #[derive(Debug, Clone)]
30
+ struct AgentProfileInput {
31
+ id: String,
32
+ provider: Provider,
33
+ auth_mode: AuthMode,
34
+ profile: Option<String>,
35
+ profile_dir: Option<PathBuf>,
36
+ model: Option<String>,
37
+ model_source: Option<String>,
38
+ }
39
+
40
+ pub(crate) fn prepare_provider_profile_launch(
41
+ workspace: &Path,
42
+ agent_id: &str,
43
+ agent: &YamlValue,
44
+ mcp_config: Option<&McpConfig>,
45
+ ) -> Result<ProviderProfileLaunch, LifecycleError> {
46
+ prepare_provider_profile_launch_with_profile_dir(workspace, agent_id, agent, None, mcp_config)
47
+ }
48
+
49
+ pub(crate) fn prepare_provider_profile_launch_with_profile_dir(
50
+ workspace: &Path,
51
+ agent_id: &str,
52
+ agent: &YamlValue,
53
+ profile_dir: Option<&Path>,
54
+ mcp_config: Option<&McpConfig>,
55
+ ) -> Result<ProviderProfileLaunch, LifecycleError> {
56
+ let input = AgentProfileInput {
57
+ id: agent
58
+ .get("id")
59
+ .and_then(YamlValue::as_str)
60
+ .unwrap_or(agent_id)
61
+ .to_string(),
62
+ provider: agent
63
+ .get("provider")
64
+ .and_then(YamlValue::as_str)
65
+ .and_then(parse_provider)
66
+ .unwrap_or(Provider::Codex),
67
+ auth_mode: agent
68
+ .get("auth_mode")
69
+ .and_then(YamlValue::as_str)
70
+ .and_then(parse_auth_mode)
71
+ .unwrap_or(AuthMode::Subscription),
72
+ profile: agent
73
+ .get("profile")
74
+ .and_then(YamlValue::as_str)
75
+ .filter(|value| !value.is_empty())
76
+ .map(str::to_string),
77
+ profile_dir: agent
78
+ .get("_profile_dir")
79
+ .and_then(YamlValue::as_str)
80
+ .filter(|value| !value.is_empty())
81
+ .map(PathBuf::from)
82
+ .or_else(|| profile_dir.map(Path::to_path_buf)),
83
+ model: agent
84
+ .get("model")
85
+ .and_then(YamlValue::as_str)
86
+ .filter(|value| !value.is_empty())
87
+ .map(str::to_string),
88
+ model_source: agent
89
+ .get("model_source")
90
+ .and_then(YamlValue::as_str)
91
+ .filter(|value| !value.is_empty())
92
+ .map(str::to_string),
93
+ };
94
+ prepare_profile_launch(workspace, input, mcp_config)
95
+ }
96
+
97
+ pub(crate) fn prepare_provider_profile_launch_from_json(
98
+ workspace: &Path,
99
+ agent_id: &str,
100
+ agent: &serde_json::Value,
101
+ mcp_config: Option<&McpConfig>,
102
+ ) -> Result<ProviderProfileLaunch, LifecycleError> {
103
+ let input = AgentProfileInput {
104
+ id: agent
105
+ .get("id")
106
+ .and_then(serde_json::Value::as_str)
107
+ .unwrap_or(agent_id)
108
+ .to_string(),
109
+ provider: agent
110
+ .get("provider")
111
+ .and_then(serde_json::Value::as_str)
112
+ .and_then(parse_provider)
113
+ .unwrap_or(Provider::Codex),
114
+ auth_mode: agent
115
+ .get("auth_mode")
116
+ .and_then(serde_json::Value::as_str)
117
+ .and_then(parse_auth_mode)
118
+ .unwrap_or(AuthMode::Subscription),
119
+ profile: agent
120
+ .get("profile")
121
+ .and_then(serde_json::Value::as_str)
122
+ .filter(|value| !value.is_empty())
123
+ .map(str::to_string),
124
+ profile_dir: agent
125
+ .get("_profile_dir")
126
+ .and_then(serde_json::Value::as_str)
127
+ .filter(|value| !value.is_empty())
128
+ .map(PathBuf::from),
129
+ model: agent
130
+ .get("model")
131
+ .and_then(serde_json::Value::as_str)
132
+ .filter(|value| !value.is_empty())
133
+ .map(str::to_string),
134
+ model_source: agent
135
+ .get("model_source")
136
+ .and_then(serde_json::Value::as_str)
137
+ .filter(|value| !value.is_empty())
138
+ .map(str::to_string),
139
+ };
140
+ prepare_profile_launch(workspace, input, mcp_config)
141
+ }
142
+
143
+ fn prepare_profile_launch(
144
+ workspace: &Path,
145
+ agent: AgentProfileInput,
146
+ mcp_config: Option<&McpConfig>,
147
+ ) -> Result<ProviderProfileLaunch, LifecycleError> {
148
+ let Some(profile) = agent.profile.as_deref() else {
149
+ return Ok(ProviderProfileLaunch::default());
150
+ };
151
+ let loaded = load_profile(workspace, profile, agent.profile_dir.as_deref())?;
152
+ validate_profile(&agent, profile, &loaded)?;
153
+
154
+ let mut env_overlay = provider_env_exports(agent.provider, agent.auth_mode, &loaded.values);
155
+ let mut env_unset = provider_env_unsets(agent.provider, agent.auth_mode);
156
+ let mut claude_config_dir = None;
157
+ let mut claude_projects_root = None;
158
+ let mut managed_mcp_config = false;
159
+
160
+ if matches!(agent.provider, Provider::Claude | Provider::ClaudeCode)
161
+ && agent.auth_mode == AuthMode::CompatibleApi
162
+ {
163
+ let dir = compatible_claude_config_dir(workspace, &agent.id)?;
164
+ if let Some(config) = mcp_config {
165
+ ensure_compatible_claude_mcp_config(&dir, workspace, config)?;
166
+ managed_mcp_config = true;
167
+ }
168
+ let projects_root = dir.join("projects");
169
+ env_overlay.insert(
170
+ "CLAUDE_CONFIG_DIR".to_string(),
171
+ dir.to_string_lossy().to_string(),
172
+ );
173
+ claude_projects_root = Some(projects_root);
174
+ claude_config_dir = Some(dir);
175
+ }
176
+
177
+ env_overlay.extend(compatible_api_network_exports(agent.auth_mode, &loaded.values));
178
+ if agent.auth_mode == AuthMode::CompatibleApi && profile_proxy_mode(&loaded.values) == "direct"
179
+ {
180
+ for key in COMPATIBLE_NETWORK_ENV_KEYS {
181
+ env_unset.insert((*key).to_string());
182
+ env_overlay.remove(*key);
183
+ }
184
+ }
185
+
186
+ let command_overrides = provider_command_overrides(&agent, &loaded.values);
187
+ write_runtime_env_file(workspace, &agent.id, &env_overlay, &env_unset)?;
188
+
189
+ Ok(ProviderProfileLaunch {
190
+ env_overlay,
191
+ env_unset,
192
+ command_overrides,
193
+ claude_config_dir,
194
+ claude_projects_root,
195
+ managed_mcp_config,
196
+ })
197
+ }
198
+
199
+ pub(crate) fn load_profile(
200
+ workspace: &Path,
201
+ name: &str,
202
+ profile_dir: Option<&Path>,
203
+ ) -> Result<ProfileValues, LifecycleError> {
204
+ let dirs = profile_lookup_dirs(workspace, profile_dir);
205
+ let path = dirs
206
+ .iter()
207
+ .map(|directory| directory.join(format!("{name}.env")))
208
+ .find(|path| path.exists())
209
+ .or_else(|| {
210
+ dirs.iter()
211
+ .map(|directory| directory.join(format!("{name}.example.env")))
212
+ .find(|path| path.exists())
213
+ })
214
+ .ok_or_else(|| {
215
+ LifecycleError::RequirementUnmet(format!(
216
+ "profile {name} not found; run team-agent profile init {name} --auth-mode subscription"
217
+ ))
218
+ })?;
219
+ let text = std::fs::read_to_string(&path)
220
+ .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", path.display())))?;
221
+ Ok(ProfileValues {
222
+ path,
223
+ values: parse_env_text(&text),
224
+ })
225
+ }
226
+
227
+ fn profile_lookup_dirs(workspace: &Path, profile_dir: Option<&Path>) -> Vec<PathBuf> {
228
+ let mut dirs = Vec::new();
229
+ if let Some(dir) = profile_dir {
230
+ push_unique_path(&mut dirs, dir.to_path_buf());
231
+ }
232
+ push_unique_path(&mut dirs, workspace.join(".team/current/profiles"));
233
+ push_unique_path(&mut dirs, workspace.join("profiles"));
234
+ push_unique_path(&mut dirs, workspace.join(".team/profiles"));
235
+ if workspace.file_name().and_then(|name| name.to_str()) == Some(".team") {
236
+ push_unique_path(&mut dirs, workspace.join("current/profiles"));
237
+ }
238
+ dirs
239
+ }
240
+
241
+ fn push_unique_path(paths: &mut Vec<PathBuf>, path: PathBuf) {
242
+ if !paths.iter().any(|existing| existing == &path) {
243
+ paths.push(path);
244
+ }
245
+ }
246
+
247
+ fn parse_env_text(text: &str) -> BTreeMap<String, String> {
248
+ let mut values = BTreeMap::new();
249
+ for raw in text.lines() {
250
+ let mut line = raw.trim();
251
+ if line.is_empty() || line.starts_with('#') {
252
+ continue;
253
+ }
254
+ if let Some(rest) = line.strip_prefix("export ") {
255
+ line = rest.trim();
256
+ }
257
+ let Some((key, value)) = line.split_once('=') else {
258
+ continue;
259
+ };
260
+ let key = key.trim();
261
+ if !is_profile_key(key) {
262
+ continue;
263
+ }
264
+ values.insert(key.to_string(), strip_env_value(value.trim()));
265
+ }
266
+ values
267
+ }
268
+
269
+ fn strip_env_value(raw: &str) -> String {
270
+ let value = raw.trim();
271
+ if value.len() >= 2 {
272
+ if let Some(inner) = value.strip_prefix('"').and_then(|v| v.strip_suffix('"')) {
273
+ return inner.to_string();
274
+ }
275
+ if let Some(inner) = value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')) {
276
+ return inner.to_string();
277
+ }
278
+ }
279
+ value.to_string()
280
+ }
281
+
282
+ fn is_profile_key(key: &str) -> bool {
283
+ let mut chars = key.chars();
284
+ match chars.next() {
285
+ Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
286
+ _ => return false,
287
+ }
288
+ chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
289
+ }
290
+
291
+ fn validate_profile(
292
+ agent: &AgentProfileInput,
293
+ profile: &str,
294
+ loaded: &ProfileValues,
295
+ ) -> Result<(), LifecycleError> {
296
+ if loaded
297
+ .values
298
+ .get("AUTH_MODE")
299
+ .is_some_and(|mode| parse_auth_mode(mode) != Some(agent.auth_mode))
300
+ {
301
+ return Err(LifecycleError::RequirementUnmet(format!(
302
+ "profile {profile} AUTH_MODE does not match agent auth_mode"
303
+ )));
304
+ }
305
+ if agent.auth_mode == AuthMode::CompatibleApi
306
+ && matches!(agent.model_source.as_deref(), Some("role" | "team"))
307
+ {
308
+ let profile_model = profile_model(&loaded.values);
309
+ if let Some(profile_model) = profile_model {
310
+ if agent.model.as_deref().is_some_and(|model| model != profile_model) {
311
+ return Err(LifecycleError::RequirementUnmet(format!(
312
+ "role/team model does not match profile MODEL in {}",
313
+ loaded.path.display()
314
+ )));
315
+ }
316
+ }
317
+ }
318
+ let mut missing: Vec<&str> = Vec::new();
319
+ for key in required_profile_keys(agent.provider, agent.auth_mode) {
320
+ if key == &"API_KEY" {
321
+ continue;
322
+ }
323
+ if !profile_value_or_alternate(&loaded.values, key).is_some_and(|value| !value.is_empty())
324
+ {
325
+ missing.push(*key);
326
+ }
327
+ }
328
+ if agent.auth_mode == AuthMode::CompatibleApi
329
+ && effective_profile_or_agent_model(agent, &loaded.values).is_none()
330
+ {
331
+ missing.push("MODEL");
332
+ }
333
+ if !missing.is_empty() {
334
+ return Err(LifecycleError::RequirementUnmet(format!(
335
+ "profile {profile} missing required values: {}",
336
+ missing.join(", ")
337
+ )));
338
+ }
339
+ Ok(())
340
+ }
341
+
342
+ fn provider_env_exports(
343
+ provider: Provider,
344
+ auth_mode: AuthMode,
345
+ values: &BTreeMap<String, String>,
346
+ ) -> BTreeMap<String, String> {
347
+ let mut exports = BTreeMap::new();
348
+ if auth_mode == AuthMode::Subscription {
349
+ return exports;
350
+ }
351
+ match provider {
352
+ Provider::Claude | Provider::ClaudeCode => {
353
+ if let Some(value) = value_or_alternate(values, "ANTHROPIC_BASE_URL", "BASE_URL") {
354
+ exports.insert("ANTHROPIC_BASE_URL".to_string(), value.to_string());
355
+ }
356
+ if auth_mode == AuthMode::OfficialApi {
357
+ if let Some(value) = value_or_alternate(values, "ANTHROPIC_API_KEY", "API_KEY") {
358
+ exports.insert("ANTHROPIC_API_KEY".to_string(), value.to_string());
359
+ }
360
+ }
361
+ if let Some(value) = value_or_alternate(values, "ANTHROPIC_AUTH_TOKEN", "AUTH_TOKEN")
362
+ .or_else(|| {
363
+ (auth_mode == AuthMode::CompatibleApi)
364
+ .then(|| value_or_alternate(values, "ANTHROPIC_API_KEY", "API_KEY"))
365
+ .flatten()
366
+ })
367
+ {
368
+ exports.insert("ANTHROPIC_AUTH_TOKEN".to_string(), value.to_string());
369
+ }
370
+ if let Some(value) = profile_model(values) {
371
+ exports.insert("ANTHROPIC_MODEL".to_string(), value.to_string());
372
+ }
373
+ }
374
+ Provider::Codex => {
375
+ if let Some(value) = value_or_alternate(values, "OPENAI_API_KEY", "API_KEY") {
376
+ exports.insert("TEAM_AGENT_PROVIDER_API_KEY".to_string(), value.to_string());
377
+ exports.insert("OPENAI_API_KEY".to_string(), value.to_string());
378
+ }
379
+ if let Some(value) = values.get("BASE_URL").filter(|value| !value.is_empty()) {
380
+ exports.insert("OPENAI_BASE_URL".to_string(), value.to_string());
381
+ }
382
+ }
383
+ Provider::GeminiCli => {
384
+ if let Some(value) = value_or_alternate(values, "GEMINI_API_KEY", "API_KEY") {
385
+ exports.insert("GEMINI_API_KEY".to_string(), value.to_string());
386
+ }
387
+ }
388
+ Provider::Fake => {}
389
+ }
390
+ exports
391
+ }
392
+
393
+ fn provider_env_unsets(provider: Provider, auth_mode: AuthMode) -> BTreeSet<String> {
394
+ let mut unsets = BTreeSet::new();
395
+ match provider {
396
+ Provider::Claude | Provider::ClaudeCode => {
397
+ if auth_mode == AuthMode::CompatibleApi {
398
+ unsets.insert("ANTHROPIC_API_KEY".to_string());
399
+ }
400
+ if auth_mode == AuthMode::OfficialApi {
401
+ unsets.insert("ANTHROPIC_AUTH_TOKEN".to_string());
402
+ }
403
+ }
404
+ Provider::Codex => {
405
+ if auth_mode == AuthMode::CompatibleApi {
406
+ unsets.insert("OPENAI_API_KEY".to_string());
407
+ unsets.insert("OPENAI_BASE_URL".to_string());
408
+ }
409
+ }
410
+ Provider::GeminiCli => {
411
+ if auth_mode == AuthMode::CompatibleApi {
412
+ unsets.insert("GEMINI_API_KEY".to_string());
413
+ }
414
+ }
415
+ Provider::Fake => {}
416
+ }
417
+ unsets
418
+ }
419
+
420
+ fn provider_command_overrides(
421
+ agent: &AgentProfileInput,
422
+ values: &BTreeMap<String, String>,
423
+ ) -> ProviderCommandOverrides {
424
+ let model = match agent.model_source.as_deref() {
425
+ Some("role" | "team") => agent.model.clone(),
426
+ Some("default") => profile_model(values)
427
+ .map(str::to_string)
428
+ .or_else(|| agent.model.clone()),
429
+ _ if agent.model.is_some() => agent.model.clone(),
430
+ _ => profile_model(values).map(str::to_string),
431
+ };
432
+ ProviderCommandOverrides { model }
433
+ }
434
+
435
+ fn write_runtime_env_file(
436
+ workspace: &Path,
437
+ agent_id: &str,
438
+ exports: &BTreeMap<String, String>,
439
+ unsets: &BTreeSet<String>,
440
+ ) -> Result<(), LifecycleError> {
441
+ if exports.is_empty() && unsets.is_empty() {
442
+ return Ok(());
443
+ }
444
+ let dir = workspace.join(".team/runtime/provider-env");
445
+ std::fs::create_dir_all(&dir)
446
+ .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", dir.display())))?;
447
+ let path = dir.join(format!("{agent_id}.env"));
448
+ let mut lines = Vec::new();
449
+ for key in unsets {
450
+ lines.push(format!("unset {key}"));
451
+ }
452
+ for (key, value) in exports {
453
+ lines.push(format!("export {key}={}", shell_quote(value)));
454
+ }
455
+ std::fs::write(&path, format!("{}\n", lines.join("\n")))
456
+ .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", path.display())))?;
457
+ set_owner_only_permissions(&path)?;
458
+ Ok(())
459
+ }
460
+
461
+ #[cfg(unix)]
462
+ fn set_owner_only_permissions(path: &Path) -> Result<(), LifecycleError> {
463
+ use std::os::unix::fs::PermissionsExt;
464
+
465
+ std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
466
+ .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", path.display())))
467
+ }
468
+
469
+ #[cfg(not(unix))]
470
+ fn set_owner_only_permissions(path: &Path) -> Result<(), LifecycleError> {
471
+ let _ = path;
472
+ Ok(())
473
+ }
474
+
475
+ fn shell_quote(value: &str) -> String {
476
+ if value.is_empty() {
477
+ return "''".to_string();
478
+ }
479
+ if value
480
+ .chars()
481
+ .all(|c| c.is_ascii_alphanumeric() || "-_./:@%+=".contains(c))
482
+ {
483
+ return value.to_string();
484
+ }
485
+ format!("'{}'", value.replace('\'', "'\"'\"'"))
486
+ }
487
+
488
+ fn compatible_claude_config_dir(
489
+ workspace: &Path,
490
+ agent_id: &str,
491
+ ) -> Result<PathBuf, LifecycleError> {
492
+ let dir = workspace
493
+ .join(".team/runtime/provider-config")
494
+ .join(agent_id)
495
+ .join("claude");
496
+ std::fs::create_dir_all(&dir)
497
+ .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", dir.display())))?;
498
+ ensure_compatible_claude_config(&dir, workspace)?;
499
+ Ok(dir)
500
+ }
501
+
502
+ fn ensure_compatible_claude_config(dir: &Path, workspace: &Path) -> Result<(), LifecycleError> {
503
+ let settings_path = dir.join("settings.json");
504
+ let mut settings = read_json_object(&settings_path)?;
505
+ insert_if_missing(&mut settings, "theme", serde_json::json!("auto"));
506
+ insert_if_missing(
507
+ &mut settings,
508
+ "skipDangerousModePermissionPrompt",
509
+ serde_json::json!(true),
510
+ );
511
+ settings.insert("hasCompletedOnboarding".to_string(), serde_json::json!(true));
512
+ settings.insert("hasTrustDialogAccepted".to_string(), serde_json::json!(true));
513
+ insert_if_missing(
514
+ &mut settings,
515
+ "lastOnboardingVersion",
516
+ serde_json::json!("2.1.0"),
517
+ );
518
+ insert_if_missing(
519
+ &mut settings,
520
+ "firstStartTime",
521
+ serde_json::json!("1970-01-01T00:00:00.000Z"),
522
+ );
523
+ insert_if_missing(&mut settings, "numStartups", serde_json::json!(0));
524
+ insert_if_missing(
525
+ &mut settings,
526
+ "projectOnboardingSeenCount",
527
+ serde_json::json!(1),
528
+ );
529
+ write_json_object(&settings_path, &settings)?;
530
+
531
+ let state_path = dir.join(".claude.json");
532
+ let mut state = read_json_object(&state_path)?;
533
+ state.insert("hasCompletedOnboarding".to_string(), serde_json::json!(true));
534
+ insert_if_missing(
535
+ &mut state,
536
+ "lastOnboardingVersion",
537
+ serde_json::json!("2.1.0"),
538
+ );
539
+ insert_if_missing(
540
+ &mut state,
541
+ "firstStartTime",
542
+ serde_json::json!("1970-01-01T00:00:00.000Z"),
543
+ );
544
+ insert_if_missing(&mut state, "numStartups", serde_json::json!(0));
545
+ ensure_claude_projects(&mut state, workspace, None);
546
+ write_json_object(&state_path, &state)
547
+ }
548
+
549
+ fn ensure_compatible_claude_mcp_config(
550
+ dir: &Path,
551
+ workspace: &Path,
552
+ mcp_config: &McpConfig,
553
+ ) -> Result<(), LifecycleError> {
554
+ let state_path = dir.join(".claude.json");
555
+ let mut state = read_json_object(&state_path)?;
556
+ state.insert("hasCompletedOnboarding".to_string(), serde_json::json!(true));
557
+ state.insert("hasTrustDialogAccepted".to_string(), serde_json::json!(true));
558
+ insert_if_missing(
559
+ &mut state,
560
+ "projectOnboardingSeenCount",
561
+ serde_json::json!(1),
562
+ );
563
+ state.insert("mcpServers".to_string(), mcp_config.raw.clone());
564
+ state.insert(
565
+ "enabledMcpjsonServers".to_string(),
566
+ serde_json::json!(mcp_server_names(&mcp_config.raw)),
567
+ );
568
+ ensure_claude_projects(&mut state, workspace, Some(&mcp_config.raw));
569
+ write_json_object(&state_path, &state)
570
+ }
571
+
572
+ fn ensure_claude_projects(
573
+ state: &mut serde_json::Map<String, serde_json::Value>,
574
+ workspace: &Path,
575
+ mcp_servers: Option<&serde_json::Value>,
576
+ ) {
577
+ if !state.get("projects").is_some_and(serde_json::Value::is_object) {
578
+ state.insert("projects".to_string(), serde_json::json!({}));
579
+ }
580
+ let Some(projects) = state
581
+ .get_mut("projects")
582
+ .and_then(serde_json::Value::as_object_mut)
583
+ else {
584
+ return;
585
+ };
586
+ for key in claude_project_keys(workspace) {
587
+ let entry = projects.entry(key).or_insert_with(|| serde_json::json!({}));
588
+ if !entry.is_object() {
589
+ *entry = serde_json::json!({});
590
+ }
591
+ let Some(project) = entry.as_object_mut() else {
592
+ continue;
593
+ };
594
+ project.insert("hasTrustDialogAccepted".to_string(), serde_json::json!(true));
595
+ insert_if_missing(project, "projectOnboardingSeenCount", serde_json::json!(1));
596
+ if let Some(mcp_servers) = mcp_servers {
597
+ insert_if_missing(project, "allowedTools", serde_json::json!([]));
598
+ insert_if_missing(project, "mcpContextUris", serde_json::json!([]));
599
+ project.insert(
600
+ "enabledMcpjsonServers".to_string(),
601
+ serde_json::json!(mcp_server_names(mcp_servers)),
602
+ );
603
+ insert_if_missing(project, "disabledMcpjsonServers", serde_json::json!([]));
604
+ insert_if_missing(
605
+ project,
606
+ "hasClaudeMdExternalIncludesApproved",
607
+ serde_json::json!(false),
608
+ );
609
+ insert_if_missing(
610
+ project,
611
+ "hasClaudeMdExternalIncludesWarningShown",
612
+ serde_json::json!(false),
613
+ );
614
+ if !project
615
+ .get("mcpServers")
616
+ .is_some_and(serde_json::Value::is_object)
617
+ {
618
+ project.insert("mcpServers".to_string(), serde_json::json!({}));
619
+ }
620
+ if let Some(existing) = project
621
+ .get_mut("mcpServers")
622
+ .and_then(serde_json::Value::as_object_mut)
623
+ {
624
+ if let Some(servers) = mcp_servers.as_object() {
625
+ existing.extend(servers.clone());
626
+ }
627
+ }
628
+ }
629
+ }
630
+ }
631
+
632
+ fn mcp_server_names(mcp_servers: &serde_json::Value) -> Vec<String> {
633
+ mcp_servers
634
+ .as_object()
635
+ .map(|servers| servers.keys().cloned().collect())
636
+ .unwrap_or_default()
637
+ }
638
+
639
+ fn read_json_object(
640
+ path: &Path,
641
+ ) -> Result<serde_json::Map<String, serde_json::Value>, LifecycleError> {
642
+ let Ok(text) = std::fs::read_to_string(path) else {
643
+ return Ok(serde_json::Map::new());
644
+ };
645
+ match serde_json::from_str::<serde_json::Value>(&text) {
646
+ Ok(serde_json::Value::Object(map)) => Ok(map),
647
+ Ok(_) | Err(_) => Ok(serde_json::Map::new()),
648
+ }
649
+ }
650
+
651
+ fn write_json_object(
652
+ path: &Path,
653
+ value: &serde_json::Map<String, serde_json::Value>,
654
+ ) -> Result<(), LifecycleError> {
655
+ if let Some(parent) = path.parent() {
656
+ std::fs::create_dir_all(parent)
657
+ .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", parent.display())))?;
658
+ }
659
+ let body = serde_json::to_string_pretty(value)
660
+ .map_err(|e| LifecycleError::StatePersist(format!("serialize {}: {e}", path.display())))?;
661
+ std::fs::write(path, format!("{body}\n"))
662
+ .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", path.display())))?;
663
+ set_owner_only_permissions(path)
664
+ }
665
+
666
+ fn insert_if_missing(
667
+ map: &mut serde_json::Map<String, serde_json::Value>,
668
+ key: &str,
669
+ value: serde_json::Value,
670
+ ) {
671
+ map.entry(key.to_string()).or_insert(value);
672
+ }
673
+
674
+ fn claude_project_keys(workspace: &Path) -> Vec<String> {
675
+ let mut keys = vec![workspace.to_string_lossy().to_string()];
676
+ if let Ok(resolved) = workspace.canonicalize() {
677
+ let resolved = resolved.to_string_lossy().to_string();
678
+ if !keys.iter().any(|key| key == &resolved) {
679
+ keys.push(resolved);
680
+ }
681
+ }
682
+ for key in keys.clone() {
683
+ if let Some(stripped) = key.strip_prefix("/private/var/") {
684
+ push_unique_string(&mut keys, format!("/var/{stripped}"));
685
+ } else if let Some(stripped) = key.strip_prefix("/var/") {
686
+ push_unique_string(&mut keys, format!("/private/var/{stripped}"));
687
+ }
688
+ }
689
+ keys
690
+ }
691
+
692
+ fn push_unique_string(values: &mut Vec<String>, value: String) {
693
+ if !values.iter().any(|existing| existing == &value) {
694
+ values.push(value);
695
+ }
696
+ }
697
+
698
+ fn compatible_api_network_exports(
699
+ auth_mode: AuthMode,
700
+ values: &BTreeMap<String, String>,
701
+ ) -> BTreeMap<String, String> {
702
+ if auth_mode != AuthMode::CompatibleApi || profile_proxy_mode(values) == "direct" {
703
+ return BTreeMap::new();
704
+ }
705
+ COMPATIBLE_NETWORK_ENV_KEYS
706
+ .iter()
707
+ .filter_map(|key| {
708
+ values
709
+ .get(*key)
710
+ .filter(|value| !value.is_empty())
711
+ .map(|value| ((*key).to_string(), value.clone()))
712
+ })
713
+ .collect()
714
+ }
715
+
716
+ pub(crate) fn profile_proxy_mode(values: &BTreeMap<String, String>) -> String {
717
+ values
718
+ .get("PROXY_MODE")
719
+ .or_else(|| values.get("NETWORK_MODE"))
720
+ .map(|value| value.trim().to_ascii_lowercase())
721
+ .unwrap_or_else(|| "inherit".to_string())
722
+ }
723
+
724
+ fn required_profile_keys(provider: Provider, auth_mode: AuthMode) -> &'static [&'static str] {
725
+ if auth_mode == AuthMode::Subscription {
726
+ return &[];
727
+ }
728
+ match provider {
729
+ Provider::Claude | Provider::ClaudeCode | Provider::Codex => match auth_mode {
730
+ AuthMode::OfficialApi => &["API_KEY"],
731
+ AuthMode::CompatibleApi => &["BASE_URL", "API_KEY"],
732
+ AuthMode::Subscription => &[],
733
+ },
734
+ Provider::GeminiCli => &["API_KEY"],
735
+ Provider::Fake => &[],
736
+ }
737
+ }
738
+
739
+ pub(crate) fn profile_value_or_alternate<'a>(
740
+ values: &'a BTreeMap<String, String>,
741
+ key: &str,
742
+ ) -> Option<&'a str> {
743
+ values
744
+ .get(key)
745
+ .filter(|value| !value.is_empty())
746
+ .map(String::as_str)
747
+ .or_else(|| match key {
748
+ "API_KEY" => values
749
+ .get("ANTHROPIC_API_KEY")
750
+ .or_else(|| values.get("ANTHROPIC_AUTH_TOKEN"))
751
+ .or_else(|| values.get("AUTH_TOKEN"))
752
+ .or_else(|| values.get("CLAUDE_API_KEY"))
753
+ .or_else(|| values.get("CLAUDE_AUTH_TOKEN"))
754
+ .or_else(|| values.get("BEARER_TOKEN"))
755
+ .or_else(|| values.get("TEAM_AGENT_PROVIDER_API_KEY"))
756
+ .or_else(|| values.get("OPENAI_API_KEY")),
757
+ "BASE_URL" => values.get("ANTHROPIC_BASE_URL").or_else(|| values.get("OPENAI_BASE_URL")),
758
+ "MODEL" => values.get("ANTHROPIC_MODEL"),
759
+ _ => None,
760
+ }
761
+ .filter(|value| !value.is_empty())
762
+ .map(String::as_str))
763
+ }
764
+
765
+ pub(crate) fn value_or_alternate<'a>(
766
+ values: &'a BTreeMap<String, String>,
767
+ primary: &str,
768
+ alternate: &str,
769
+ ) -> Option<&'a str> {
770
+ values
771
+ .get(primary)
772
+ .filter(|value| !value.is_empty())
773
+ .or_else(|| values.get(alternate).filter(|value| !value.is_empty()))
774
+ .map(String::as_str)
775
+ }
776
+
777
+ pub(crate) fn profile_model(values: &BTreeMap<String, String>) -> Option<&str> {
778
+ values
779
+ .get("MODEL")
780
+ .filter(|value| !value.is_empty())
781
+ .or_else(|| values.get("ANTHROPIC_MODEL").filter(|value| !value.is_empty()))
782
+ .map(String::as_str)
783
+ }
784
+
785
+ fn effective_profile_or_agent_model<'a>(
786
+ agent: &'a AgentProfileInput,
787
+ values: &'a BTreeMap<String, String>,
788
+ ) -> Option<&'a str> {
789
+ agent.model.as_deref().or_else(|| profile_model(values))
790
+ }
791
+
792
+ pub(crate) fn parse_provider(raw: &str) -> Option<Provider> {
793
+ match raw {
794
+ "claude" => Some(Provider::Claude),
795
+ "claude_code" => Some(Provider::ClaudeCode),
796
+ "codex" => Some(Provider::Codex),
797
+ "gemini_cli" => Some(Provider::GeminiCli),
798
+ "fake" => Some(Provider::Fake),
799
+ _ => None,
800
+ }
801
+ }
802
+
803
+ pub(crate) fn parse_auth_mode(raw: &str) -> Option<AuthMode> {
804
+ match raw {
805
+ "subscription" => Some(AuthMode::Subscription),
806
+ "official_api" => Some(AuthMode::OfficialApi),
807
+ "compatible_api" => Some(AuthMode::CompatibleApi),
808
+ _ => None,
809
+ }
810
+ }