@team-agent/installer 0.5.83 → 0.5.85

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.
package/Cargo.lock CHANGED
@@ -581,7 +581,7 @@ dependencies = [
581
581
 
582
582
  [[package]]
583
583
  name = "team-agent"
584
- version = "0.5.83"
584
+ version = "0.5.85"
585
585
  dependencies = [
586
586
  "anyhow",
587
587
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent", "crates/win-conpty-phase0", "crates/conpty-trans
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.5.83"
12
+ version = "0.5.85"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -418,7 +418,7 @@ fn command_help(command: Option<&str>) -> String {
418
418
  .to_string(),
419
419
  Some("allow-peer-talk") => "usage: team-agent allow-peer-talk A B [--workspace WORKSPACE] [--json]".to_string(),
420
420
  Some("status") => "usage: team-agent status [AGENT] [--workspace WORKSPACE] [--team TEAM] [--summary|--json] [--detail]\n\n输出七字段:name/provider/runtime_status/activity/health/session_name/tmux_command;人读与 --json 使用同一投影。缺少可靠定位或 nodeprobe 证据时显示 unknown;tmux_command 可复制到对应目标。--summary/--detail 仅保留兼容性,不增加诊断字段。".to_string(),
421
- Some("models") => "usage: team-agent models --provider pi [--search TEXT] [--json]\n\nPrints models.v1 exact role_model entries; each entry includes current=true|false. Catalog readiness is reported as auth=ok|not_ready with auth_basis=catalog_visibility.".to_string(),
421
+ Some("models") => "usage: team-agent models [--provider pi|cursor_agent] [QUERY|--search TEXT] [--json]\n\nLists exact provider model ids with case-insensitive multi-word search across provider, vendor, id, and display name. Cursor uses the local `agent --list-models` catalog.".to_string(),
422
422
  Some("leaders") => "usage: team-agent leaders [QUERY|--search TEXT] [--all|--stale] [--json] | --prune [--dry-run] [--json]\n\nLists LIVE leaders by default. Use --all to include retained STALE entries, --stale to inspect only STALE entries, QUERY or --search TEXT to match workspace/team/name fields, and --prune to remove only entries proven terminal by canonical state. --dry-run is valid only with --prune.".to_string(),
423
423
  Some("stop") => compat_hidden_help("stop", "usage: team-agent stop [--workspace WORKSPACE] [--team TEAM] [--keep-logs] [--json]"),
424
424
  Some("shutdown") => "usage: team-agent shutdown [--workspace WORKSPACE] [--team TEAM] [--keep-logs] [--json]".to_string(),
@@ -1007,6 +1007,9 @@ fn parse_args(args: &[String]) -> ParsedArgs {
1007
1007
  other if other.starts_with("--provider=") => {
1008
1008
  parsed.provider = Some(other.trim_start_matches("--provider=").to_string());
1009
1009
  }
1010
+ other if other.starts_with("--search=") => {
1011
+ parsed.search = Some(other.trim_start_matches("--search=").to_string());
1012
+ }
1010
1013
  other if other.starts_with("--socket=") => {
1011
1014
  parsed.socket = Some(other.trim_start_matches("--socket=").to_string());
1012
1015
  }
@@ -1106,17 +1109,19 @@ fn quick_start_args(args: &[String], cwd: &Path) -> Result<QuickStartArgs, CliEr
1106
1109
 
1107
1110
  fn models_args(args: &[String]) -> Result<ModelsArgs, CliError> {
1108
1111
  let parsed = parse_args(args);
1109
- let provider = parsed
1110
- .provider
1111
- .ok_or_else(|| CliError::Usage("models requires --provider pi".to_string()))?;
1112
- if provider != "pi" {
1113
- return Err(CliError::Usage(format!(
1114
- "models supports only --provider pi, got {provider:?}"
1115
- )));
1112
+ let provider = parsed.provider.unwrap_or_else(|| "pi".to_string());
1113
+ if parsed.search.is_some() && !parsed.positionals.is_empty() {
1114
+ return Err(CliError::Usage(
1115
+ "models accepts either QUERY or --search TEXT, not both".to_string(),
1116
+ ));
1116
1117
  }
1118
+ let search = parsed
1119
+ .search
1120
+ .or_else(|| (!parsed.positionals.is_empty()).then(|| parsed.positionals.join(" ")))
1121
+ .filter(|query| !query.trim().is_empty());
1117
1122
  Ok(ModelsArgs {
1118
1123
  provider,
1119
- search: parsed.search,
1124
+ search,
1120
1125
  json: parsed.json,
1121
1126
  })
1122
1127
  }
@@ -2385,6 +2390,30 @@ mod tests {
2385
2390
  assert!(!help.contains("错误细分走 status --summary"));
2386
2391
  }
2387
2392
 
2393
+ #[test]
2394
+ fn models_defaults_provider_and_accepts_one_query_form() {
2395
+ let defaulted = models_args(&cli_argv(&["gpt", "5.6", "luna"])).unwrap();
2396
+ assert_eq!(defaulted.provider, "pi");
2397
+ assert_eq!(defaulted.search.as_deref(), Some("gpt 5.6 luna"));
2398
+
2399
+ let cursor = models_args(&cli_argv(&[
2400
+ "--provider",
2401
+ "cursor_agent",
2402
+ "--search",
2403
+ "GPT Luna",
2404
+ ]))
2405
+ .unwrap();
2406
+ assert_eq!(cursor.provider, "cursor_agent");
2407
+ assert_eq!(cursor.search.as_deref(), Some("GPT Luna"));
2408
+
2409
+ let both = models_args(&cli_argv(&["luna", "--search", "gpt"])).unwrap_err();
2410
+ assert!(
2411
+ matches!(both, CliError::Usage(message) if message.contains("either QUERY or --search TEXT"))
2412
+ );
2413
+ let blank = models_args(&cli_argv(&["--search", " "])).unwrap();
2414
+ assert_eq!(blank.search, None);
2415
+ }
2416
+
2388
2417
  #[test]
2389
2418
  fn send_pane_positionals_are_message_not_target() {
2390
2419
  let cwd = tmp_workspace();
@@ -1,53 +1,98 @@
1
- //! Read-only Pi model catalog discovery for `team-agent models`.
1
+ //! Read-only provider model catalog discovery for `team-agent models`.
2
2
  use super::{CliError, CmdOutput, CmdResult, ExitCode, ModelsArgs};
3
3
  use serde_json::{json, Value};
4
4
  #[cfg(test)]
5
5
  use std::collections::BTreeMap;
6
6
  use std::path::Path;
7
- use std::process::Command;
8
7
  use std::time::Duration;
9
8
  const CATALOG_TIMEOUT: Duration = Duration::from_secs(10);
10
9
  const MAX_CATALOG_BYTES: u64 = 1024 * 1024;
11
10
 
11
+ #[derive(Clone, Copy, Debug, Eq, PartialEq)]
12
+ enum CatalogFormat {
13
+ Pi,
14
+ CursorAgent,
15
+ }
16
+
17
+ #[derive(Clone, Debug)]
18
+ struct ModelRecord {
19
+ provider: String,
20
+ vendor: String,
21
+ id: String,
22
+ display_name: String,
23
+ default: Option<bool>,
24
+ }
25
+
12
26
  pub fn cmd_models(args: &ModelsArgs) -> Result<CmdResult, CliError> {
13
- cmd_models_with(args, Path::new("pi"), CATALOG_TIMEOUT, MAX_CATALOG_BYTES)
27
+ let (program, format) = match args.provider.as_str() {
28
+ "pi" => (Path::new("pi"), CatalogFormat::Pi),
29
+ "cursor_agent" => (Path::new("agent"), CatalogFormat::CursorAgent),
30
+ provider => {
31
+ return Ok(failure(
32
+ args,
33
+ &format!("unsupported model provider {provider:?}"),
34
+ ))
35
+ }
36
+ };
37
+ cmd_models_with_format(args, program, format, CATALOG_TIMEOUT, MAX_CATALOG_BYTES)
14
38
  }
15
39
 
40
+ /// Existing injectable Pi boundary retained for deterministic command/timeout tests.
16
41
  fn cmd_models_with(
17
42
  args: &ModelsArgs,
18
43
  program: &Path,
19
44
  timeout: Duration,
20
45
  max_bytes: u64,
46
+ ) -> Result<CmdResult, CliError> {
47
+ cmd_models_with_format(args, program, CatalogFormat::Pi, timeout, max_bytes)
48
+ }
49
+
50
+ fn cmd_models_with_format(
51
+ args: &ModelsArgs,
52
+ program: &Path,
53
+ format: CatalogFormat,
54
+ timeout: Duration,
55
+ max_bytes: u64,
21
56
  ) -> Result<CmdResult, CliError> {
22
57
  let bytes = match run_catalog(program, timeout, max_bytes) {
23
58
  Ok(bytes) => bytes,
24
- Err(message) => return Ok(failure(args, &message)),
59
+ Err(message) => return Ok(failure(args, &catalog_error_prefix(format, message))),
25
60
  };
26
- let all = match crate::lifecycle::launch::pi_mcp::parse_pi_list_models_table(&bytes) {
61
+ let all = match parse_catalog(&bytes, format) {
27
62
  Ok(models) => models,
28
- Err(error) => return Ok(failure(args, &safe_catalog_error(error))),
63
+ Err(message) => return Ok(failure(args, &message)),
29
64
  };
30
65
  let visible = args.search.as_deref().map_or_else(
31
66
  || all.clone(),
32
67
  |search| {
33
68
  all.iter()
34
- .filter(|model| model.contains(search))
69
+ .filter(|model| model_matches(model, search))
35
70
  .cloned()
36
71
  .collect::<Vec<_>>()
37
72
  },
38
73
  );
39
- let current = current_role_model(&all);
74
+ let current = current_model_for(format, &all);
40
75
  let entries: Vec<Value> = visible
41
76
  .iter()
42
77
  .map(|model| {
43
78
  json!({
44
- "role_model": model,
45
- "current": current.as_deref() == Some(model.as_str()),
79
+ "role_model": model.id,
80
+ "current": if format == CatalogFormat::Pi {
81
+ json!(current.as_deref() == Some(model.id.as_str()))
82
+ } else {
83
+ Value::Null
84
+ },
85
+ "provider": model.provider,
86
+ "vendor": model.vendor,
87
+ "model_id": model.id,
88
+ "display_name": model.display_name,
89
+ "default": model.default,
46
90
  })
47
91
  })
48
92
  .collect();
93
+ let provider = args.provider.as_str();
49
94
  let value = json!({
50
- "schema_version": "models.v1", "ok": true, "provider": "pi", "models": entries,
95
+ "schema_version": "models.v1", "ok": true, "provider": provider, "models": entries,
51
96
  "auth": "ok", "auth_basis": "catalog_visibility", "current_role_model": current, "search": args.search,
52
97
  });
53
98
  if args.json {
@@ -59,11 +104,25 @@ fn cmd_models_with(
59
104
  preserve_json_order: true,
60
105
  })
61
106
  } else {
62
- let mut lines = vec!["models.v1 | Pi models (copyable role_model):".to_string()];
107
+ let mut lines = vec![format!(
108
+ "models.v1 | {provider} models (copyable model_id):"
109
+ )];
110
+ lines.push("PROVIDER VENDOR MODEL_ID DISPLAY_NAME CURRENT DEFAULT".to_string());
63
111
  lines.extend(visible.iter().map(|model| {
64
112
  format!(
65
- "role_model: {model} current={}",
66
- current.as_deref() == Some(model.as_str())
113
+ "{} {} {} {} {} {}",
114
+ model.provider,
115
+ model.vendor,
116
+ model.id,
117
+ model.display_name,
118
+ if format == CatalogFormat::Pi {
119
+ (current.as_deref() == Some(model.id.as_str())).to_string()
120
+ } else {
121
+ "—".to_string()
122
+ },
123
+ model
124
+ .default
125
+ .map_or_else(|| "—".to_string(), |value| value.to_string()),
67
126
  )
68
127
  }));
69
128
  lines.push(format!(
@@ -71,14 +130,135 @@ fn cmd_models_with(
71
130
  visible.len()
72
131
  ));
73
132
  if visible.is_empty() {
74
- lines.push("No models matched --search.".to_string());
133
+ lines.push("No models matched --search/query.".to_string());
75
134
  }
76
135
  Ok(CmdResult::human(lines.join("\n")))
77
136
  }
78
137
  }
79
138
 
139
+ fn parse_catalog(bytes: &[u8], format: CatalogFormat) -> Result<Vec<ModelRecord>, String> {
140
+ match format {
141
+ CatalogFormat::Pi => crate::lifecycle::launch::pi_mcp::parse_pi_list_models_table(bytes)
142
+ .map(|models| {
143
+ models
144
+ .into_iter()
145
+ .map(|id| {
146
+ let vendor = id
147
+ .split_once('/')
148
+ .map_or_else(|| "pi".to_string(), |(vendor, _)| vendor.to_string());
149
+ ModelRecord {
150
+ provider: "pi".to_string(),
151
+ vendor,
152
+ display_name: id.clone(),
153
+ id,
154
+ default: None,
155
+ }
156
+ })
157
+ .collect()
158
+ })
159
+ .map_err(safe_catalog_error),
160
+ CatalogFormat::CursorAgent => parse_cursor_catalog(bytes),
161
+ }
162
+ }
163
+
164
+ fn parse_cursor_catalog(bytes: &[u8]) -> Result<Vec<ModelRecord>, String> {
165
+ let text = std::str::from_utf8(bytes)
166
+ .map_err(|_| "Cursor model catalog is not valid UTF-8".to_string())?;
167
+ let mut models = Vec::new();
168
+ for line in text.lines() {
169
+ let line = line.trim();
170
+ if line.is_empty()
171
+ || line
172
+ .trim_end_matches(':')
173
+ .eq_ignore_ascii_case("available models")
174
+ {
175
+ continue;
176
+ }
177
+ let Some((id, display)) = line.split_once(" - ") else {
178
+ return Err("Cursor model catalog contains a malformed row".to_string());
179
+ };
180
+ let id = id.trim();
181
+ if id.is_empty() || id.chars().any(char::is_whitespace) || display.trim().is_empty() {
182
+ return Err("Cursor model catalog contains a malformed row".to_string());
183
+ }
184
+ if models.iter().any(|model: &ModelRecord| model.id == id) {
185
+ return Err("Cursor model catalog contains duplicate model ids".to_string());
186
+ }
187
+ let default = display.trim_end().ends_with("(default)");
188
+ let display_name = display.trim().strip_suffix("(default)").map_or_else(
189
+ || display.trim().to_string(),
190
+ |name| name.trim().to_string(),
191
+ );
192
+ let vendor = id
193
+ .split_once('-')
194
+ .map_or_else(|| "cursor".to_string(), |(vendor, _)| vendor.to_string());
195
+ models.push(ModelRecord {
196
+ provider: "cursor_agent".to_string(),
197
+ vendor,
198
+ id: id.to_string(),
199
+ display_name,
200
+ default: Some(default),
201
+ });
202
+ }
203
+ if models.is_empty() {
204
+ return Err("Cursor model catalog is empty".to_string());
205
+ }
206
+ Ok(models)
207
+ }
208
+
209
+ fn model_matches(model: &ModelRecord, search: &str) -> bool {
210
+ let fields = [
211
+ model.provider.as_str(),
212
+ model.vendor.as_str(),
213
+ model.id.as_str(),
214
+ model.display_name.as_str(),
215
+ ];
216
+ search
217
+ .split_whitespace()
218
+ .map(str::to_lowercase)
219
+ .all(|needle| {
220
+ fields
221
+ .iter()
222
+ .any(|field| field.to_lowercase().contains(needle.as_str()))
223
+ })
224
+ }
225
+
226
+ fn current_model_for(format: CatalogFormat, models: &[ModelRecord]) -> Option<String> {
227
+ match format {
228
+ CatalogFormat::Pi => current_role_model(
229
+ &models
230
+ .iter()
231
+ .map(|model| model.id.clone())
232
+ .collect::<Vec<_>>(),
233
+ ),
234
+ // Cursor's catalog does not expose the active role model; never mark a
235
+ // catalog default as current without an explicit runtime identity.
236
+ CatalogFormat::CursorAgent => None,
237
+ }
238
+ }
239
+
240
+ fn catalog_error_prefix(format: CatalogFormat, message: String) -> String {
241
+ match format {
242
+ CatalogFormat::Pi => message,
243
+ CatalogFormat::CursorAgent => message.replace("Pi", "Cursor"),
244
+ }
245
+ }
246
+
80
247
  fn failure(args: &ModelsArgs, message: &str) -> CmdResult {
81
- let value = json!({ "schema_version": "models.v1", "ok": false, "provider": "pi", "auth": "not_ready", "auth_basis": "catalog_visibility", "models": Value::Array(Vec::new()), "current_role_model": Value::Null, "error": message, "action": "install or repair the PATH-first `pi` executable, then retry `team-agent models --provider pi`" });
248
+ let executable = if args.provider == "cursor_agent" {
249
+ "agent"
250
+ } else {
251
+ "pi"
252
+ };
253
+ let action = if message.starts_with("unsupported model provider") {
254
+ "choose a supported provider: pi or cursor_agent".to_string()
255
+ } else {
256
+ format!(
257
+ "install or repair the PATH-first `{executable}` executable, then retry `team-agent models --provider {}`",
258
+ args.provider
259
+ )
260
+ };
261
+ let value = json!({ "schema_version": "models.v1", "ok": false, "provider": args.provider, "auth": "not_ready", "auth_basis": "catalog_visibility", "models": Value::Array(Vec::new()), "current_role_model": Value::Null, "error": message, "action": action });
82
262
  if args.json {
83
263
  let text = serde_json::to_string_pretty(&value)
84
264
  .unwrap_or_else(|_| "{\"schema_version\":\"models.v1\",\"ok\":false}".to_string());
@@ -89,7 +269,7 @@ fn failure(args: &ModelsArgs, message: &str) -> CmdResult {
89
269
  preserve_json_order: true,
90
270
  }
91
271
  } else {
92
- CmdResult { output: CmdOutput::Human(format!("models.v1 | error: {message}\naction: install or repair the PATH-first `pi` executable, then retry `team-agent models --provider pi`\nauth: not_ready (catalog_visibility)")), exit: ExitCode::Error, as_json: false, preserve_json_order: false }
272
+ CmdResult { output: CmdOutput::Human(format!("models.v1 | error: {message}\naction: {action}\nauth: not_ready (catalog_visibility)")), exit: ExitCode::Error, as_json: false, preserve_json_order: false }
93
273
  }
94
274
  }
95
275
 
@@ -279,6 +459,38 @@ mod tests {
279
459
  assert_eq!(models[1]["role_model"], "team-agent/gpt-5.6-luna");
280
460
  }
281
461
 
462
+ #[cfg(unix)]
463
+ #[test]
464
+ fn cursor_catalog_parses_copyable_ids_and_multiword_search() {
465
+ let path = fixture(
466
+ "printf 'Available models\\ngpt-5.6-luna-high - GPT-5.6 Luna 1M High\\ncursor-grok-4.6-high - Cursor Grok 4.6\\nauto - Auto (default)\\n'",
467
+ );
468
+ let result = cmd_models_with_format(
469
+ &ModelsArgs {
470
+ provider: "cursor_agent".into(),
471
+ search: Some("GPT luna".into()),
472
+ json: true,
473
+ },
474
+ &path,
475
+ CatalogFormat::CursorAgent,
476
+ Duration::from_secs(1),
477
+ MAX_CATALOG_BYTES,
478
+ )
479
+ .unwrap();
480
+ let CmdOutput::Human(text) = result.output else {
481
+ panic!("expected JSON projection")
482
+ };
483
+ let value: Value = serde_json::from_str(&text).unwrap();
484
+ assert_eq!(value["ok"], true);
485
+ assert_eq!(value["provider"], "cursor_agent");
486
+ assert_eq!(value["models"].as_array().unwrap().len(), 1);
487
+ assert_eq!(value["models"][0]["role_model"], "gpt-5.6-luna-high");
488
+ assert_eq!(value["models"][0]["model_id"], "gpt-5.6-luna-high");
489
+ assert_eq!(value["models"][0]["display_name"], "GPT-5.6 Luna 1M High");
490
+ assert_eq!(value["models"][0]["current"], Value::Null);
491
+ cleanup_fixture(&path);
492
+ }
493
+
282
494
  #[cfg(unix)]
283
495
  fn fixture(body: &str) -> std::path::PathBuf {
284
496
  use std::os::unix::fs::PermissionsExt;
@@ -785,12 +997,7 @@ mod tests {
785
997
  &["--provider".into(), "codex".into()],
786
998
  Path::new("/tmp"),
787
999
  );
788
- match refusal {
789
- Err(CliError::Usage(message)) => {
790
- assert_eq!(message, "models supports only --provider pi, got \"codex\"")
791
- }
792
- other => panic!("expected provider refusal, got {other:?}"),
793
- }
1000
+ assert!(matches!(refusal, Ok(ExitCode::Error)));
794
1001
  cleanup_fixture(&path);
795
1002
  }
796
1003
 
@@ -894,7 +1101,7 @@ mod tests {
894
1101
  );
895
1102
  assert_eq!(
896
1103
  crate::cli::spec::command_spec("models").unwrap().usage,
897
- "usage: team-agent models --provider pi [--search TEXT] [--json]"
1104
+ "usage: team-agent models [--provider pi|cursor_agent] [QUERY|--search TEXT] [--json]"
898
1105
  );
899
1106
  }
900
1107
 
@@ -222,18 +222,10 @@ fn set_owner_only_permissions(path: &Path) -> Result<(), CliError> {
222
222
 
223
223
  fn read_profile_env(path: &Path) -> Result<Map<String, Value>, CliError> {
224
224
  let raw = std::fs::read_to_string(path)?;
225
- let mut values = Map::new();
226
- for line in raw.lines() {
227
- let trimmed = line.trim();
228
- if trimmed.is_empty() || trimmed.starts_with('#') {
229
- continue;
230
- }
231
- let Some((key, value)) = trimmed.split_once('=') else {
232
- continue;
233
- };
234
- values.insert(key.to_string(), Value::String(value.to_string()));
235
- }
236
- Ok(values)
225
+ Ok(crate::lifecycle::profile_launch::parse_env_text(&raw)
226
+ .into_iter()
227
+ .map(|(key, value)| (key, Value::String(value)))
228
+ .collect())
237
229
  }
238
230
 
239
231
  fn redacted_values(values: &Map<String, Value>) -> Value {
@@ -169,7 +169,7 @@ pub(crate) const COMMAND_SPECS: &[CommandSpec] = &[
169
169
  CommandSpec { name: "quick-start", tier: CommandTier::Core, category: CommandCategory::Start, kind: CommandKind::Dispatch(DispatchKind::QuickStart), summary: "start or attach a team from TEAM.md", usage: "usage: team-agent quick-start [TEAMDIR] [--workspace WORKSPACE] [--name NAME] [--team-id TEAM|--team TEAM] [--yes] [--no-display] [--backend tmux|conpty] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
170
170
  CommandSpec { name: "send", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Send), summary: "persist a message for an in-team short name or fully-qualified logical recipient", usage: "usage: team-agent send TO MESSAGE... [--workspace WORKSPACE] [--team TEAM] [--mailbox] [--json]\nTO forms are co-equal: an in-team short name (for example `team-agent send reviewer \"Review\"`) or `<workspace>::<team>/<agent>`.", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: Some("next compatibility release"), action: Some("use positional logical TO and the returned message id"), governance: None },
171
171
  CommandSpec { name: "status", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Status), summary: "show current team status", usage: "usage: team-agent status [AGENT] [--workspace WORKSPACE] [--team TEAM] [--summary|--json] [--detail]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
172
- CommandSpec { name: "models", tier: CommandTier::Core, category: CommandCategory::Observe, kind: CommandKind::Dispatch(DispatchKind::Models), summary: "list exact provider model ids", usage: "usage: team-agent models --provider pi [--search TEXT] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
172
+ CommandSpec { name: "models", tier: CommandTier::Core, category: CommandCategory::Observe, kind: CommandKind::Dispatch(DispatchKind::Models), summary: "list exact provider model ids", usage: "usage: team-agent models [--provider pi|cursor_agent] [QUERY|--search TEXT] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
173
173
  CommandSpec { name: "collect", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Collect), summary: "collect reported results", usage: "usage: team-agent collect [--workspace WORKSPACE] [--team TEAM] [--result-file FILE] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
174
174
  CommandSpec { name: "results", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Results), summary: "read reported results for a case", usage: "usage: team-agent results --case CASE_ID [--workspace WORKSPACE] [--team TEAM] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
175
175
  CommandSpec { name: "wait", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Wait), summary: "block until a task result is stored", usage: "usage: team-agent wait --task TASK [--workspace WORKSPACE] [--json]", default_help: false, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
@@ -322,3 +322,69 @@ fn profile_show_routes_and_preserves_redacted_secret_contract() {
322
322
  );
323
323
  let _ = std::fs::remove_dir_all(&ws);
324
324
  }
325
+
326
+ #[test]
327
+ fn config_authority_t04_show_doctor_and_runtime_parse_the_same_profile_bytes() {
328
+ let ws = tmp_workspace();
329
+ let runtime_dir = ws.join(".team/runtime");
330
+ std::fs::remove_dir(&runtime_dir).unwrap();
331
+ assert!(
332
+ !runtime_dir.exists(),
333
+ "T04 fixture must start without a runtime directory"
334
+ );
335
+ let dir = profiles_dir(&ws);
336
+ std::fs::create_dir_all(&dir).unwrap();
337
+ let body = "# synthetic fixture\nexport AUTH_MODE = 'compatible_api'\nPROXY_MODE=inherit\nexport PROXY_MODE = \"direct\"\nMODEL = 'synthetic-model'\nBASE_URL = \"http://127.0.0.1:9/v1\"\nexport API_KEY = 'synthetic-secret-placeholder'\nPLAIN=plain\nBAD-KEY=ignored\n1BAD=ignored\nMISSING_EQUALS\n";
338
+ std::fs::write(dir.join("syntax.env"), body).unwrap();
339
+ let runtime = crate::lifecycle::profile_launch::load_profile(&ws, "syntax", None).unwrap();
340
+ assert!(
341
+ !runtime_dir.exists(),
342
+ "load_profile must not create the runtime directory"
343
+ );
344
+ for (key, value) in [
345
+ ("AUTH_MODE", "compatible_api"),
346
+ ("PROXY_MODE", "direct"),
347
+ ("MODEL", "synthetic-model"),
348
+ ("PLAIN", "plain"),
349
+ ] {
350
+ assert_eq!(runtime.values.get(key).map(String::as_str), Some(value));
351
+ }
352
+ assert!(!runtime.values.contains_key("BAD-KEY"));
353
+ assert!(!runtime.values.contains_key("1BAD"));
354
+ for command in ["show", "doctor"] {
355
+ let result = cmd_profile(&ProfileArgs {
356
+ command: command.into(),
357
+ name: "syntax".into(),
358
+ workspace: ws.clone(),
359
+ team: None,
360
+ auth_mode: None,
361
+ proxy_mode: None,
362
+ json: true,
363
+ })
364
+ .unwrap();
365
+ let CmdOutput::Json(output) = result.output else {
366
+ panic!("expected JSON")
367
+ };
368
+ assert!(
369
+ !runtime_dir.exists(),
370
+ "profile {command} must not create the runtime directory"
371
+ );
372
+ assert_eq!(output["auth_mode"], runtime.values["AUTH_MODE"]);
373
+ assert_eq!(output["proxy_mode"], runtime.values["PROXY_MODE"]);
374
+ assert_eq!(output["credential_present"], true);
375
+ assert_eq!(output["secret_values_printed"], false);
376
+ assert!(!output.to_string().contains("synthetic-secret-placeholder"));
377
+ assert!(!output.to_string().contains("BAD-KEY"));
378
+ if command == "show" {
379
+ assert_eq!(output["values"]["MODEL"]["value"], "synthetic-model");
380
+ assert_eq!(output["values"]["API_KEY"]["redacted"], true);
381
+ assert_eq!(output["missing_common"], serde_json::json!([]));
382
+ }
383
+ }
384
+ assert_eq!(
385
+ std::fs::read_to_string(dir.join("syntax.env")).unwrap(),
386
+ body
387
+ );
388
+ assert!(!dir.join("syntax.example.env").exists());
389
+ assert!(!ws.join(".team/runtime").exists());
390
+ }
@@ -655,7 +655,7 @@ pub struct LeadersArgs {
655
655
  pub json: bool,
656
656
  }
657
657
 
658
- /// Read-only Pi model catalog discovery.
658
+ /// Read-only provider model catalog discovery.
659
659
  #[derive(Debug, Clone, PartialEq, Eq)]
660
660
  pub struct ModelsArgs {
661
661
  pub provider: String,
@@ -135,7 +135,7 @@ pub(crate) fn write_worker_mcp_config_for_provider(
135
135
  .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", parent.display())))?;
136
136
  }
137
137
  let raw = if matches!(provider, Some(Provider::Copilot)) {
138
- copilot_translate_mcp_servers(&config.raw)
138
+ crate::provider::adapters::copilot::copilot_translate_mcp_config(&config.raw)
139
139
  } else {
140
140
  config.raw.clone()
141
141
  };
@@ -610,36 +610,6 @@ fn upsert_toml_table_prefixes(existing: &str, tables: &[&str], stanza: &str) ->
610
610
  }
611
611
  }
612
612
 
613
- /// ---
614
- /// purpose: 把 MCP 配置里每个 server 的 type 字段名换成 transport
615
- /// returns: 同结构的新值,其余字段全部保留;非对象原样返回
616
- /// ---
617
- /// C-3-4 cr verdict v2 — McpConfig.raw 是 `{name: {type, command, args, env}}` 形;
618
- /// copilot mcp add schema 取 `transport` 替 `type`(stdio|http|sse 同值)。仅
619
- /// 字段名变换,其余字段全保留。
620
- pub(super) fn copilot_translate_mcp_servers(raw: &serde_json::Value) -> serde_json::Value {
621
- let Some(servers) = raw.as_object() else {
622
- return raw.clone();
623
- };
624
- let mut translated = serde_json::Map::new();
625
- for (name, server) in servers {
626
- let Some(obj) = server.as_object() else {
627
- translated.insert(name.clone(), server.clone());
628
- continue;
629
- };
630
- let mut out = serde_json::Map::new();
631
- for (key, value) in obj {
632
- if key == "type" {
633
- out.insert("transport".to_string(), value.clone());
634
- } else {
635
- out.insert(key.clone(), value.clone());
636
- }
637
- }
638
- translated.insert(name.clone(), serde_json::Value::Object(out));
639
- }
640
- serde_json::Value::Object(translated)
641
- }
642
-
643
613
  /// ---
644
614
  /// purpose: 把 argv 里 provider 原生的 MCP 配置参数改指到已落盘的文件
645
615
  /// params:
@@ -333,7 +333,7 @@ fn push_unique_path(paths: &mut Vec<PathBuf>, path: PathBuf) {
333
333
  }
334
334
  }
335
335
 
336
- fn parse_env_text(text: &str) -> BTreeMap<String, String> {
336
+ pub(crate) fn parse_env_text(text: &str) -> BTreeMap<String, String> {
337
337
  let mut values = BTreeMap::new();
338
338
  for raw in text.lines() {
339
339
  let mut line = raw.trim();
@@ -343,10 +343,11 @@ pub(super) fn spawn_agent_window(
343
343
  let mcp_config_path = if provider == crate::provider::Provider::Pi {
344
344
  None
345
345
  } else {
346
- Some(crate::lifecycle::launch::write_worker_mcp_config(
346
+ Some(crate::lifecycle::launch::write_worker_mcp_config_for_provider(
347
347
  workspace,
348
348
  agent_id.as_str(),
349
349
  &mcp_config,
350
+ Some(provider),
350
351
  )?)
351
352
  };
352
353
  let profile_launch =