@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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +1 -1
- package/crates/team-agent/src/cli/emit.rs +3 -3
- package/crates/team-agent/src/cli/mod.rs +87 -4
- package/crates/team-agent/src/cli/models.rs +252 -3
- package/crates/team-agent/src/cli/spec.rs +1 -1
- package/crates/team-agent/src/cli/status_port/brief.rs +155 -37
- package/crates/team-agent/src/cli/tests/base.rs +112 -225
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +184 -0
- package/crates/team-agent/src/cli/tests/status_brief.rs +624 -0
- package/crates/team-agent/src/compiler/tests.rs +46 -59
- package/crates/team-agent/src/leader/tests/byte_findings.rs +1 -14
- package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +57 -173
- package/crates/team-agent/src/lifecycle/launch/pi_mcp_extension.js +226 -0
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +0 -37
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +0 -9
- package/crates/team-agent/src/lifecycle/tests/fixtures/pi_native_mcp_host.mjs +71 -0
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +0 -66
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +0 -392
- package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +0 -5
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +2 -361
- package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +1 -16
- package/crates/team-agent/src/lifecycle/tests/pi_executable_mcp_red.rs +20 -112
- package/crates/team-agent/src/lifecycle/tests/pi_fork_session_inheritance_red.rs +1 -12
- package/crates/team-agent/src/lifecycle/tests/pi_native_mcp_capability_red.rs +546 -0
- package/crates/team-agent/src/lifecycle/tests.rs +1 -0
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +11 -54
- package/crates/team-agent/src/messaging/tests/wave2.rs +2 -302
- package/crates/team-agent/src/model/name_similarity.rs +52 -67
- package/crates/team-agent/src/model/task_graph.rs +37 -124
- package/crates/team-agent/src/model/yaml/tests.rs +46 -165
- package/crates/team-agent/src/provider/model_catalog.rs +166 -3
- package/crates/team-agent/src/provider/testdata/cursor-list-models-native.stdout +250 -0
- package/crates/team-agent/src/provider/testdata/grok-models-authenticated.stdout +9 -0
- package/crates/team-agent/src/provider/testdata/grok-models-unauthenticated.stdout +7 -0
- package/crates/team-agent/src/provider/tests/adapter.rs +0 -20
- package/crates/team-agent/src/provider/tests/classify.rs +106 -198
- package/crates/team-agent/src/tmux_backend/tests.rs +178 -501
- package/crates/team-agent/src/transport/tests/wire.rs +0 -55
- 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
|
|
10
|
-
|
|
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
|
|
45
|
-
#[test]
|
|
46
|
-
fn
|
|
47
|
-
//
|
|
48
|
-
let
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
#
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
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::
|
|
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
|
|
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
|
}
|