@team-agent/installer 0.5.107 → 0.5.109

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.
@@ -80,10 +80,11 @@ impl CatalogError {
80
80
  "cursor_agent" => "agent",
81
81
  "codex" => "codex",
82
82
  "claude" | "claude_code" => "claude",
83
+ "grok" => "grok",
83
84
  _ => "provider CLI",
84
85
  };
85
86
  match self {
86
- Self::UnsupportedProvider => "choose a supported provider: pi, cursor_agent, codex, claude, or claude_code".into(),
87
+ Self::UnsupportedProvider => "choose a supported provider: pi, cursor_agent, codex, claude, claude_code, or grok".into(),
87
88
  Self::ExecutableUnavailable => format!("install or repair the PATH-first `{executable}` executable, then retry `team-agent models --provider {provider}`"),
88
89
  Self::UnsupportedSchema => format!("upgrade `{executable}` to a version that supports its model catalog protocol, then retry `team-agent models --provider {provider}`"),
89
90
  Self::CommandFailed => format!("run `{executable}` directly to check catalog/account access, then retry `team-agent models --provider {provider}`"),
@@ -118,6 +119,7 @@ fn display_provider(provider: &str) -> &str {
118
119
  "cursor_agent" => "Cursor",
119
120
  "codex" => "Codex",
120
121
  "claude" | "claude_code" => "Claude",
122
+ "grok" => "Grok",
121
123
  _ => provider,
122
124
  }
123
125
  }
@@ -130,6 +132,7 @@ pub fn parse_catalog_provider(name: &str) -> Option<Provider> {
130
132
  "codex" => Some(Provider::Codex),
131
133
  "claude" => Some(Provider::Claude),
132
134
  "claude_code" => Some(Provider::ClaudeCode),
135
+ "grok" => Some(Provider::Grok),
133
136
  _ => None,
134
137
  }
135
138
  }
@@ -153,6 +156,7 @@ enum CatalogSource {
153
156
  Pi,
154
157
  CursorAgent,
155
158
  Codex,
159
+ Grok,
156
160
  Claude,
157
161
  }
158
162
 
@@ -163,7 +167,8 @@ fn catalog_source(provider: Provider) -> Option<CatalogSource> {
163
167
  Provider::CursorAgent => Some(CatalogSource::CursorAgent),
164
168
  Provider::Codex => Some(CatalogSource::Codex),
165
169
  Provider::Claude | Provider::ClaudeCode => Some(CatalogSource::Claude),
166
- Provider::Copilot | Provider::GeminiCli | Provider::Grok | Provider::Fake => None,
170
+ Provider::Grok => Some(CatalogSource::Grok),
171
+ Provider::Copilot | Provider::GeminiCli | Provider::Fake => None,
167
172
  }
168
173
  }
169
174
 
@@ -184,6 +189,10 @@ pub fn discover_model_catalog(provider_name: &str) -> Result<Vec<ModelRecord>, C
184
189
  let bytes = run_native("codex", &["debug", "models"], None, None, &[], CATALOG_TIMEOUT, MAX_CATALOG_BYTES, &mut NoopObserver)?;
185
190
  parse_codex_catalog(&bytes, requested_provider)?
186
191
  }
192
+ CatalogSource::Grok => {
193
+ let bytes = run_native("grok", &["models"], None, None, &[], CATALOG_TIMEOUT, MAX_CATALOG_BYTES, &mut NoopObserver)?;
194
+ parse_grok_catalog(&bytes)?
195
+ }
187
196
  CatalogSource::Claude => discover_claude_catalog(requested_provider)?,
188
197
  };
189
198
  Ok(records)
@@ -242,8 +251,20 @@ pub(crate) fn parse_pi_catalog(bytes: &[u8]) -> Result<Vec<ModelRecord>, Catalog
242
251
 
