@team-agent/installer 0.5.106 → 0.5.108

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 (41) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +1 -1
  4. package/crates/team-agent/src/cli/emit.rs +3 -3
  5. package/crates/team-agent/src/cli/mod.rs +87 -4
  6. package/crates/team-agent/src/cli/models.rs +252 -3
  7. package/crates/team-agent/src/cli/spec.rs +1 -1
  8. package/crates/team-agent/src/cli/status_port/brief.rs +155 -37
  9. package/crates/team-agent/src/cli/tests/base.rs +112 -225
  10. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +184 -0
  11. package/crates/team-agent/src/cli/tests/status_brief.rs +624 -0
  12. package/crates/team-agent/src/compiler/tests.rs +46 -59
  13. package/crates/team-agent/src/leader/tests/byte_findings.rs +1 -14
  14. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +57 -173
  15. package/crates/team-agent/src/lifecycle/launch/pi_mcp_extension.js +226 -0
  16. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +0 -37
  17. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +0 -9
  18. package/crates/team-agent/src/lifecycle/tests/fixtures/pi_native_mcp_host.mjs +71 -0
  19. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +0 -66
  20. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +0 -392
  21. package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +0 -5
  22. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +2 -361
  23. package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +1 -16
  24. package/crates/team-agent/src/lifecycle/tests/pi_executable_mcp_red.rs +20 -112
  25. package/crates/team-agent/src/lifecycle/tests/pi_fork_session_inheritance_red.rs +1 -12
  26. package/crates/team-agent/src/lifecycle/tests/pi_native_mcp_capability_red.rs +546 -0
  27. package/crates/team-agent/src/lifecycle/tests.rs +1 -0
  28. package/crates/team-agent/src/lifecycle/worker_command_context.rs +11 -54
  29. package/crates/team-agent/src/messaging/tests/wave2.rs +2 -302
  30. package/crates/team-agent/src/model/name_similarity.rs +52 -67
  31. package/crates/team-agent/src/model/task_graph.rs +37 -124
  32. package/crates/team-agent/src/model/yaml/tests.rs +46 -165
  33. package/crates/team-agent/src/provider/model_catalog.rs +166 -3
  34. package/crates/team-agent/src/provider/testdata/cursor-list-models-native.stdout +250 -0
  35. package/crates/team-agent/src/provider/testdata/grok-models-authenticated.stdout +9 -0
  36. package/crates/team-agent/src/provider/testdata/grok-models-unauthenticated.stdout +7 -0
  37. package/crates/team-agent/src/provider/tests/adapter.rs +0 -20
  38. package/crates/team-agent/src/provider/tests/classify.rs +106 -198
  39. package/crates/team-agent/src/tmux_backend/tests.rs +178 -501
  40. package/crates/team-agent/src/transport/tests/wire.rs +0 -55
  41. package/package.json +4 -4
package/Cargo.lock CHANGED
@@ -581,7 +581,7 @@ dependencies = [
581
581
 
582
582
  [[package]]
583
583
  name = "team-agent"
584
- version = "0.5.106"
584
+ version = "0.5.108"
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.106"
12
+ version = "0.5.108"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -218,7 +218,7 @@ pub fn cmd_status_for_team(args: &StatusArgs, team: Option<&str>) -> Result<CmdR
218
218
  }
219
219
  }
220
220
  // Status brief is deliberately independent of the legacy RuntimeSnapshot:
221
- // it performs one native tmux/process sample and exposes exactly seven
221
+ // it performs one native tmux/process sample and exposes exactly nine
222
222
  // fields. `--summary` and `--detail` remain parser-compatible but do not
223
223
  // re-enable history, runtime diagnostics, or reminder text.
