@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
@@ -5,9 +5,11 @@ use super::*;
5
5
  /// 把一段 YAML 文本经 `dumps(loads(t))` 往返,断言**逐字节**等于 Python golden。
6
6
  /// 这是 §4.2 双跑对该方言的字节锁:golden 由
7
7
  /// `PYTHONPATH=src python3 -c "from team_agent.simple_yaml import loads,dumps; ..."` 取得。
8
- fn roundtrip_eq(input: &str, golden: &str) {
9
- let v = loads(input).unwrap();
10
- assert_eq!(dumps(&v), golden, "roundtrip mismatch for input:\n{input}");
8
+ fn roundtrip_eq(id: &str, input: &str, golden: &str, note: &str) {
9
+ let parsed = loads(input);
10
+ assert!(parsed.is_ok(), "case={id}; {note}; parse failed: {parsed:?}");
11
+ let v = parsed.unwrap();
12
+ assert_eq!(dumps(&v), golden, "case={id}; {note}; roundtrip mismatch for input:\n{input}");
11
13
  }
12
14
 
13
15
  // === 真实语料:examples/team.spec.yaml 的 dumps(loads(t)) golden(逐字节) ===
@@ -41,168 +43,47 @@ fn rich_edge_fixture_roundtrip_matches_python_golden() {
41
43
  assert_eq!(dumps(&v), golden);
42
44
  }
43
45
 
44
- // === SKILL.md front-matter 块(`---` 之间那段)的 loads/dumps ===
45
- #[test]
46
- fn skill_front_matter_roundtrip() {
47
- // 取自 skills/team-agent/SKILL.md 的 front matter。
48
- let input = "name: team-agent\ndescription: Use only when the user explicitly asks to start, operate, inspect, shutdown, or restart a Team Agent team. Treat the team-agent CLI as a sealed appliance.\n";
49
- let golden = "name: \"team-agent\"\ndescription: \"Use only when the user explicitly asks to start, operate, inspect, shutdown, or restart a Team Agent team. Treat the team-agent CLI as a sealed appliance.\"\n";
50
- roundtrip_eq(input, golden);
51
- }
52
-
53
- // === 方言怪癖逐一对拍(每个 golden 来自 Python 真相源)===
54
-
55
- #[test]
56
- fn block_scalar_basic() {
57
- roundtrip_eq(
58
- "msg: |\n line one\n line two\n",
59
- "msg: |\n line one\n line two\n",
60
- );
61
- }
62
-
63
- #[test]
64
- fn block_scalar_blank_line_becomes_padded() {
65
- // 空行在 dumps 时产生仅含 pad 的行(尾随空格)—— Python 行为,必须复刻。
66
- roundtrip_eq("msg: |\n a\n\n b\n", "msg: |\n a\n \n b\n");
67
- }
68
-
69
- #[test]
70
- fn inline_unquoted_list_stays_string() {
71
- // ast.literal_eval('[a, b, c]') 失败 → 原样字符串。
72
- roundtrip_eq("tools: [a, b, c]\n", "tools: \"[a, b, c]\"\n");
73
- }
74
-
75
- #[test]
76
- fn inline_quoted_list_becomes_list() {
77
- roundtrip_eq("x: ['a', 'b']\n", "x:\n - \"a\"\n - \"b\"\n");
78
- }
79
-
80
- #[test]
81
- fn ints_canonicalize() {
82
- // 007 -> 7,-5 保号。
83
- roundtrip_eq("a: 1\nb: -5\nc: 007\n", "a: 1\nb: -5\nc: 7\n");
84
- }
85
-
86
- #[test]
87
- fn int_plus_and_underscore() {
88
- roundtrip_eq("a: +3\nb: 1_000\n", "a: 3\nb: 1000\n");
89
- }
90
-
91
- #[test]
92
- fn non_ints_stay_strings() {
93
- roundtrip_eq(
94
- "a: 1.5\nb: 0x1f\nc: 1e3\nd: _1\n",
95
- "a: \"1.5\"\nb: \"0x1f\"\nc: \"1e3\"\nd: \"_1\"\n",
96
- );
97
- }
98
-
99
- #[test]
100
- fn bools_and_nulls() {
101
- roundtrip_eq(
102
- "a: true\nb: False\nc: NULL\nd: ~\n",
103
- "a: true\nb: false\nc: null\nd: null\n",
104
- );
105
- }
106
-
107
- #[test]
108
- fn empty_collections() {
109
- roundtrip_eq("a: {}\nb: []\n", "a: {}\nb: []\n");
110
- }
111
-
112
- #[test]
113
- fn quoted_strings_unwrap() {
114
- roundtrip_eq("a: \"hello\"\nb: 'world'\n", "a: \"hello\"\nb: \"world\"\n");
115
- }
116
-
117
- #[test]
118
- fn nested_map() {
119
- roundtrip_eq("a:\n b:\n c: 1\n", "a:\n b:\n c: 1\n");
120
- }
121
-
122
- #[test]
123
- fn list_of_maps() {
124
- roundtrip_eq(
125
- "items:\n - id: x\n val: 1\n - id: y\n val: 2\n",
126
- "items:\n - id: \"x\"\n val: 1\n - id: \"y\"\n val: 2\n",
127
- );
128
- }
129
-
130
- #[test]
131
- fn scalar_list_mixed() {
132
- roundtrip_eq(
133
- "a:\n - 1\n - two\n - true\n",
134
- "a:\n - 1\n - \"two\"\n - true\n",
135
- );
136
- }
137
-
138
- #[test]
139
- fn empty_value_is_null() {
140
- roundtrip_eq("a:\nb: 2\n", "a: null\nb: 2\n");
141
- }
142
-
143
- #[test]
144
- fn inline_comment_not_stripped() {
145
- // 整行注释被跳过;行内 `#` 不被当注释,留在值里。
146
- roundtrip_eq(
147
- "# top\na: 1 # inline?\nb: 2\n",
148
- "a: \"1 # inline?\"\nb: 2\n",
149
- );
150
- }
151
-
152
- #[test]
153
- fn key_no_space_after_colon() {
154
- roundtrip_eq("a:1\n", "a: 1\n");
155
- }
156
-
157
- #[test]
158
- fn special_chars_json_escaped() {
159
- roundtrip_eq("a: he said \"hi\"\n", "a: \"he said \\\"hi\\\"\"\n");
160
- }
161
-
162
- #[test]
163
- fn unicode_preserved() {
164
- roundtrip_eq("name: café ☕\n", "name: \"café ☕\"\n");
165
- }
166
-
167
- #[test]
168
- fn list_item_value_has_colon_and_space() {
169
- // foo: bar baz —— item_text 看起来是 key-value(key=foo 合法),value = "bar baz"。
170
- roundtrip_eq("a:\n - foo: bar baz\n", "a:\n - foo: \"bar baz\"\n");
171
- }
172
-
173
- // === JSON 顶层路径(`{`/`[` 开头 → json.loads,保留键序)===
174
-
175
- #[test]
176
- fn json_object_path_preserves_key_order() {
177
- roundtrip_eq("{\"a\": 1, \"b\": [1,2]}", "a: 1\nb:\n - 1\n - 2\n");
178
- }
179
-
180
- #[test]
181
- fn json_array_path() {
182
- roundtrip_eq("[1, 2, 3]", "- 1\n- 2\n- 3\n");
183
- }
184
-
185
- #[test]
186
- fn json_leading_whitespace() {
187
- roundtrip_eq(" {\"a\": 1}", "a: 1\n");
188
- }
189
-
190
- #[test]
191
- fn json_float_becomes_quoted_string() {
192
- // json.loads -> float 1.5; dumps -> json.dumps(str(1.5)) = "1.5"。
193
- roundtrip_eq("{\"a\": 1.5}", "a: \"1.5\"\n");
194
- }
195
-
196
- #[test]
197
- fn json_string_with_newline_becomes_block_scalar() {
198
- roundtrip_eq("{\"x\": \"with\\nnewline\"}", "x: |\n with\n newline\n");
199
- }
200
-
201
- #[test]
202
- fn json_empty_object_and_array_top_level() {
203
- // 顶层 {} / [] → _dump 返回空 → "\n"。
204
- roundtrip_eq("{}", "\n");
205
- roundtrip_eq("[]", "\n");
46
+ // === SKILL front matter、方言怪癖与 JSON 顶层路径:逐字节锁定 Python golden ===
47
+ #[test]
48
+ fn python_golden_roundtrip_cases() {
49
+ // (旧函数 id, input, golden, Python 语义说明)。26 个旧测试包含 27 次往返。
50
+ let cases = [
51
+ (
52
+ "skill_front_matter_roundtrip",
53
+ "name: team-agent\ndescription: Use only when the user explicitly asks to start, operate, inspect, shutdown, or restart a Team Agent team. Treat the team-agent CLI as a sealed appliance.\n",
54
+ "name: \"team-agent\"\ndescription: \"Use only when the user explicitly asks to start, operate, inspect, shutdown, or restart a Team Agent team. Treat the team-agent CLI as a sealed appliance.\"\n",
55
+ "取自 skills/team-agent/SKILL.md 的 front matter。SKILL.md front-matter 块(`---` 之间那段)。",
56
+ ),
57
+ ("block_scalar_basic", "msg: |\n line one\n line two\n", "msg: |\n line one\n line two\n", ""),
58
+ ("block_scalar_blank_line_becomes_padded", "msg: |\n a\n\n b\n", "msg: |\n a\n \n b\n", "空行在 dumps 时产生仅含 pad 的行(尾随空格)—— Python 行为,必须复刻。"),
59
+ ("inline_unquoted_list_stays_string", "tools: [a, b, c]\n", "tools: \"[a, b, c]\"\n", "ast.literal_eval('[a, b, c]') 失败 → 原样字符串。"),
60
+ ("inline_quoted_list_becomes_list", "x: ['a', 'b']\n", "x:\n - \"a\"\n - \"b\"\n", ""),
61
+ ("ints_canonicalize", "a: 1\nb: -5\nc: 007\n", "a: 1\nb: -5\nc: 7\n", "007 -> 7,-5 保号。"),
62
+ ("int_plus_and_underscore", "a: +3\nb: 1_000\n", "a: 3\nb: 1000\n", ""),
63
+ ("non_ints_stay_strings", "a: 1.5\nb: 0x1f\nc: 1e3\nd: _1\n", "a: \"1.5\"\nb: \"0x1f\"\nc: \"1e3\"\nd: \"_1\"\n", ""),
64
+ ("bools_and_nulls", "a: true\nb: False\nc: NULL\nd: ~\n", "a: true\nb: false\nc: null\nd: null\n", ""),
65
+ ("empty_collections", "a: {}\nb: []\n", "a: {}\nb: []\n", ""),
66
+ ("quoted_strings_unwrap", "a: \"hello\"\nb: 'world'\n", "a: \"hello\"\nb: \"world\"\n", ""),
67
+ ("nested_map", "a:\n b:\n c: 1\n", "a:\n b:\n c: 1\n", ""),
68
+ ("list_of_maps", "items:\n - id: x\n val: 1\n - id: y\n val: 2\n", "items:\n - id: \"x\"\n val: 1\n - id: \"y\"\n val: 2\n", ""),
69
+ ("scalar_list_mixed", "a:\n - 1\n - two\n - true\n", "a:\n - 1\n - \"two\"\n - true\n", ""),
70
+ ("empty_value_is_null", "a:\nb: 2\n", "a: null\nb: 2\n", ""),
71
+ ("inline_comment_not_stripped", "# top\na: 1 # inline?\nb: 2\n", "a: \"1 # inline?\"\nb: 2\n", "整行注释被跳过;行内 `#` 不被当注释,留在值里。"),
72
+ ("key_no_space_after_colon", "a:1\n", "a: 1\n", ""),
73
+ ("special_chars_json_escaped", "a: he said \"hi\"\n", "a: \"he said \\\"hi\\\"\"\n", ""),
74
+ ("unicode_preserved", "name: café ☕\n", "name: \"café ☕\"\n", ""),
75
+ ("list_item_value_has_colon_and_space", "a:\n - foo: bar baz\n", "a:\n - foo: \"bar baz\"\n", "foo: bar baz —— item_text 看起来是 key-value(key=foo 合法),value = \"bar baz\"。"),
76
+ ("json_object_path_preserves_key_order", "{\"a\": 1, \"b\": [1,2]}", "a: 1\nb:\n - 1\n - 2\n", "JSON 顶层路径保留键序。"),
77
+ ("json_array_path", "[1, 2, 3]", "- 1\n- 2\n- 3\n", ""),
78
+ ("json_leading_whitespace", " {\"a\": 1}", "a: 1\n", ""),
79
+ ("json_float_becomes_quoted_string", "{\"a\": 1.5}", "a: \"1.5\"\n", "json.loads -> float 1.5; dumps -> json.dumps(str(1.5)) = \"1.5\"。"),
80
+ ("json_string_with_newline_becomes_block_scalar", "{\"x\": \"with\\nnewline\"}", "x: |\n with\n newline\n", ""),
81
+ ("json_empty_object_and_array_top_level", "{}", "\n", "顶层 {} / [] → _dump 返回空 → \"\\n\"。"),
82
+ ("json_empty_object_and_array_top_level", "[]", "\n", "顶层 {} / [] → _dump 返回空 → \"\\n\"。"),
83
+ ];
84
+ for (id, input, golden, note) in cases {
85
+ roundtrip_eq(id, input, golden, note);
86
+ }
206
87
  }
207
88
 
208
89
  // === dumps-only 结构(直接构造 Value,golden 来自 Python dumps)===
@@ -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)?;
@@ -647,4 +779,35 @@ mod tests {
647
779
  assert!(!model_matches(&row, "LUNA absent"));
648
780
  assert!(model_matches(&row, " "));
649
781
  }
782
+
783
+ #[test]
784
+ fn frozen_cursor_catalog_skips_only_its_native_trailing_tip() {
785
+ let frozen = include_bytes!("testdata/cursor-list-models-native.stdout");
786
+ let records = parse_cursor_catalog(frozen).unwrap();
787
+ let low = records.iter().find(|row| row.id == "gpt-5.6-luna-low").unwrap();
788
+ assert_eq!(low.provider.as_str(), "cursor_agent");
789
+ assert_eq!(low.vendor.as_str(), "gpt");
790
+ assert_eq!(low.display_name, "GPT-5.6 Luna 1M Low (current)");
791
+ assert_eq!(low.default, Some(false));
792
+ assert!(low.aliases.is_empty());
793
+ let xhigh = records.iter().find(|row| row.id == "gpt-5.6-luna-xhigh").unwrap();
794
+ assert_eq!(xhigh.id.as_str(), "gpt-5.6-luna-xhigh");
795
+ assert_eq!(xhigh.display_name, "GPT-5.6 Luna 1M Extra High");
796
+ let auto = records.iter().find(|row| row.id == "auto").unwrap();
797
+ assert_eq!(auto.default, Some(true));
798
+
799
+ let text = std::str::from_utf8(frozen).unwrap();
800
+ let without_tip = text.split_once("\nTip: use --model ").unwrap().0;
801
+ assert_eq!(parse_cursor_catalog(without_tip.as_bytes()).unwrap(), parse_cursor_catalog(frozen).unwrap());
802
+ }
803
+
804
+ #[test]
805
+ fn cursor_tip_compatibility_keeps_fail_closed_row_validation() {
806
+ let duplicate = b"Available models\na - A\na - A\n";
807
+ assert!(matches!(parse_cursor_catalog(duplicate), Err(CatalogError::DuplicateIdentity(_))));
808
+ assert!(matches!(parse_cursor_catalog(b"Available models\n"), Err(CatalogError::Empty)));
809
+ assert!(matches!(parse_cursor_catalog(&[b'A', 0xff]), Err(CatalogError::InvalidUtf8)));
810
+ assert!(matches!(parse_cursor_catalog(b"Available models\na - A\nnot a model row\n"), Err(CatalogError::Malformed)));
811
+ assert!(matches!(parse_cursor_catalog(b"Available models\na - A\nTip: use --model a to switch.\nunexpected tail\n"), Err(CatalogError::Malformed)));
812
+ }
650
813
  }