@team-agent/installer 0.5.71 → 0.5.72

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.
@@ -0,0 +1,443 @@
1
+ //! Read-only Pi model catalog discovery for `team-agent models`.
2
+ use super::{CliError, CmdOutput, CmdResult, ExitCode, ModelsArgs};
3
+ use serde_json::{json, Value};
4
+ use std::path::Path;
5
+ use std::process::Command;
6
+ use std::time::Duration;
7
+ const CATALOG_TIMEOUT: Duration = Duration::from_secs(10);
8
+ const MAX_CATALOG_BYTES: u64 = 1024 * 1024;
9
+
10
+ pub fn cmd_models(args: &ModelsArgs) -> Result<CmdResult, CliError> {
11
+ cmd_models_with(args, Path::new("pi"), CATALOG_TIMEOUT, MAX_CATALOG_BYTES)
12
+ }
13
+
14
+ fn cmd_models_with(
15
+ args: &ModelsArgs,
16
+ program: &Path,
17
+ timeout: Duration,
18
+ max_bytes: u64,
19
+ ) -> Result<CmdResult, CliError> {
20
+ let bytes = match run_catalog(program, timeout, max_bytes) {
21
+ Ok(bytes) => bytes,
22
+ Err(message) => return Ok(failure(args, &message)),
23
+ };
24
+ let all = match crate::lifecycle::launch::pi_mcp::parse_pi_list_models_table(&bytes) {
25
+ Ok(models) => models,
26
+ Err(error) => return Ok(failure(args, &safe_catalog_error(error))),
27
+ };
28
+ let visible = args.search.as_deref().map_or_else(
29
+ || all.clone(),
30
+ |search| {
31
+ all.iter()
32
+ .filter(|model| model.contains(search))
33
+ .cloned()
34
+ .collect::<Vec<_>>()
35
+ },
36
+ );
37
+ let current = current_role_model(&all);
38
+ let entries: Vec<Value> = visible
39
+ .iter()
40
+ .map(|model| {
41
+ json!({
42
+ "role_model": model,
43
+ "current": current.as_deref() == Some(model.as_str()),
44
+ })
45
+ })
46
+ .collect();
47
+ let value = json!({
48
+ "schema_version": "models.v1", "ok": true, "provider": "pi", "models": entries,
49
+ "auth": "ok", "auth_basis": "catalog_visibility", "current_role_model": current, "search": args.search,
50
+ });
51
+ if args.json {
52
+ let text = serde_json::to_string_pretty(&value)?;
53
+ Ok(CmdResult {
54
+ output: CmdOutput::Human(text),
55
+ exit: ExitCode::Ok,
56
+ as_json: false,
57
+ preserve_json_order: true,
58
+ })
59
+ } else {
60
+ let mut lines = vec!["models.v1 | Pi models (copyable role_model):".to_string()];
61
+ lines.extend(visible.iter().map(|model| {
62
+ format!(
63
+ "role_model: {model} current={}",
64
+ current.as_deref() == Some(model.as_str())
65
+ )
66
+ }));
67
+ lines.push(format!(
68
+ "auth: ok (catalog_visibility); {} model(s)",
69
+ visible.len()
70
+ ));
71
+ if visible.is_empty() {
72
+ lines.push("No models matched --search.".to_string());
73
+ }
74
+ Ok(CmdResult::human(lines.join("\n")))
75
+ }
76
+ }
77
+
78
+ fn failure(args: &ModelsArgs, message: &str) -> CmdResult {
79
+ 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`" });
80
+ if args.json {
81
+ let text = serde_json::to_string_pretty(&value)
82
+ .unwrap_or_else(|_| "{\"schema_version\":\"models.v1\",\"ok\":false}".to_string());
83
+ CmdResult {
84
+ output: CmdOutput::Human(text),
85
+ exit: ExitCode::Error,
86
+ as_json: false,
87
+ preserve_json_order: true,
88
+ }
89
+ } else {
90
+ 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 }
91
+ }
92
+ }
93
+
94
+ fn safe_catalog_error(error: crate::provider::ProviderError) -> String {
95
+ let text = error.to_string();
96
+ if text.contains("not UTF-8") {
97
+ "Pi model catalog is not valid UTF-8".into()
98
+ } else if text.contains("duplicate") {
99
+ "Pi model catalog contains duplicate model ids".into()
100
+ } else if text.contains("header") {
101
+ "Pi model catalog has an unexpected header".into()
102
+ } else if text.contains("row") {
103
+ "Pi model catalog contains a malformed row".into()
104
+ } else if text.contains("empty") || text.contains("no models") {
105
+ "Pi model catalog is empty".into()
106
+ } else {
107
+ "Pi model catalog is invalid".into()
108
+ }
109
+ }
110
+ fn current_role_model(models: &[String]) -> Option<String> {
111
+ current_role_model_from(
112
+ models,
113
+ std::env::var("PI_PROVIDER").ok().as_deref(),
114
+ std::env::var("PI_MODEL").ok().as_deref(),
115
+ )
116
+ }
117
+ fn current_role_model_from(
118
+ models: &[String],
119
+ provider: Option<&str>,
120
+ model: Option<&str>,
121
+ ) -> Option<String> {
122
+ let exact = format!("{}/{}", provider?, model?);
123
+ models
124
+ .iter()
125
+ .any(|candidate| candidate == &exact)
126
+ .then_some(exact)
127
+ }
128
+
129
+ /// Injectable executable, deadline, and byte-limit boundary for deterministic tests.
130
+ fn run_catalog(program: &Path, timeout: Duration, max_bytes: u64) -> Result<Vec<u8>, String> {
131
+ crate::lifecycle::launch::pi_mcp::run_pi_catalog(program, timeout, max_bytes)
132
+ }
133
+
134
+ #[cfg(test)]
135
+ mod tests {
136
+ use super::*;
137
+ use std::time::Instant;
138
+
139
+ #[cfg(unix)]
140
+ #[test]
141
+ fn real_dispatcher_child_helper() {
142
+ if std::env::var_os("TEAM_AGENT_MODELS_CHILD").is_none() {
143
+ return;
144
+ }
145
+ let args: Vec<String> =
146
+ serde_json::from_str(&std::env::var("TEAM_AGENT_MODELS_ARGS").unwrap()).unwrap();
147
+ print!("__TEAM_AGENT_MODELS_CLI_OUTPUT_v1__\n");
148
+ use std::io::Write;
149
+ std::io::stdout().flush().unwrap();
150
+ let exit = crate::cli::emit::run(&args, Path::new("/tmp"));
151
+ std::process::exit(exit.code());
152
+ }
153
+
154
+ #[cfg(unix)]
155
+ #[test]
156
+ fn real_dispatcher_isolated_success_json_human_and_no_match() {
157
+ let pi = fixture("printf 'provider model\\nopenai-codex gpt-5.6-sol\\n'");
158
+ let dir = pi.parent().unwrap().to_path_buf();
159
+ let run_child = |args: &[&str], model: &str| {
160
+ std::process::Command::new(std::env::current_exe().unwrap())
161
+ .args([
162
+ "--exact",
163
+ "cli::models::tests::real_dispatcher_child_helper",
164
+ "--nocapture",
165
+ ])
166
+ .env("TEAM_AGENT_MODELS_CHILD", "1")
167
+ .env(
168
+ "TEAM_AGENT_MODELS_ARGS",
169
+ serde_json::to_string(args).unwrap(),
170
+ )
171
+ .env("PATH", &dir)
172
+ .env("PI_PROVIDER", "openai-codex")
173
+ .env("PI_MODEL", model)
174
+ .output()
175
+ .unwrap()
176
+ };
177
+ let json = run_child(&["models", "--provider", "pi", "--json"], "gpt-5.6-sol");
178
+ assert!(json.status.success());
179
+ let output = String::from_utf8(json.stdout).unwrap();
180
+ let cli_output = output
181
+ .split("__TEAM_AGENT_MODELS_CLI_OUTPUT_v1__\n")
182
+ .nth(1)
183
+ .unwrap();
184
+ let value: Value = serde_json::from_str(cli_output.trim()).unwrap();
185
+ assert_eq!(value["schema_version"], "models.v1");
186
+ assert_eq!(value["models"][0]["role_model"], "openai-codex/gpt-5.6-sol");
187
+ assert_eq!(value["models"][0]["current"], true);
188
+ let human = run_child(
189
+ &["models", "--provider", "pi", "--search", "absent"],
190
+ "missing",
191
+ );
192
+ assert!(human.status.success());
193
+ let human_output = String::from_utf8(human.stdout).unwrap();
194
+ let text = human_output
195
+ .split("__TEAM_AGENT_MODELS_CLI_OUTPUT_v1__\n")
196
+ .nth(1)
197
+ .unwrap();
198
+ assert!(text.contains("No models matched --search") && text.contains("auth: ok"));
199
+ let null = run_child(&["models", "--provider", "pi", "--json"], "missing");
200
+ let null_output = String::from_utf8(null.stdout).unwrap();
201
+ let null_cli = null_output
202
+ .split("__TEAM_AGENT_MODELS_CLI_OUTPUT_v1__\n")
203
+ .nth(1)
204
+ .unwrap();
205
+ let null_value: Value = serde_json::from_str(null_cli.trim()).unwrap();
206
+ assert!(null.status.success() && null_value["current_role_model"].is_null());
207
+ std::fs::write(&pi, "#!/bin/sh\nprintf 'sensitive-stderr' >&2\nexit 7\n").unwrap();
208
+ let failed = run_child(&["models", "--provider", "pi", "--json"], "missing");
209
+ assert!(!failed.status.success());
210
+ let failed_output = String::from_utf8(failed.stdout).unwrap();
211
+ let failed_cli = failed_output
212
+ .split("__TEAM_AGENT_MODELS_CLI_OUTPUT_v1__\n")
213
+ .nth(1)
214
+ .unwrap();
215
+ assert!(!failed_cli.contains("sensitive-stderr"));
216
+ assert_eq!(
217
+ std::fs::read_to_string(pi.with_extension("count"))
218
+ .unwrap()
219
+ .lines()
220
+ .count(),
221
+ 3
222
+ );
223
+ cleanup_fixture(&pi);
224
+ }
225
+
226
+ #[test]
227
+ fn current_model_is_deterministic() {
228
+ let models = vec!["openai-codex/gpt-5.6-sol".into()];
229
+ assert_eq!(
230
+ current_role_model_from(&models, Some("openai-codex"), Some("gpt-5.6-sol")),
231
+ Some("openai-codex/gpt-5.6-sol".into())
232
+ );
233
+ assert_eq!(
234
+ current_role_model_from(&models, Some("openai-codex"), Some("other")),
235
+ None
236
+ );
237
+ assert_eq!(
238
+ current_role_model_from(&models, None, Some("gpt-5.6-sol")),
239
+ None
240
+ );
241
+ }
242
+ #[cfg(unix)]
243
+ fn fixture(body: &str) -> std::path::PathBuf {
244
+ use std::os::unix::fs::PermissionsExt;
245
+ let root = std::env::current_exe()
246
+ .unwrap()
247
+ .parent()
248
+ .unwrap()
249
+ .join(format!(
250
+ "team-agent-pi-fixture-{}-{}",
251
+ std::process::id(),
252
+ std::time::SystemTime::now()
253
+ .duration_since(std::time::UNIX_EPOCH)
254
+ .unwrap()
255
+ .as_nanos()
256
+ ));
257
+ std::fs::create_dir(&root).unwrap();
258
+ let path = root.join("pi");
259
+ std::fs::write(
260
+ &path,
261
+ format!("#!/bin/sh\nprintf '%s\\n' \"$#:$1\" >> \"$0.count\"\n{body}\n"),
262
+ )
263
+ .unwrap();
264
+ let mut permissions = std::fs::metadata(&path).unwrap().permissions();
265
+ permissions.set_mode(0o700);
266
+ std::fs::set_permissions(&path, permissions).unwrap();
267
+ path
268
+ }
269
+
270
+ #[cfg(unix)]
271
+ fn cleanup_fixture(path: &Path) {
272
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
273
+ }
274
+
275
+ #[cfg(unix)]
276
+ #[test]
277
+ fn runner_invokes_exact_argv_once_and_drains() {
278
+ let path = fixture(
279
+ "test \"$1\" = --list-models && echo provider model && echo openai-codex gpt-5.6-sol",
280
+ );
281
+ assert!(run_catalog(&path, Duration::from_secs(1), 1024)
282
+ .unwrap()
283
+ .starts_with(b"provider model"));
284
+ assert_eq!(
285
+ std::fs::read_to_string(path.with_extension("count")).unwrap(),
286
+ "1:--list-models\n"
287
+ );
288
+ cleanup_fixture(&path);
289
+ }
290
+
291
+ #[cfg(unix)]
292
+ #[test]
293
+ fn runner_timeout_is_bounded_when_descendant_keeps_stdout() {
294
+ let path = fixture("sleep 2 & exit 0");
295
+ let started = Instant::now();
296
+ let result = run_catalog(&path, Duration::from_millis(40), 1024);
297
+ assert!(started.elapsed() < Duration::from_millis(500));
298
+ assert!(result.unwrap_err().contains("timed out"));
299
+ cleanup_fixture(&path);
300
+ }
301
+
302
+ #[cfg(unix)]
303
+ #[test]
304
+ fn command_boundary_connects_runner_parser_search_and_contract() {
305
+ let path = fixture(
306
+ "printf 'provider model\\nopenai-codex gpt-5.6-sol\\nopenai-codex gpt-5.6-luna\\n'",
307
+ );
308
+ let args = ModelsArgs {
309
+ provider: "pi".into(),
310
+ search: Some("sol".into()),
311
+ json: true,
312
+ };
313
+ let result = cmd_models_with(&args, &path, Duration::from_secs(1), 1024).unwrap();
314
+ let CmdOutput::Human(text) = result.output else {
315
+ panic!("expected JSON projection")
316
+ };
317
+ let value: Value = serde_json::from_str(&text).unwrap();
318
+ assert_eq!(value["schema_version"], "models.v1");
319
+ assert_eq!(value["models"][0]["role_model"], "openai-codex/gpt-5.6-sol");
320
+ assert_eq!(value["models"][0]["current"], false);
321
+ assert_eq!(value["auth"], "ok");
322
+ assert_eq!(value["models"].as_array().unwrap().len(), 1);
323
+ let human = cmd_models_with(
324
+ &ModelsArgs {
325
+ provider: "pi".into(),
326
+ search: Some("absent".into()),
327
+ json: false,
328
+ },
329
+ &path,
330
+ Duration::from_secs(1),
331
+ 1024,
332
+ )
333
+ .unwrap();
334
+ let CmdOutput::Human(text) = human.output else {
335
+ panic!("expected human projection")
336
+ };
337
+ assert!(text.contains("No models matched --search") && text.contains("auth: ok"));
338
+ let refusal = crate::cli::emit::__test_dispatch(
339
+ "models",
340
+ &["--provider".into(), "codex".into()],
341
+ Path::new("/tmp"),
342
+ );
343
+ match refusal {
344
+ Err(CliError::Usage(message)) => {
345
+ assert_eq!(message, "models supports only --provider pi, got \"codex\"")
346
+ }
347
+ other => panic!("expected provider refusal, got {other:?}"),
348
+ }
349
+ cleanup_fixture(&path);
350
+ }
351
+
352
+ #[cfg(unix)]
353
+ #[test]
354
+ fn runner_fails_closed_for_nonzero_oversize_timeout_and_unavailable() {
355
+ let fail = fixture("echo sensitive-token >&2; exit 7");
356
+ assert_eq!(
357
+ run_catalog(&fail, Duration::from_secs(1), 1024).unwrap_err(),
358
+ "Pi model catalog command failed"
359
+ );
360
+ let failed = cmd_models_with(
361
+ &ModelsArgs {
362
+ provider: "pi".into(),
363
+ search: None,
364
+ json: false,
365
+ },
366
+ &fail,
367
+ Duration::from_secs(1),
368
+ 1024,
369
+ )
370
+ .unwrap();
371
+ let CmdOutput::Human(text) = failed.output else {
372
+ panic!("expected failure projection")
373
+ };
374
+ assert_eq!(failed.exit, ExitCode::Error);
375
+ assert!(!text.contains("sensitive-token"));
376
+ let big = fixture("head -c 64 /dev/zero");
377
+ assert!(run_catalog(&big, Duration::from_secs(1), 8)
378
+ .unwrap_err()
379
+ .contains("bounded output"));
380
+ let slow = fixture("sleep 2");
381
+ assert!(run_catalog(&slow, Duration::from_millis(20), 1024)
382
+ .unwrap_err()
383
+ .contains("timed out"));
384
+ assert!(run_catalog(
385
+ std::path::Path::new("/does/not/exist"),
386
+ Duration::from_secs(1),
387
+ 1024
388
+ )
389
+ .unwrap_err()
390
+ .contains("unavailable"));
391
+ for path in [fail, big, slow] {
392
+ cleanup_fixture(&path);
393
+ }
394
+ }
395
+
396
+ #[test]
397
+ fn public_failure_and_help_contracts_are_versioned() {
398
+ let args = ModelsArgs {
399
+ provider: "pi".into(),
400
+ search: None,
401
+ json: true,
402
+ };
403
+ let result = failure(&args, "safe failure");
404
+ let CmdOutput::Human(text) = result.output else {
405
+ panic!("json contract must render")
406
+ };
407
+ let value: Value = serde_json::from_str(&text).unwrap();
408
+ assert_eq!(value["schema_version"], "models.v1");
409
+ assert_eq!(value["models"], Value::Array(vec![]));
410
+ assert_eq!(result.exit, ExitCode::Error);
411
+ assert!(crate::cli::emit::default_help().contains("models"));
412
+ assert_eq!(
413
+ crate::cli::run(&["models".into(), "--help".into()], Path::new("/tmp")),
414
+ ExitCode::Ok
415
+ );
416
+ assert_eq!(
417
+ crate::cli::run(
418
+ &["models".into(), "--provider".into(), "codex".into()],
419
+ Path::new("/tmp")
420
+ ),
421
+ ExitCode::Error
422
+ );
423
+ assert_eq!(
424
+ crate::cli::spec::command_spec("models").unwrap().usage,
425
+ "usage: team-agent models --provider pi [--search TEXT] [--json]"
426
+ );
427
+ }
428
+
429
+ #[test]
430
+ fn parser_contract_fixtures() {
431
+ let parse =
432
+ |input: &[u8]| crate::lifecycle::launch::pi_mcp::parse_pi_list_models_table(input);
433
+ assert_eq!(
434
+ parse(b"provider model\r\nopenai-codex gpt-5.6-sol\r\n").unwrap(),
435
+ vec!["openai-codex/gpt-5.6-sol"]
436
+ );
437
+ assert!(parse(b"provider model\n").is_err());
438
+ assert!(parse(b"provider model\na b\na b\n").is_err());
439
+ assert!(parse(b"wrong model\na b\n").is_err());
440
+ assert!(parse(b"provider model\na\n").is_err());
441
+ assert!(parse(&[b'p', b'r', 0xff]).is_err());
442
+ }
443
+ }
@@ -85,6 +85,7 @@ pub(crate) enum DispatchKind {
85
85
  E2e,
86
86
  Peek,
87
87
  Coordinator,
88
+ Models,
88
89
  }
89
90
 
90
91
  pub(crate) const ALL_DISPATCH_KINDS: &[DispatchKind] = &[
@@ -131,6 +132,7 @@ pub(crate) const ALL_DISPATCH_KINDS: &[DispatchKind] = &[
131
132
  DispatchKind::E2e,
132
133
  DispatchKind::Peek,
133
134
  DispatchKind::Coordinator,
135
+ DispatchKind::Models,
134
136
  ];
135
137
 
136
138
  #[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -162,8 +164,9 @@ pub(crate) const COMMAND_SPECS: &[CommandSpec] = &[
162
164
  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 },
163
165
  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 },
164
166
  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 },
167
+ 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 },
165
168
  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 },
166
- 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 },
169
+ 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: false, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
167
170
  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 },
168
171
  CommandSpec { name: "restart", tier: CommandTier::Core, category: CommandCategory::TeamLifecycle, kind: CommandKind::Dispatch(DispatchKind::Restart), summary: "restart the selected team", usage: "usage: team-agent restart [WORKSPACE] [--team TEAM] [--allow-fresh] [--session-converge-deadline SECONDS] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
169
172
  CommandSpec { name: "shutdown", tier: CommandTier::Core, category: CommandCategory::TeamLifecycle, kind: CommandKind::Dispatch(DispatchKind::Shutdown), summary: "stop the selected team", usage: "usage: team-agent shutdown [--workspace WORKSPACE] [--team TEAM] [--keep-logs] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
@@ -638,6 +638,14 @@ pub struct LeadersArgs {
638
638
  pub json: bool,
639
639
  }
640
640
 
641
+ /// Read-only Pi model catalog discovery.
642
+ #[derive(Debug, Clone, PartialEq, Eq)]
643
+ pub struct ModelsArgs {
644
+ pub provider: String,
645
+ pub search: Option<String>,
646
+ pub json: bool,
647
+ }
648
+
641
649
  /// `validate [spec=team.spec.yaml] --json`(`parser.py:120`)。
642
650
  #[derive(Debug, Clone, PartialEq, Eq)]
643
651
  pub struct ValidateArgs {
@@ -366,6 +366,114 @@ pub struct CompiledRole {
366
366
  pub agent: Value,
367
367
  }
368
368
 
369
+ #[derive(Debug, Clone, PartialEq, Eq)]
370
+ pub struct PiModelPreflightError {
371
+ pub requested: String,
372
+ pub candidates: Vec<String>,
373
+ pub action: String,
374
+ pub not_ready: bool,
375
+ }
376
+
377
+ impl std::fmt::Display for PiModelPreflightError {
378
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
379
+ write!(
380
+ formatter,
381
+ "Pi role model {:?} is not a qualified exact model",
382
+ self.requested
383
+ )
384
+ }
385
+ }
386
+
387
+ /// Shared admission gate: omitted and qualified models pass; only an
388
+ /// unqualified present model causes one bounded catalog observation.
389
+ pub fn preflight_pi_role_model(meta: &Value) -> Result<(), PiModelPreflightError> {
390
+ preflight_pi_role_model_with(meta, |requested| {
391
+ crate::lifecycle::launch::pi_mcp::pi_model_candidates(requested).map_err(|_| ())
392
+ })
393
+ }
394
+
395
+ pub fn preflight_pi_role_model_with<F>(
396
+ meta: &Value,
397
+ mut discover: F,
398
+ ) -> Result<(), PiModelPreflightError>
399
+ where
400
+ F: FnMut(&str) -> Result<Vec<String>, ()>,
401
+ {
402
+ if parse_canonical_provider(meta.get("provider").and_then(Value::as_str).unwrap_or(""))
403
+ != Some(Provider::Pi)
404
+ {
405
+ return Ok(());
406
+ }
407
+ let Some(model) = string_field(meta, "model").filter(|value| !value.trim().is_empty()) else {
408
+ return Ok(());
409
+ };
410
+ let requested = model.trim().to_string();
411
+ if requested.split_once('/').is_some_and(|(provider, name)| {
412
+ !provider.is_empty() && !name.is_empty() && !requested.contains('*')
413
+ }) {
414
+ return Ok(());
415
+ }
416
+ let (candidates, not_ready) = match discover(&requested) {
417
+ Ok(candidates) => (candidates, false),
418
+ Err(()) => (Vec::new(), true),
419
+ };
420
+ let action = if candidates.is_empty() {
421
+ format!("run `team-agent models --provider pi --search {requested}`")
422
+ } else {
423
+ format!("copy a candidate into the role, or run `team-agent models --provider pi --search {requested}`")
424
+ };
425
+ Err(PiModelPreflightError {
426
+ requested,
427
+ candidates,
428
+ action,
429
+ not_ready,
430
+ })
431
+ }
432
+
433
+ pub fn preflight_pi_models_in_team(team_dir: &Path) -> Result<(), PiModelPreflightError> {
434
+ preflight_pi_models_in_team_with(team_dir, |requested| {
435
+ crate::lifecycle::launch::pi_mcp::pi_model_candidates(requested).map_err(|_| ())
436
+ })
437
+ }
438
+
439
+ pub fn preflight_pi_models_in_team_with<F>(
440
+ team_dir: &Path,
441
+ mut discover: F,
442
+ ) -> Result<(), PiModelPreflightError>
443
+ where
444
+ F: FnMut(&str) -> Result<Vec<String>, ()>,
445
+ {
446
+ let agents_dir = team_dir.join("agents");
447
+ let entries = match std::fs::read_dir(&agents_dir) {
448
+ Ok(entries) => entries,
449
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
450
+ Err(_) => {
451
+ return Err(PiModelPreflightError {
452
+ requested: "<role directory>".into(),
453
+ candidates: Vec::new(),
454
+ action: "repair the role directory and retry".into(),
455
+ not_ready: true,
456
+ })
457
+ }
458
+ };
459
+ let mut paths = entries
460
+ .filter_map(Result::ok)
461
+ .map(|entry| entry.path())
462
+ .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("md"))
463
+ .collect::<Vec<_>>();
464
+ paths.sort();
465
+ for path in paths {
466
+ let (meta, _) = read_front_matter(&path).map_err(|_| PiModelPreflightError {
467
+ requested: path.display().to_string(),
468
+ candidates: Vec::new(),
469
+ action: "repair the role document and retry".into(),
470
+ not_ready: true,
471
+ })?;
472
+ preflight_pi_role_model_with(&meta, &mut discover)?;
473
+ }
474
+ Ok(())
475
+ }
476
+
369
477
  /// 把一份 role 文档编译成 agent spec 条目。`team_meta` 供 model/auth_mode 继承;
370
478
  /// `workspace_s` 是 working_directory。**纯读 `role_path`,无任何文件落地。**
371
479
  pub fn compile_role_agent(
@@ -696,6 +804,55 @@ fn int_field(meta: &Value, key: &str, default: i64) -> Value {
696
804
  }
697
805
  }
698
806
 
807
+ #[cfg(test)]
808
+ mod pi_preflight_tests {
809
+ use super::*;
810
+ use std::cell::Cell;
811
+
812
+ fn role(provider: &str, model: Option<&str>) -> Value {
813
+ let mut fields = vec![("provider".to_string(), Value::Str(provider.into()))];
814
+ if let Some(model) = model {
815
+ fields.push(("model".to_string(), Value::Str(model.into())));
816
+ }
817
+ Value::Map(fields)
818
+ }
819
+
820
+ #[test]
821
+ fn omitted_qualified_and_non_pi_do_not_discover() {
822
+ for meta in [role("pi", None), role("pi", Some("openai/gpt-5")), role("codex", Some("gpt-5"))] {
823
+ let calls = Cell::new(0);
824
+ assert!(preflight_pi_role_model_with(&meta, |_| {
825
+ calls.set(calls.get() + 1);
826
+ Ok(vec![])
827
+ }).is_ok());
828
+ assert_eq!(calls.get(), 0);
829
+ }
830
+ }
831
+
832
+ #[test]
833
+ fn unqualified_discovers_once_and_projects_candidates() {
834
+ let calls = Cell::new(0);
835
+ let error = preflight_pi_role_model_with(&role("pi", Some("gpt-5.6-sol")), |requested| {
836
+ calls.set(calls.get() + 1);
837
+ assert_eq!(requested, "gpt-5.6-sol");
838
+ Ok(vec!["openai-codex/gpt-5.6-sol".into(), "azure/gpt-5.6-sol".into()])
839
+ }).expect_err("unqualified model must fail closed");
840
+ assert_eq!(calls.get(), 1);
841
+ assert_eq!(error.candidates, vec!["openai-codex/gpt-5.6-sol", "azure/gpt-5.6-sol"]);
842
+ assert!(!error.not_ready);
843
+ assert!(error.action.contains("team-agent models --provider pi --search gpt-5.6-sol"));
844
+ }
845
+
846
+ #[test]
847
+ fn catalog_failure_is_not_ready_without_partial_candidates() {
848
+ let error = preflight_pi_role_model_with(&role("pi", Some("gpt-5.6-sol")), |_| Err(()))
849
+ .expect_err("catalog failure must reject");
850
+ assert!(error.not_ready);
851
+ assert!(error.candidates.is_empty());
852
+ assert!(!error.to_string().contains("stderr"));
853
+ }
854
+ }
855
+
699
856
  fn py_int_value(value: &Value) -> Option<i64> {
700
857
  match value {
701
858
  Value::Bool(b) => Some(if *b { 1 } else { 0 }),