224
224
  if args.json {
@@ -290,7 +290,7 @@ pub(crate) fn default_help() -> String {
290
290
  &["claim-leader", "takeover", "attach-leader"],
291
291
  );
292
292
  out.push_str("\nWorkflow: `team-agent models --provider pi`, then quick-start/add-agent/start-agent, then `team-agent send AGENT MESSAGE`.\n");
293
- out.push_str("Model catalogs: pi, cursor_agent, codex, claude, claude_code.\n");
293
+ out.push_str("Model catalogs: pi, cursor_agent, codex, claude, claude_code, grok.\n");
294
294
  out.push_str("Provider launchers:\n team-agent codex|claude|copilot|grok|cursor|pi ...\n");
295
295
  out.push_str("\nRun `team-agent <command> --help` for command flags.");
296
296
  out
@@ -340,8 +340,8 @@ fn command_help(command: Option<&str>) -> String {
340
340
  )
341
341
  .to_string(),
342
342
  Some("allow-peer-talk") => "usage: team-agent allow-peer-talk A B [--workspace WORKSPACE] [--json]".to_string(),
343
- Some("status") => "usage: team-agent status [AGENT] [--workspace WORKSPACE] [--team TEAM] [--summary|--json] [--detail]\n\n输出九字段:name/provider/model/effort/runtime_status/activity/health/session_name/tmux_command;人读与 --json 使用同一投影。model/effort 为已接受启动配置,未设置为 null,不猜 provider 默认值。缺少可靠定位或原生 tmux/process 采样时显示 unknown;tmux_command 可复制到对应目标。--summary/--detail 仅保留兼容性,不增加诊断字段。".to_string(),
344
- Some("models") => "usage: team-agent models [--provider pi|cursor_agent|codex|claude|claude_code] [QUERY|--search TEXT] [--json]\n\nLists exact provider model ids with case-insensitive multi-word search across provider, vendor, id, display name, and source aliases. Uses each provider's native catalog; Codex uses `codex debug models` and Claude uses SDK stream-json initialize.".to_string(),
343
+ Some("status") => "usage: team-agent status [AGENT] [--workspace WORKSPACE] [--team TEAM] [--summary|--json] [--detail]\n\n输出九字段:name/provider/model/effort/runtime_status/activity/health/session_name/tmux_command;人读与 --json 使用同一投影字段(多节点人读紧凑显示 tmux_command)。--json 始终保留每节点完整可复制的 tmux_command;人读单节点保留完整旧格式,多节点按(socket, tmux session)显示每组一次共享 attach 模板,ATTACH列为<group>:<window>.<pane>,模板中的 <target> 替换为其 window.pane 部分。model/effort 为已接受启动配置,未设置为 null,不猜 provider 默认值;缺少可靠定位或原生 tmux/process 采样时显示 unknown,不推导attach信息。--summary/--detail 仅保留兼容性,不增加诊断字段。".to_string(),
344
+ Some("models") => "usage: team-agent models [--provider pi|cursor_agent|codex|claude|claude_code|grok] [QUERY|--search TEXT] [--json]\n\nLists exact provider model ids with case-insensitive multi-word search across provider, vendor, id, display name, and source aliases. Uses each provider's native catalog; Cursor uses `agent --list-models`, Codex uses `codex debug models`, Claude uses SDK stream-json initialize, and Grok uses the PATH-first `grok models` command.".to_string(),
345
345
  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(),
346
346
  Some("shutdown") => "usage: team-agent shutdown [--workspace WORKSPACE] [--team TEAM] [--keep-logs] [--json]".to_string(),
347
347
  Some("restart") => "usage: team-agent restart [WORKSPACE] [--team TEAM] [--allow-fresh] [--session-converge-deadline SECONDS] [--json] [--detail]".to_string(),
@@ -1358,6 +1358,13 @@ pub mod lifecycle_port {
1358
1358
  .map(|path| json!({ "path": path }))
1359
1359
  .collect::<Vec<_>>();
1360
1360
  deadline.check("process_residuals")?;
1361
+ // Let already-protected-filtered shutdown roots converge before the final
1362
+ // snapshot, so a transient OS reaping window is not reported as a residue.
1363
+ wait_for_processes_gone(
1364
+ &root_pids,
1365
+ std::time::Duration::from_millis(500).min(deadline.remaining()),
1366
+ );
1367
+ deadline.check("process_residuals")?;
1361
1368
  // C-①: the post-verify gets ONE fresh verification snapshot (reaps changed
1362
1369
  // the world; #248 post-verify facts must be current, not the entry view).
1363
1370
  let verify_table =
@@ -1936,6 +1943,10 @@ pub mod lifecycle_port {
1936
1943
  }
1937
1944
  Ok(())
1938
1945
  }
1946
+
1947
+ fn remaining(&self) -> std::time::Duration {
1948
+ self.timeout.saturating_sub(self.start.elapsed())
1949
+ }
1939
1950
  }
1940
1951
 