243
252
  pub(crate) fn parse_cursor_catalog(bytes: &[u8]) -> Result<Vec<ModelRecord>, CatalogError> {
244
253
  let text = std::str::from_utf8(bytes).map_err(|_| CatalogError::InvalidUtf8)?;
254
+ let lines = text.lines().collect::<Vec<_>>();
255
+ let tip_line = lines
256
+ .iter()
257
+ .rposition(|line| !line.trim().is_empty())
258
+ .filter(|index| {
259
+ lines[*index]
260
+ .strip_prefix("Tip: use --model ")
261
+ .is_some_and(|tip| !tip.is_empty())
262
+ });
245
263
  let mut records: Vec<ModelRecord> = Vec::new();
246
- for line in text.lines() {
264
+ for (index, line) in lines.into_iter().enumerate() {
265
+ if Some(index) == tip_line {
266
+ continue;
267
+ }
247
268
  let line = line.trim();
248
269
  if line.is_empty() || line.trim_end_matches(':').eq_ignore_ascii_case("available models") { continue; }
249
270
  let Some((id, display)) = line.split_once(" - ") else { return Err(CatalogError::Malformed); };
@@ -260,6 +281,117 @@ pub(crate) fn parse_cursor_catalog(bytes: &[u8]) -> Result<Vec<ModelRecord>, Cat
260
281
  Ok(records)
261
282
  }
262
283
 
284
+ fn parse_grok_catalog(bytes: &[u8]) -> Result<Vec<ModelRecord>, CatalogError> {
285
+ let text = std::str::from_utf8(bytes).map_err(|_| CatalogError::InvalidUtf8)?;
286
+ let mut has_auth_notice = false;
287
+ let mut has_models_header = false;
288
+ let mut declared_default: Option<String> = None;
289
+ let mut starred_default: Option<String> = None;
290
+ let mut marked_default: Option<String> = None;
291
+ let mut seen = HashSet::new();
292
+ let mut records = Vec::new();
293
+
294
+ for line in text.lines() {
295
+ let line = line.trim();
296
+ if line.is_empty() {
297
+ continue;
298
+ }
299
+ if !has_models_header {
300
+ if matches!(line, "You are logged in with grok.com." | "You are not authenticated.") {
301
+ if has_auth_notice {
302
+ return Err(CatalogError::Malformed);
303
+ }
304
+ has_auth_notice = true;
305
+ continue;
306
+ }
307
+ if let Some(id) = line.strip_prefix("Default model: ") {
308
+ if declared_default.is_some() || id.is_empty() {
309
+ return Err(CatalogError::Malformed);
310
+ }
311
+ validate_identity(id)?;
312
+ declared_default = Some(id.to_string());
313
+ continue;
314
+ }
315
+ if line == "Available models:" {
316
+ has_models_header = true;
317
+ continue;
318
+ }
319
+ return Err(CatalogError::Malformed);
320
+ }
321
+
322
+ let (is_starred, row) = if let Some(row) = line.strip_prefix("- ") {
323
+ (false, row)
324
+ } else if let Some(row) = line.strip_prefix("* ") {
325
+ (true, row)
326
+ } else {
327
+ return Err(CatalogError::Malformed);
328
+ };
329
+ if row == "(default)" {
330
+ return Err(CatalogError::Malformed);
331
+ }
332
+ let (id, has_default_marker) = row
333
+ .strip_suffix(" (default)")
334
+ .map_or((row, false), |id| (id, true));
335
+ if id.is_empty() {
336
+ return Err(CatalogError::Malformed);
337
+ }
338
+ validate_identity(id)?;
339
+ if !seen.insert(id.to_string()) {
340
+ return Err(CatalogError::DuplicateIdentity(id.to_string()));
341
+ }
342
+ if is_starred {
343
+ if starred_default.as_deref().is_some_and(|current| current != id) {
344
+ return Err(CatalogError::Malformed);
345
+ }
346
+ starred_default = Some(id.to_string());
347
+ }
348
+ if has_default_marker {
349
+ if marked_default.as_deref().is_some_and(|current| current != id) {
350
+ return Err(CatalogError::Malformed);
351
+ }
352
+ marked_default = Some(id.to_string());
353
+ }
354
+ records.push(ModelRecord {
355
+ provider: "grok".into(),
356
+ vendor: "xai".into(),
357
+ id: id.into(),
358
+ display_name: id.into(),
359
+ default: None,
360
+ aliases: Vec::new(),
361
+ });
362
+ }
363
+ if !has_models_header {
364
+ return Err(CatalogError::Malformed);
365
+ }
366
+ if records.is_empty() {
367
+ return Err(CatalogError::Empty);
368
+ }
369
+
370
+ let mut default_id: Option<&str> = None;
371
+ for candidate in [
372
+ declared_default.as_deref(),
373
+ starred_default.as_deref(),
374
+ marked_default.as_deref(),
375
+ ]
376
+ .into_iter()
377
+ .flatten()
378
+ {
379
+ if default_id.is_some_and(|current| current != candidate) {
380
+ return Err(CatalogError::Malformed);
381
+ }
382
+ default_id = Some(candidate);
383
+ }
384
+ if let Some(default_id) = default_id {
385
+ if !seen.contains(default_id) {
386
+ return Err(CatalogError::Malformed);
387
+ }
388
+ for record in &mut records {
389
+ record.default = Some(record.id.as_str() == default_id);
390
+ }
391
+ }
392
+ Ok(records)
393
+ }
394
+
263
395
  fn parse_codex_catalog(bytes: &[u8], provider: &str) -> Result<Vec<ModelRecord>, CatalogError> {
264
396
  let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|_| CatalogError::Malformed)?;
265
397
  let rows = value.get("models").and_then(serde_json::Value::as_array).ok_or(CatalogError::UnsupportedSchema)?;
@@ -457,15 +589,18 @@ pub(crate) fn run_command(
457
589
  Some((writer, rx))
458
590
  } else { None };
459
591
  let mut output = None;
592
+ let mut pending_output = None;
593
+ let mut output_ready_at: Option<Instant> = None;
460
594
  let mut too_large_at = None;
461
595
  let status = loop {
462
- match reader_rx.try_recv() {
596
+ match pending_output.take().map_or_else(|| reader_rx.try_recv(), Ok) {
463
597
  Ok(Ok(bytes)) => {
464
598
  if bytes.len() as u64 > max_bytes {
465
599
  too_large_at.get_or_insert_with(Instant::now);
466
600
  } else {
467
601
  output = Some(bytes);
468
602
  }
603
+ output_ready_at = Some(Instant::now());
469
604
  }
470
605
  Ok(Err(_)) => { terminate(&mut child, pid); return Err(CatalogError::OutputReadFailed); }
471
606
  Err(mpsc::TryRecvError::Disconnected) if output.is_none() && too_large_at.is_none() => { terminate(&mut child, pid); return Err(CatalogError::OutputReadFailed); }
@@ -482,7 +617,24 @@ pub(crate) fn run_command(
482
617
  terminate(&mut child, pid);
483
618
  return Err(CatalogError::OutputTooLarge);
484
619
  }
485
- Ok(None) if started.elapsed() < timeout => std::thread::sleep(Duration::from_millis(10)),
620
+ Ok(None) if started.elapsed() < timeout => {
621
+ let mut wait = timeout.saturating_sub(started.elapsed()).min(Duration::from_millis(10));
622
+ if let Some(over_at) = too_large_at {
623
+ wait = wait.min(Duration::from_millis(100).saturating_sub(over_at.elapsed()));
624
+ }
625
+ if output.is_none() && too_large_at.is_none() {
626
+ // Reader completion wakes us instead of trailing a blind 10ms sleep.
627
+ if let Ok(result) = reader_rx.recv_timeout(wait) {
628
+ pending_output = Some(result);
629
+ }
630
+ } else {
631
+ // stdout can close before exit; never busy-poll a disconnected channel.
632
+ let interval = output_ready_at.map_or(Duration::from_millis(10), |at| {
633
+ catalog_exit_poll_interval(at.elapsed())
634
+ });
635
+ std::thread::sleep(wait.min(interval));
636
+ }
637
+ }
486
638
  Ok(None) if too_large_at.is_some() => { terminate(&mut child, pid); return Err(CatalogError::OutputTooLarge); }
487
639
  Ok(None) => { terminate(&mut child, pid); return Err(CatalogError::Timeout); }
488
640
  Err(_) => { terminate(&mut child, pid); return Err(CatalogError::CommandCouldNotBeObserved); }
@@ -510,6 +662,16 @@ pub(crate) fn run_command(
510
662
  output.ok_or(CatalogError::OutputReadFailed)
511
663
  }
512
664
 
665
+ fn catalog_exit_poll_interval(elapsed: Duration) -> Duration {
666
+ [1, 3, 6, 10]
667
+ .into_iter()
668
+ .map(Duration::from_millis)
669
+ .find(|checkpoint| *checkpoint > elapsed)
670
+ .map_or(Duration::from_millis(10), |checkpoint| {
671
+ checkpoint.saturating_sub(elapsed)
672
+ })
673
+ }
674
+
513
675
  fn terminate(child: &mut Child, pid: u32) {
514
676
  #[cfg(unix)]
515
677
  unsafe {
@@ -568,6 +730,151 @@ impl Drop for OwnedTempDir {
568
730
  fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); }
569
731
  }
570
732
 
733
+ #[cfg(all(test, unix))]
734
+ mod command_wait_regressions {
735
+ use super::*;
736
+
737
+ #[derive(Default)]
738
+ struct Observer {
739
+ pid: Option<u32>,
740
+ exits: Vec<Option<i32>>,
741
+ output_timeouts: usize,
742
+ }
743
+
744
+ impl CatalogObserver for Observer {
745
+ fn spawned(&mut self, pid: u32) { self.pid = Some(pid); }
746
+ fn exited(&mut self, status: &ExitStatus) { self.exits.push(status.code()); }
747
+ fn output_timeout(&mut self) { self.output_timeouts += 1; }
748
+ }
749
+
750
+ fn run(
751
+ script: &str,
752
+ input: Option<&[u8]>,
753
+ timeout: Duration,
754
+ max_bytes: u64,
755
+ observer: &mut Observer,
756
+ ) -> Result<Vec<u8>, CatalogError> {
757
+ run_command(
758
+ Path::new("/bin/sh"), &["-c", script], input, None, &[], &[],
759
+ timeout, max_bytes, observer,
760
+ )
761
+ }
762
+
763
+ fn assert_owned_child_gone(observer: &Observer) {
764
+ let pid = observer.pid.unwrap();
765
+ assert!(pid > 1);
766
+ let status = Command::new("/bin/kill")
767
+ .args(["-0", &pid.to_string()])
768
+ .stdout(Stdio::null())
769
+ .stderr(Stdio::null())
770
+ .status()
771
+ .unwrap();
772
+ assert!(!status.success(), "owned child must be reaped");
773
+ }
774
+
775
+ #[test]
776
+ fn eof_and_success_preserve_input_and_raw_output() {
777
+ let mut observer = Observer::default();
778
+ let input = b"catalog\0\xff\n";
779
+ let output = run(
780
+ "exec /bin/cat", Some(input), CATALOG_TIMEOUT, MAX_CATALOG_BYTES, &mut observer,
781
+ ).unwrap();
782
+ assert_eq!(output, input.to_vec());
783
+ assert_eq!(observer.exits, vec![Some(0)]);
784
+ assert_eq!(observer.output_timeouts, 0);
785
+ assert_owned_child_gone(&observer);
786
+ }
787
+
788
+ #[test]
789
+ fn stdout_eof_does_not_hide_a_later_nonzero_exit() {
790
+ let mut observer = Observer::default();
791
+ let error = run(
792
+ "printf 'partial'; exec 1>&-; /bin/sleep 0.03; exit 17",
793
+ None, CATALOG_TIMEOUT, MAX_CATALOG_BYTES, &mut observer,
794
+ ).unwrap_err();
795
+ assert_eq!(error, CatalogError::CommandFailed);
796
+ assert_eq!(observer.exits, vec![Some(17)]);
797
+ assert_eq!(observer.output_timeouts, 0);
798
+ assert_owned_child_gone(&observer);
799
+ }
800
+
801
+ #[test]
802
+ fn stdout_eof_does_not_disable_the_ten_second_process_timeout() {
803
+ let mut observer = Observer::default();
804
+ let started = Instant::now();
805
+ let error = run(
806
+ "exec 1>&-; exec /bin/sleep 30", None, CATALOG_TIMEOUT,
807
+ MAX_CATALOG_BYTES, &mut observer,
808
+ ).unwrap_err();
809
+ let elapsed = started.elapsed();
810
+ assert_eq!(CATALOG_TIMEOUT, Duration::from_secs(10));
811
+ assert_eq!(error, CatalogError::Timeout);
812
+ assert!(elapsed >= CATALOG_TIMEOUT);
813
+ assert!(elapsed < CATALOG_TIMEOUT + Duration::from_secs(2));
814
+ assert!(observer.exits.is_empty());
815
+ assert_eq!(observer.output_timeouts, 0);
816
+ assert_owned_child_gone(&observer);
817
+ }
818
+
819
+ #[test]
820
+ fn exact_one_mib_is_allowed_and_one_more_byte_is_rejected() {
821
+ assert_eq!(MAX_CATALOG_BYTES, 1024 * 1024);
822
+ let mut observer = Observer::default();
823
+ let script = format!("exec /usr/bin/head -c {MAX_CATALOG_BYTES} /dev/zero");
824
+ let output = run(
825
+ &script, None, CATALOG_TIMEOUT, MAX_CATALOG_BYTES, &mut observer,
826
+ ).unwrap();
827
+ assert_eq!(output.len() as u64, MAX_CATALOG_BYTES);
828
+ assert_owned_child_gone(&observer);
829
+ let mut observer = Observer::default();
830
+ let script = format!("exec /usr/bin/head -c {} /dev/zero", MAX_CATALOG_BYTES + 1);
831
+ assert_eq!(
832
+ run(&script, None, CATALOG_TIMEOUT, MAX_CATALOG_BYTES, &mut observer),
833
+ Err(CatalogError::OutputTooLarge)
834
+ );
835
+ assert_owned_child_gone(&observer);
836
+ }
837
+
838
+ #[test]
839
+ fn oversized_output_keeps_one_hundred_millisecond_exit_grace() {
840
+ let mut observer = Observer::default();
841
+ let started = Instant::now();
842
+ let error = run(
843
+ "printf '%65s' x; exec /bin/sleep 30", None, CATALOG_TIMEOUT, 64, &mut observer,
844
+ ).unwrap_err();
845
+ assert_eq!(error, CatalogError::OutputTooLarge);
846
+ assert!(started.elapsed() >= Duration::from_millis(100));
847
+ assert!(started.elapsed() < Duration::from_secs(2));
848
+ assert_owned_child_gone(&observer);
849
+ }
850
+
851
+ #[test]
852
+ fn exited_child_with_inherited_stdout_keeps_output_timeout_observation() {
853
+ let mut observer = Observer::default();
854
+ let error = run(
855
+ "/bin/sleep 30 & exit 0", None, Duration::from_millis(200),
856
+ MAX_CATALOG_BYTES, &mut observer,
857
+ ).unwrap_err();
858
+ assert_eq!(error, CatalogError::Timeout);
859
+ assert_eq!(observer.exits, vec![Some(0)]);
860
+ assert_eq!(observer.output_timeouts, 1);
861
+ assert_owned_child_gone(&observer);
862
+ }
863
+
864
+ #[test]
865
+ fn early_eof_polling_keeps_ten_millisecond_anchor_and_then_old_cadence() {
866
+ let mut elapsed = Duration::ZERO;
867
+ for (sleep_ms, check_ms) in [(1, 1), (2, 3), (3, 6), (4, 10), (10, 20)] {
868
+ let interval = catalog_exit_poll_interval(elapsed);
869
+ assert_eq!(interval, Duration::from_millis(sleep_ms));
870
+ elapsed += interval;
871
+ assert_eq!(elapsed, Duration::from_millis(check_ms));
872
+ }
873
+ assert_eq!(catalog_exit_poll_interval(Duration::from_micros(9500)), Duration::from_micros(500));
874
+ assert_eq!(catalog_exit_poll_interval(Duration::from_millis(50)), Duration::from_millis(10));
875
+ }
876
+ }
877
+
571
878
  #[cfg(test)]
572
879
  mod tests {
573
880
  use super::*;
@@ -647,4 +954,35 @@ mod tests {
647
954
  assert!(!model_matches(&row, "LUNA absent"));
648
955
  assert!(model_matches(&row, " "));
649
956
  }
957
+
958
+ #[test]
959
+ fn frozen_cursor_catalog_skips_only_its_native_trailing_tip() {
960
+ let frozen = include_bytes!("testdata/cursor-list-models-native.stdout");
961
+ let records = parse_cursor_catalog(frozen).unwrap();
962
+ let low = records.iter().find(|row| row.id == "gpt-5.6-luna-low").unwrap();
963
+ assert_eq!(low.provider.as_str(), "cursor_agent");
964
+ assert_eq!(low.vendor.as_str(), "gpt");
965
+ assert_eq!(low.display_name, "GPT-5.6 Luna 1M Low (current)");
966
+ assert_eq!(low.default, Some(false));
967
+ assert!(low.aliases.is_empty());
968
+ let xhigh = records.iter().find(|row| row.id == "gpt-5.6-luna-xhigh").unwrap();
969
+ assert_eq!(xhigh.id.as_str(), "gpt-5.6-luna-xhigh");
970
+ assert_eq!(xhigh.display_name, "GPT-5.6 Luna 1M Extra High");
971
+ let auto = records.iter().find(|row| row.id == "auto").unwrap();
972
+ assert_eq!(auto.default, Some(true));
973
+
974
+ let text = std::str::from_utf8(frozen).unwrap();
975
+ let without_tip = text.split_once("\nTip: use --model ").unwrap().0;
976
+ assert_eq!(parse_cursor_catalog(without_tip.as_bytes()).unwrap(), parse_cursor_catalog(frozen).unwrap());
977
+ }
978
+
979
+ #[test]
980
+ fn cursor_tip_compatibility_keeps_fail_closed_row_validation() {
981
+ let duplicate = b"Available models\na - A\na - A\n";
982
+ assert!(matches!(parse_cursor_catalog(duplicate), Err(CatalogError::DuplicateIdentity(_))));
983
+ assert!(matches!(parse_cursor_catalog(b"Available models\n"), Err(CatalogError::Empty)));
984
+ assert!(matches!(parse_cursor_catalog(&[b'A', 0xff]), Err(CatalogError::InvalidUtf8)));
985
+ assert!(matches!(parse_cursor_catalog(b"Available models\na - A\nnot a model row\n"), Err(CatalogError::Malformed)));
986
+ assert!(matches!(parse_cursor_catalog(b"Available models\na - A\nTip: use --model a to switch.\nunexpected tail\n"), Err(CatalogError::Malformed)));
987
+ }
650
988
  }