1941
1952
  fn shutdown_state_for_team(workspace: &Path, team: Option<&str>) -> Result<Value, CliError> {
@@ -2239,9 +2250,70 @@ pub mod lifecycle_port {
2239
2250
  }
2240
2251
  }
2241
2252
 
2253
+ #[cfg(test)]
2254
+ #[derive(Clone, Copy)]
2255
+ struct ShutdownTestProbes {
2256
+ process_table: Option<fn() -> Result<Vec<String>, String>>,
2257
+ pid_is_alive: Option<fn(u32) -> bool>,
2258
+ }
2259
+
2260
+ #[cfg(test)]
2261
+ std::thread_local! {
2262
+ static SHUTDOWN_TEST_PROBES: std::cell::Cell<ShutdownTestProbes> = const {
2263
+ std::cell::Cell::new(ShutdownTestProbes { process_table: None, pid_is_alive: None })
2264
+ };
2265
+ }
2266
+
2267
+ #[cfg(test)]
2268
+ struct ShutdownTestProbeRestore(ShutdownTestProbes);
2269
+
2270
+ #[cfg(test)]
2271
+ impl Drop for ShutdownTestProbeRestore {
2272
+ fn drop(&mut self) {
2273
+ SHUTDOWN_TEST_PROBES.with(|probes| probes.set(self.0));
2274
+ }
2275
+ }
2276
+
2277
+ #[cfg(test)]
2278
+ pub(super) fn with_process_table_test_probe<T>(
2279
+ probe: fn() -> Result<Vec<String>, String>,
2280
+ run: impl FnOnce() -> T,
2281
+ ) -> T {
2282
+ let previous = SHUTDOWN_TEST_PROBES.with(|probes| {
2283
+ let previous = probes.get();
2284
+ probes.set(ShutdownTestProbes { process_table: Some(probe), ..previous });
2285
+ previous
2286
+ });
2287
+ let _restore = ShutdownTestProbeRestore(previous);
2288
+ run()
2289
+ }
2290
+
2291
+ #[cfg(test)]
2292
+ pub(super) fn with_pid_liveness_test_probe<T>(
2293
+ probe: fn(u32) -> bool,
2294
+ run: impl FnOnce() -> T,
2295
+ ) -> T {
2296
+ let previous = SHUTDOWN_TEST_PROBES.with(|probes| {
2297
+ let previous = probes.get();
2298
+ probes.set(ShutdownTestProbes { pid_is_alive: Some(probe), ..previous });
2299
+ previous
2300
+ });
2301
+ let _restore = ShutdownTestProbeRestore(previous);
2302
+ run()
2303
+ }
2304
+
2242
2305
  /// swallow batch 1: the raw ps probe with an explicit error channel — a failed
2243
2306
  /// probe must never masquerade as "no processes" (CLAUDE.md §5).
2244
2307
  fn probed_process_table() -> Result<Vec<ProcessInfo>, String> {
2308
+ #[cfg(test)]
2309
+ if let Some(probe) = SHUTDOWN_TEST_PROBES.with(|probes| probes.get().process_table) {
2310
+ return probe().map(|lines| {
2311
+ lines
2312
+ .iter()
2313
+ .filter_map(|line| parse_process_info(line))
2314
+ .collect()
2315
+ });
2316
+ }
2245
2317
  match crate::os_probe::bounded_command_output_with_probe(
2246
2318
  std::process::Command::new("ps").args(["-axo", "pid=,ppid=,pgid=,sess=,command="]),
2247
2319
  "ps_table",
@@ -2696,20 +2768,31 @@ pub mod lifecycle_port {
2696
2768
  // 1:1 to SIGTERM/SIGKILL, ProcessLiveness::Live == the previous
2697
2769
  // `kill(pid, 0) == 0 || EPERM` branch).
2698
2770
 
2771
+ fn shutdown_pid_is_alive(pid: u32) -> bool {
2772
+ #[cfg(test)]
2773
+ if let Some(probe) = SHUTDOWN_TEST_PROBES.with(|probes| probes.get().pid_is_alive) {
2774
+ return probe(pid);
2775
+ }
2776
+ crate::platform::process::pid_is_alive(pid)
2777
+ }
2778
+
2699
2779
  fn wait_for_processes_gone(pids: &[u32], timeout: std::time::Duration) {
2700
2780
  let start = std::time::Instant::now();
2701
2781
  loop {
2702
2782
  for pid in pids {
2703
2783
  crate::platform::process::reap_child_if_possible(*pid);
2704
2784
  }
2785
+ let elapsed = start.elapsed();
2705
2786
  if !pids
2706
2787
  .iter()
2707
- .any(|pid| crate::platform::process::pid_is_alive(*pid))
2708
- || start.elapsed() >= timeout
2788
+ .any(|pid| shutdown_pid_is_alive(*pid))
2789
+ || elapsed >= timeout
2709
2790
  {
2710
2791
  return;
2711
2792
  }
2712
- std::thread::sleep(std::time::Duration::from_millis(25));
2793
+ std::thread::sleep(
2794
+ std::time::Duration::from_millis(25).min(timeout.saturating_sub(elapsed)),
2795
+ );
2713
2796
  }
2714
2797
  }
2715
2798
 
@@ -2730,7 +2813,7 @@ pub mod lifecycle_port {
2730
2813
  .collect::<std::collections::BTreeSet<_>>();
2731
2814
  for pid in root_pids {
2732
2815
  if !protected.contains_pid(*pid)
2733
- && crate::platform::process::pid_is_alive(*pid)
2816
+ && shutdown_pid_is_alive(*pid)
2734
2817
  && seen.insert(*pid)
2735
2818
  {
2736
2819
  residuals.push(ProcessInfo {
@@ -357,8 +357,192 @@ mod tests {
357
357
  cleanup_fixture(&path);
358
358
  }
359
359
 
360
+ #[cfg(unix)]
361
+ #[test]
362
+ fn cursor_native_tip_fixture_works_through_public_cli_once() {
363
+ let agent = native_cli_fixture(
364
+ "agent",
365
+ include_bytes!("../provider/testdata/cursor-list-models-native.stdout"),
366
+ );
367
+ let output = run_public_models_cli(
368
+ &agent.program,
369
+ agent.response.as_deref(),
370
+ &["models", "--provider", "cursor_agent", "--json"],
371
+ );
372
+ let text = public_cli_text(&output);
373
+ let calls = std::fs::read_to_string(agent.program.with_extension("count")).unwrap_or_default();
374
+ drop(agent);
375
+ assert!(output.status.success(), "{text}");
376
+ assert_eq!(calls, "1:--list-models\n");
377
+ let value: Value = serde_json::from_str(text.trim()).unwrap();
378
+ assert_eq!(value["schema_version"], "models.v1");
379
+ assert_eq!(value["provider"], "cursor_agent");
380
+ let models = value["models"].as_array().unwrap();
381
+ for exact in ["gpt-5.6-luna-low", "gpt-5.6-luna-xhigh"] {
382
+ assert!(models.iter().any(|model| model["model_id"] == exact));
383
+ }
384
+ assert_eq!(models[0]["model_id"], "auto");
385
+ assert_eq!(models[0]["default"], true);
386
+ }
387
+
388
+ #[cfg(unix)]
389
+ #[test]
390
+ fn grok_public_cli_uses_native_catalog_and_keeps_shared_projection() {
391
+ let grok = native_cli_fixture(
392
+ "grok",
393
+ include_bytes!("../provider/testdata/grok-models-authenticated.stdout"),
394
+ );
395
+ let json = run_public_models_cli(
396
+ &grok.program,
397
+ grok.response.as_deref(),
398
+ &["models", "--provider", "grok", "--json"],
399
+ );
400
+ let human = run_public_models_cli(
401
+ &grok.program,
402
+ grok.response.as_deref(),
403
+ &["models", "--provider", "grok", "--search", "BUILD FAST"],
404
+ );
405
+ let no_match = run_public_models_cli(
406
+ &grok.program,
407
+ grok.response.as_deref(),
408
+ &["models", "--provider", "grok", "--search", "definitely-absent"],
409
+ );
410
+ let json_text = public_cli_text(&json);
411
+ let human_text = public_cli_text(&human);
412
+ let no_match_text = public_cli_text(&no_match);
413
+ let calls = std::fs::read_to_string(grok.program.with_extension("count")).unwrap_or_default();
414
+ drop(grok);
415
+ assert!(json.status.success(), "{json_text}");
416
+ assert!(human.status.success(), "{human_text}");
417
+ assert!(no_match.status.success(), "{no_match_text}");
418
+ assert_eq!(calls, "1:models\n".repeat(3));
419
+ let value: Value = serde_json::from_str(json_text.trim()).unwrap();
420
+ assert_eq!(value["schema_version"], "models.v1");
421
+ assert_eq!(value["provider"], "grok");
422
+ assert_eq!(value["auth_basis"], "catalog_visibility");
423
+ let models = value["models"].as_array().unwrap();
424
+ assert_eq!(
425
+ models.iter().map(|model| model["model_id"].as_str().unwrap()).collect::<Vec<_>>(),
426
+ vec!["grok-4.7", "grok-4.7-build-fast", "grok-4.5", "grok-4.6"]
427
+ );
428
+ assert!(models.iter().all(|model| model["provider"] == "grok" && model["vendor"] == "xai" && model["aliases"] == json!([])));
429
+ assert_eq!(models[0]["default"], false);
430
+ assert_eq!(models[3]["default"], true);
431
+ assert!(human_text.contains("grok-4.7-build-fast"));
432
+ assert!(no_match_text.contains("No models matched --search"));
433
+ assert!(no_match_text.contains("auth: ok (catalog_visibility)"));
434
+
435
+ let grok = native_cli_fixture(
436
+ "grok",
437
+ include_bytes!("../provider/testdata/grok-models-unauthenticated.stdout"),
438
+ );
439
+ let changed_default = run_public_models_cli(
440
+ &grok.program,
441
+ grok.response.as_deref(),
442
+ &["models", "--provider", "grok", "--json"],
443
+ );
444
+ let changed_text = public_cli_text(&changed_default);
445
+ drop(grok);
446
+ assert!(changed_default.status.success(), "{changed_text}");
447
+ let changed: Value = serde_json::from_str(changed_text.trim()).unwrap();
448
+ assert_eq!(changed["models"][0]["model_id"], "grok-4.7");
449
+ assert_eq!(changed["models"][0]["default"], true);
450
+ assert_eq!(changed["models"][1]["default"], false);
451
+ }
452
+
453
+ #[cfg(unix)]
454
+ #[test]
455
+ fn grok_invalid_native_catalogs_fail_closed_without_fallback() {
456
+ let cases: [(&str, Vec<u8>); 7] = [
457
+ ("unknown row", b"You are logged in with grok.com.\n\nDefault model: grok-4.6\n\nAvailable models:\n - grok-4.6\n ? grok-4.5\n".to_vec()),
458
+ ("duplicate", b"You are logged in with grok.com.\n\nDefault model: grok-4.6\n\nAvailable models:\n - grok-4.6\n - grok-4.6\n".to_vec()),
459
+ ("invalid id", b"You are logged in with grok.com.\n\nDefault model: grok-4.6\n\nAvailable models:\n * grok bad (default)\n".to_vec()),
460
+ ("control id", b"You are logged in with grok.com.\n\nDefault model: grok-4.6\n\nAvailable models:\n * grok-\x01evil (default)\n".to_vec()),
461
+ ("empty", b"You are not authenticated.\n\nDefault model: grok-4.6\n\nAvailable models:\n".to_vec()),
462
+ ("contradictory default", b"You are not authenticated.\n\nDefault model: grok-4.6\n\nAvailable models:\n * grok-4.7 (default)\n - grok-4.6\n".to_vec()),
463
+ ("invalid utf8", vec![b'Y', b'o', b'u', 0xff]),
464
+ ];
465
+ let mut outcomes = Vec::new();
466
+ for (name, bytes) in cases {
467
+ let grok = native_cli_fixture("grok", &bytes);
468
+ let output = run_public_models_cli(
469
+ &grok.program,
470
+ grok.response.as_deref(),
471
+ &["models", "--provider", "grok", "--json"],
472
+ );
473
+ let text = public_cli_text(&output);
474
+ let calls = std::fs::read_to_string(grok.program.with_extension("count")).unwrap_or_default();
475
+ outcomes.push((name, output.status.success(), text, calls));
476
+ }
477
+ for (name, success, text, calls) in outcomes {
478
+ assert!(!success, "{name} catalog unexpectedly accepted: {text}");
479
+ assert_eq!(calls, "1:models\n", "{name}");
480
+ let value: Value = serde_json::from_str(text.trim()).unwrap();
481
+ assert_eq!(value["schema_version"], "models.v1", "{name}");
482
+ assert_eq!(value["ok"], false, "{name}");
483
+ assert_eq!(value["models"], json!([]), "{name}");
484
+ assert!(!value["error"].as_str().unwrap().to_ascii_lowercase().contains("unsupported model provider"), "{name}: {text}");
485
+ }
486
+ }
487
+
488
+ #[cfg(unix)]
489
+ #[test]
490
+ fn grok_nonzero_native_command_is_not_a_successful_empty_catalog() {
491
+ let grok = CliFixture::new(fixture_program("grok", "printf 'private diagnostic' >&2\nexit 7"));
492
+ let output = run_public_models_cli(
493
+ &grok.program,
494
+ None,
495
+ &["models", "--provider", "grok", "--json"],
496
+ );
497
+ let text = public_cli_text(&output);
498
+ let calls = std::fs::read_to_string(grok.program.with_extension("count")).unwrap_or_default();
499
+ drop(grok);
500
+ assert!(!output.status.success(), "{text}");
501
+ assert_eq!(calls, "1:models\n");
502
+ assert!(!text.contains("private diagnostic"));
503
+ let value: Value = serde_json::from_str(text.trim()).unwrap();
504
+ assert_eq!(value["ok"], false);
505
+ assert_eq!(value["models"], json!([]));
506
+ assert!(value["error"].as_str().unwrap().to_ascii_lowercase().contains("grok"), "{text}");
507
+ }
508
+
509
+ #[cfg(unix)]
510
+ #[test]
511
+ fn grok_failure_help_and_unsupported_action_are_publicly_diagnostic() {
512
+ let missing = CliFixture::new(fixture_program("not-grok", "exit 0"));
513
+ let output = run_public_models_cli(
514
+ &missing.program,
515
+ None,
516
+ &["models", "--provider", "grok", "--json"],
517
+ );
518
+ let text = public_cli_text(&output);
519
+ let value: Value = serde_json::from_str(text.trim()).unwrap();
520
+ assert_eq!(value["ok"], false);
521
+ assert!(value["error"].as_str().unwrap().to_ascii_lowercase().contains("grok"), "{text}");
522
+ assert!(value["action"].as_str().unwrap().contains("grok"), "{text}");
523
+ assert!(crate::cli::emit::default_help().contains("grok"));
524
+ assert!(crate::cli::spec::command_spec("models").unwrap().usage.contains("grok"));
525
+
526
+ let unsupported = run_public_models_cli(
527
+ &missing.program,
528
+ None,
529
+ &["models", "--provider", "cloud", "--json"],
530
+ );
531
+ let unsupported_text = public_cli_text(&unsupported);
532
+ let unsupported_value: Value = serde_json::from_str(unsupported_text.trim()).unwrap();
533
+ drop(missing);
534
+ assert!(!output.status.success());
535
+ assert!(!unsupported.status.success());
536
+ assert!(unsupported_value["action"].as_str().unwrap().contains("grok"), "{unsupported_text}");
537
+ }
538
+
360
539
  #[cfg(unix)]
361
540
  fn fixture(body: &str) -> std::path::PathBuf {
541
+ fixture_program("pi", body)
542
+ }
543
+
544
+ #[cfg(unix)]
545
+ fn fixture_program(program: &str, body: &str) -> std::path::PathBuf {
362
546
  use std::os::unix::fs::PermissionsExt;
363
547
  let sequence = FIXTURE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
364
548
  let root = std::env::current_exe()
@@ -366,7 +550,7 @@ mod tests {
366
550
  .parent()
367
551
  .unwrap()
368
552
  .join(format!(
369
- "team-agent-pi-fixture-{}-{}-{sequence}",
553
+ "team-agent-{program}-fixture-{}-{}-{sequence}",
370
554
  std::process::id(),
371
555
  std::time::SystemTime::now()
372
556
  .duration_since(std::time::UNIX_EPOCH)
@@ -374,7 +558,7 @@ mod tests {
374
558
  .as_nanos()
375
559
  ));
376
560
  std::fs::create_dir(&root).unwrap();
377
- let path = root.join("pi");
561
+ let path = root.join(program);
378
562
  std::fs::write(
379
563
  &path,
380
564
  format!("#!/bin/sh\nprintf '%s\\n' \"$#:$1\" >> \"$0.count\"\n{body}\n"),
@@ -386,6 +570,71 @@ mod tests {
386
570
  path
387
571
  }
388
572
 
573
+ #[cfg(unix)]
574
+ struct CliFixture {
575
+ program: std::path::PathBuf,
576
+ response: Option<std::path::PathBuf>,
577
+ }
578
+
579
+ #[cfg(unix)]
580
+ impl CliFixture {
581
+ fn new(program: std::path::PathBuf) -> Self {
582
+ Self { program, response: None }
583
+ }
584
+ }
585
+
586
+ #[cfg(unix)]
587
+ impl Drop for CliFixture {
588
+ fn drop(&mut self) {
589
+ cleanup_fixture(&self.program);
590
+ }
591
+ }
592
+
593
+ #[cfg(unix)]
594
+ fn native_cli_fixture(program: &str, bytes: &[u8]) -> CliFixture {
595
+ let program = fixture_program(program, "exec /bin/cat \"$TEAM_AGENT_MODELS_FIXTURE\"");
596
+ let mut fixture = CliFixture::new(program);
597
+ let response = fixture.program.with_extension("stdout");
598
+ std::fs::write(&response, bytes).unwrap();
599
+ fixture.response = Some(response);
600
+ fixture
601
+ }
602
+
603
+ #[cfg(unix)]
604
+ fn run_public_models_cli(
605
+ path: &Path,
606
+ response: Option<&Path>,
607
+ args: &[&str],
608
+ ) -> std::process::Output {
609
+ let mut command = std::process::Command::new(std::env::current_exe().unwrap());
610
+ command
611
+ .args([
612
+ "--exact",
613
+ "cli::models::tests::real_dispatcher_child_helper",
614
+ "--nocapture",
615
+ ])
616
+ .env("TEAM_AGENT_MODELS_CHILD", "1")
617
+ .env("TEAM_AGENT_MODELS_ARGS", serde_json::to_string(args).unwrap())
618
+ .env("PATH", path.parent().unwrap())
619
+ .env_remove("PI_PROVIDER")
620
+ .env_remove("PI_MODEL")
621
+ .env_remove("TEAM_AGENT_MODELS_FIXTURE");
622
+ if let Some(response) = response {
623
+ command.env("TEAM_AGENT_MODELS_FIXTURE", response);
624
+ }
625
+ command.output().unwrap()
626
+ }
627
+
628
+ #[cfg(unix)]
629
+ fn public_cli_text(output: &std::process::Output) -> String {
630
+ String::from_utf8(output.stdout.clone())
631
+ .unwrap()
632
+ .split("__TEAM_AGENT_MODELS_CLI_OUTPUT_v1__\n")
633
+ .nth(1)
634
+ .expect("public CLI child output marker")
635
+ .to_string()
636
+ }
637
+
389
638
  #[cfg(unix)]
390
639
  fn fixture_with_sidecars(body: &str) -> std::path::PathBuf {
391
640
  let path = fixture(body);
@@ -968,7 +1217,7 @@ mod tests {
968
1217
  );
969
1218
  assert_eq!(
970
1219
  crate::cli::spec::command_spec("models").unwrap().usage,
971
- "usage: team-agent models [--provider pi|cursor_agent|codex|claude|claude_code] [QUERY|--search TEXT] [--json]"
1220
+ "usage: team-agent models [--provider pi|cursor_agent|codex|claude|claude_code|grok] [QUERY|--search TEXT] [--json]"
972
1221
  );
973
1222
  }
974
1223
 
@@ -147,7 +147,7 @@ pub(crate) const COMMAND_SPECS: &[CommandSpec] = &[
147
147
  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] [--backend tmux|conpty] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, sunset: None, action: None, governance: None },
148
148
  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, sunset: Some("next compatibility release"), action: Some("use positional logical TO and the returned message id"), governance: None },
149
149
  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, sunset: None, action: None, governance: None },
150
- 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|codex|claude|claude_code] [QUERY|--search TEXT] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, sunset: None, action: None, governance: None },
150
+ 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|codex|claude|claude_code|grok] [QUERY|--search TEXT] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, sunset: None, action: None, governance: None },
151
151
  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, sunset: None, action: None, governance: None },
152
152
  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, sunset: None, action: None, governance: None },
153
153
  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, sunset: None, action: None, governance: None },