@team-agent/installer 0.5.109 → 0.5.111

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 (34) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/README.md +33 -0
  4. package/README.zh.md +30 -0
  5. package/crates/team-agent/src/cli/adapters.rs +234 -73
  6. package/crates/team-agent/src/cli/emit.rs +494 -201
  7. package/crates/team-agent/src/cli/leader.rs +4 -9
  8. package/crates/team-agent/src/cli/leaders.rs +90 -29
  9. package/crates/team-agent/src/cli/mod.rs +348 -161
  10. package/crates/team-agent/src/cli/models.rs +80 -24
  11. package/crates/team-agent/src/cli/named_address.rs +114 -25
  12. package/crates/team-agent/src/cli/route.rs +212 -0
  13. package/crates/team-agent/src/cli/send/presentation.rs +60 -41
  14. package/crates/team-agent/src/cli/send/resolve.rs +25 -18
  15. package/crates/team-agent/src/cli/send.rs +4 -4
  16. package/crates/team-agent/src/cli/spec.rs +56 -106
  17. package/crates/team-agent/src/cli/tests/base.rs +189 -82
  18. package/crates/team-agent/src/cli/tests/lane_c.rs +1 -2
  19. package/crates/team-agent/src/cli/tests/named_address.rs +19 -22
  20. package/crates/team-agent/src/cli/tests/status_send.rs +15 -10
  21. package/crates/team-agent/src/cli/tests/verb_install_skill.rs +2 -2
  22. package/crates/team-agent/src/cli/types.rs +22 -13
  23. package/crates/team-agent/src/leader/registry.rs +3 -3
  24. package/crates/team-agent/src/leader/start.rs +5 -1
  25. package/crates/team-agent/src/lifecycle/launch/spawn.rs +2 -0
  26. package/crates/team-agent/src/lifecycle/restart/common.rs +2 -0
  27. package/crates/team-agent/src/lifecycle/tests/cli_argv_routing_red.rs +1457 -0
  28. package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +4 -9
  29. package/crates/team-agent/src/lifecycle/tests/stale_team_saveconflict_contract.rs +3 -1
  30. package/crates/team-agent/src/lifecycle/tests.rs +1 -0
  31. package/crates/team-agent/src/provider/argv_route.rs +409 -0
  32. package/crates/team-agent/src/provider/mod.rs +3 -2
  33. package/package.json +4 -4
  34. package/skills/team-agent/SKILL.md +32 -0
@@ -298,7 +298,8 @@ mod tests {
298
298
  #[cfg(unix)]
299
299
  #[test]
300
300
  fn search_survives_unrelated_spaced_model_ids() {
301
- let catalog = include_str!("../lifecycle/tests/fixtures/pi_list_models_spaced_ids.stdout.txt");
301
+ let catalog =
302
+ include_str!("../lifecycle/tests/fixtures/pi_list_models_spaced_ids.stdout.txt");
302
303
  let path = fixture(&format!("printf '%s' '{catalog}'"));
303
304
  let result = cmd_models_with(
304
305
  &ModelsArgs {
@@ -370,7 +371,8 @@ mod tests {
370
371
  &["models", "--provider", "cursor_agent", "--json"],
371
372
  );
372
373
  let text = public_cli_text(&output);
373
- let calls = std::fs::read_to_string(agent.program.with_extension("count")).unwrap_or_default();
374
+ let calls =
375
+ std::fs::read_to_string(agent.program.with_extension("count")).unwrap_or_default();
374
376
  drop(agent);
375
377
  assert!(output.status.success(), "{text}");
376
378
  assert_eq!(calls, "1:--list-models\n");
@@ -405,12 +407,19 @@ mod tests {
405
407
  let no_match = run_public_models_cli(
406
408
  &grok.program,
407
409
  grok.response.as_deref(),
408
- &["models", "--provider", "grok", "--search", "definitely-absent"],
410
+ &[
411
+ "models",
412
+ "--provider",
413
+ "grok",
414
+ "--search",
415
+ "definitely-absent",
416
+ ],
409
417
  );
410
418
  let json_text = public_cli_text(&json);
411
419
  let human_text = public_cli_text(&human);
412
420
  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();
421
+ let calls =
422
+ std::fs::read_to_string(grok.program.with_extension("count")).unwrap_or_default();
414
423
  drop(grok);
415
424
  assert!(json.status.success(), "{json_text}");
416
425
  assert!(human.status.success(), "{human_text}");
@@ -422,10 +431,15 @@ mod tests {
422
431
  assert_eq!(value["auth_basis"], "catalog_visibility");
423
432
  let models = value["models"].as_array().unwrap();
424
433
  assert_eq!(
425
- models.iter().map(|model| model["model_id"].as_str().unwrap()).collect::<Vec<_>>(),
434
+ models
435
+ .iter()
436
+ .map(|model| model["model_id"].as_str().unwrap())
437
+ .collect::<Vec<_>>(),
426
438
  vec!["grok-4.7", "grok-4.7-build-fast", "grok-4.5", "grok-4.6"]
427
439
  );
428
- assert!(models.iter().all(|model| model["provider"] == "grok" && model["vendor"] == "xai" && model["aliases"] == json!([])));
440
+ assert!(models.iter().all(|model| model["provider"] == "grok"
441
+ && model["vendor"] == "xai"
442
+ && model["aliases"] == json!([])));
429
443
  assert_eq!(models[0]["default"], false);
430
444
  assert_eq!(models[3]["default"], true);
431
445
  assert!(human_text.contains("grok-4.7-build-fast"));
@@ -471,7 +485,8 @@ mod tests {
471
485
  &["models", "--provider", "grok", "--json"],
472
486
  );
473
487
  let text = public_cli_text(&output);
474
- let calls = std::fs::read_to_string(grok.program.with_extension("count")).unwrap_or_default();
488
+ let calls =
489
+ std::fs::read_to_string(grok.program.with_extension("count")).unwrap_or_default();
475
490
  outcomes.push((name, output.status.success(), text, calls));
476
491
  }
477
492
  for (name, success, text, calls) in outcomes {
@@ -481,21 +496,32 @@ mod tests {
481
496
  assert_eq!(value["schema_version"], "models.v1", "{name}");
482
497
  assert_eq!(value["ok"], false, "{name}");
483
498
  assert_eq!(value["models"], json!([]), "{name}");
484
- assert!(!value["error"].as_str().unwrap().to_ascii_lowercase().contains("unsupported model provider"), "{name}: {text}");
499
+ assert!(
500
+ !value["error"]
501
+ .as_str()
502
+ .unwrap()
503
+ .to_ascii_lowercase()
504
+ .contains("unsupported model provider"),
505
+ "{name}: {text}"
506
+ );
485
507
  }
486
508
  }
487
509
 
488
510
  #[cfg(unix)]
489
511
  #[test]
490
512
  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"));
513
+ let grok = CliFixture::new(fixture_program(
514
+ "grok",
515
+ "printf 'private diagnostic' >&2\nexit 7",
516
+ ));
492
517
  let output = run_public_models_cli(
493
518
  &grok.program,
494
519
  None,
495
520
  &["models", "--provider", "grok", "--json"],
496
521
  );
497
522
  let text = public_cli_text(&output);
498
- let calls = std::fs::read_to_string(grok.program.with_extension("count")).unwrap_or_default();
523
+ let calls =
524
+ std::fs::read_to_string(grok.program.with_extension("count")).unwrap_or_default();
499
525
  drop(grok);
500
526
  assert!(!output.status.success(), "{text}");
501
527
  assert_eq!(calls, "1:models\n");
@@ -503,7 +529,14 @@ mod tests {
503
529
  let value: Value = serde_json::from_str(text.trim()).unwrap();
504
530
  assert_eq!(value["ok"], false);
505
531
  assert_eq!(value["models"], json!([]));
506
- assert!(value["error"].as_str().unwrap().to_ascii_lowercase().contains("grok"), "{text}");
532
+ assert!(
533
+ value["error"]
534
+ .as_str()
535
+ .unwrap()
536
+ .to_ascii_lowercase()
537
+ .contains("grok"),
538
+ "{text}"
539
+ );
507
540
  }
508
541
 
509
542
  #[cfg(unix)]
@@ -518,10 +551,20 @@ mod tests {
518
551
  let text = public_cli_text(&output);
519
552
  let value: Value = serde_json::from_str(text.trim()).unwrap();
520
553
  assert_eq!(value["ok"], false);
521
- assert!(value["error"].as_str().unwrap().to_ascii_lowercase().contains("grok"), "{text}");
554
+ assert!(
555
+ value["error"]
556
+ .as_str()
557
+ .unwrap()
558
+ .to_ascii_lowercase()
559
+ .contains("grok"),
560
+ "{text}"
561
+ );
522
562
  assert!(value["action"].as_str().unwrap().contains("grok"), "{text}");
523
563
  assert!(crate::cli::emit::default_help().contains("grok"));
524
- assert!(crate::cli::spec::command_spec("models").unwrap().usage.contains("grok"));
564
+ assert!(crate::cli::spec::command_spec("models")
565
+ .unwrap()
566
+ .usage
567
+ .contains("grok"));
525
568
 
526
569
  let unsupported = run_public_models_cli(
527
570
  &missing.program,
@@ -533,7 +576,13 @@ mod tests {
533
576
  drop(missing);
534
577
  assert!(!output.status.success());
535
578
  assert!(!unsupported.status.success());
536
- assert!(unsupported_value["action"].as_str().unwrap().contains("grok"), "{unsupported_text}");
579
+ assert!(
580
+ unsupported_value["action"]
581
+ .as_str()
582
+ .unwrap()
583
+ .contains("grok"),
584
+ "{unsupported_text}"
585
+ );
537
586
  }
538
587
 
539
588
  #[cfg(unix)]
@@ -579,7 +628,10 @@ mod tests {
579
628
  #[cfg(unix)]
580
629
  impl CliFixture {
581
630
  fn new(program: std::path::PathBuf) -> Self {
582
- Self { program, response: None }
631
+ Self {
632
+ program,
633
+ response: None,
634
+ }
583
635
  }
584
636
  }
585
637
 
@@ -614,7 +666,10 @@ mod tests {
614
666
  "--nocapture",
615
667
  ])
616
668
  .env("TEAM_AGENT_MODELS_CHILD", "1")
617
- .env("TEAM_AGENT_MODELS_ARGS", serde_json::to_string(args).unwrap())
669
+ .env(
670
+ "TEAM_AGENT_MODELS_ARGS",
671
+ serde_json::to_string(args).unwrap(),
672
+ )
618
673
  .env("PATH", path.parent().unwrap())
619
674
  .env_remove("PI_PROVIDER")
620
675
  .env_remove("PI_MODEL")
@@ -719,7 +774,10 @@ mod tests {
719
774
  fn with_native_mode<T>(
720
775
  mode: &str,
721
776
  action: impl FnOnce() -> T,
722
- ) -> (T, crate::lifecycle::launch::pi_mcp::PiCatalogTestObservation) {
777
+ ) -> (
778
+ T,
779
+ crate::lifecycle::launch::pi_mcp::PiCatalogTestObservation,
780
+ ) {
723
781
  let receipt = native_timeout_receipt();
724
782
  assert!(!receipt.exists());
725
783
  let result = crate::lifecycle::launch::pi_mcp::with_pi_catalog_test_observation(
@@ -1122,9 +1180,8 @@ mod tests {
1122
1180
  fn runner_fails_closed_for_nonzero_oversize_timeout_and_unavailable() {
1123
1181
  let fail = native_timeout_fixture();
1124
1182
  let started = Instant::now();
1125
- let (failed_result, fail_observation) = with_native_mode("exit7", || {
1126
- run_catalog(&fail, Duration::from_secs(1), 1024)
1127
- });
1183
+ let (failed_result, fail_observation) =
1184
+ with_native_mode("exit7", || run_catalog(&fail, Duration::from_secs(1), 1024));
1128
1185
  let elapsed = started.elapsed();
1129
1186
  assert_eq!(
1130
1187
  failed_result.unwrap_err(),
@@ -1161,9 +1218,8 @@ mod tests {
1161
1218
  assert_eq!(failed.exit, ExitCode::Error);
1162
1219
  assert!(!text.contains("sensitive-token"));
1163
1220
  let big = native_timeout_fixture();
1164
- let (big_result, big_observation) = with_native_mode("oversize", || {
1165
- run_catalog(&big, Duration::from_secs(1), 8)
1166
- });
1221
+ let (big_result, big_observation) =
1222
+ with_native_mode("oversize", || run_catalog(&big, Duration::from_secs(1), 8));
1167
1223
  assert!(big_result.unwrap_err().contains("bounded output"));
1168
1224
  assert_eq!(big_observation.spawn_count, 1);
1169
1225
  assert_eq!(big_observation.argv, vec!["--list-models"]);
@@ -1217,7 +1273,7 @@ mod tests {
1217
1273
  );
1218
1274
  assert_eq!(
1219
1275
  crate::cli::spec::command_spec("models").unwrap().usage,
1220
- "usage: team-agent models [--provider pi|cursor_agent|codex|claude|claude_code|grok] [QUERY|--search TEXT] [--json]"
1276
+ "team-agent models [--provider pi|cursor_agent|codex|claude|claude_code|grok] [QUERY|--search TEXT] [--json]"
1221
1277
  );
1222
1278
  }
1223
1279
 
@@ -71,6 +71,25 @@ pub(crate) struct NamedAddressError {
71
71
  /// `requested_name`/`suggested_name` without re-parsing. Absent
72
72
  /// for exact refusals like empty-workspace.
73
73
  pub requested_name: Option<String>,
74
+ // Presentation only: never serialized or used as routing authority.
75
+ human_scope: Option<PathBuf>,
76
+ human_team: Option<String>,
77
+ }
78
+
79
+ pub(crate) fn human_address_reason(reason: &str) -> Option<&'static str> {
80
+ Some(match reason {
81
+ "name_invalid" => "收信队友名不正确;请使用队友列表中的名称。",
82
+ "workspace_not_found" => "所选项目目录不存在;请核对项目路径。",
83
+ "state_not_found" | "workspace_no_state" => {
84
+ "所选项目还没有可用队伍;首次起队用 quick-start,已有队伍恢复用 restart。"
85
+ }
86
+ "team_key_not_found" => "所选项目没有这支队伍;请核对 --workspace 和 --team。",
87
+ "leader_not_attached" => "主控还没有连接到这支队伍;请先体检,按提示恢复连接。",
88
+ "name_not_resolvable" => "当前队伍没有这个队友;请先查看队友列表。",
89
+ "name_not_live" => "该队友当前未运行或连接已失效;请查看状态再恢复。",
90
+ "name_ambiguous" => "有多个同名对象;请明确选择项目和队伍,不猜对象。",
91
+ _ => return None,
92
+ })
74
93
  }
75
94
 
76
95
  impl NamedAddressError {
@@ -86,7 +105,77 @@ impl NamedAddressError {
86
105
  available_team_keys: Vec::new(),
87
106
  suggested_name: None,
88
107
  requested_name: None,
108
+ human_scope: None,
109
+ human_team: None,
110
+ }
111
+ }
112
+
113
+ fn with_human_scope(mut self, workspace: Option<&Path>, team: Option<&str>) -> Self {
114
+ self.human_scope = workspace.map(Path::to_path_buf);
115
+ if self.human_team.is_none() {
116
+ self.human_team = team.map(str::to_string);
89
117
  }
118
+ self
119
+ }
120
+
121
+ pub(crate) fn human_guidance(&self, requested: &str) -> (String, String) {
122
+ let workspace = self.human_scope.as_deref();
123
+ let mut message = format!(
124
+ "{}\n你刚才填写的是 {requested:?}。",
125
+ human_address_reason(self.kind.as_str()).unwrap_or("未能找到可用的收信队友。")
126
+ );
127
+ if let Some(workspace) = workspace {
128
+ message.push_str(&format!("\n所选项目:{:?}。", workspace.to_string_lossy()));
129
+ }
130
+ if let Some(team) = self.human_team.as_deref() {
131
+ message.push_str(&format!("\n所选队伍:{team:?}。"));
132
+ }
133
+ let mut action = "没有可靠的拼写建议;请先核对项目和队伍,不猜对象。".to_string();
134
+ for candidate in &self.candidates {
135
+ if let (Some(agent), Some(team)) = (
136
+ candidate.get("agent_id").and_then(Value::as_str),
137
+ candidate.get("team_key").and_then(Value::as_str),
138
+ ) {
139
+ message.push_str(&format!(
140
+ "\n候选队伍 {team:?},队友 {agent:?};请确认是否是你要找的对象。"
141
+ ));
142
+ }
143
+ }
144
+ if let Some(candidate) = self.suggested_name.as_deref().and_then(|suggested| {
145
+ self.candidates
146
+ .iter()
147
+ .find(|candidate| candidate.get("name").and_then(Value::as_str) == Some(suggested))
148
+ }) {
149
+ if let (Some(workspace), Some(agent), Some(team)) = (
150
+ workspace,
151
+ candidate.get("agent_id").and_then(Value::as_str),
152
+ candidate.get("team_key").and_then(Value::as_str),
153
+ ) {
154
+ if !agent.is_empty()
155
+ && !team.is_empty()
156
+ && !agent
157
+ .chars()
158
+ .chain(team.chars())
159
+ .chain(workspace.to_string_lossy().chars())
160
+ .any(char::is_control)
161
+ {
162
+ if let Some(command) =
163
+ super::adapters::send_command(agent, workspace, Some(team))
164
+ {
165
+ action = format!("你是否想发给 {agent:?}、队伍 {team:?}?确认后将任务内容替换到这个例子;本次没有发送:\n{command}");
166
+ }
167
+ }
168
+ }
169
+ }
170
+ if let Some(workspace) =
171
+ workspace.filter(|path| !path.to_string_lossy().chars().any(char::is_control))
172
+ {
173
+ action.push_str(&format!(
174
+ "\n也可查看可用队友列表:\nteam-agent status --workspace {}",
175
+ super::adapters::shell_quote(&workspace.to_string_lossy())
176
+ ));
177
+ }
178
+ (message, action)
90
179
  }
91
180
 
92
181
  pub(crate) fn n38_message(&self) -> String {
@@ -188,9 +277,7 @@ impl NamedAddressError {
188
277
  })
189
278
  })
190
279
  .collect();
191
- let best = ranked
192
- .first()
193
- .map(|agent_id| format!("{team}/{agent_id}"));
280
+ let best = ranked.first().map(|agent_id| format!("{team}/{agent_id}"));
194
281
  self.with_scoped_suggestions(requested_display, candidate_values, best)
195
282
  }
196
283
 
@@ -216,9 +303,7 @@ impl NamedAddressError {
216
303
  // returned candidate (RED-4). Falls back to a status-JSON
217
304
  // hint when no candidate cleared the threshold.
218
305
  if let Some(best) = best_copyable {
219
- self.action = format!(
220
- "Did you mean `{best}`? Rerun with `--to-name {best}`."
221
- );
306
+ self.action = format!("Did you mean `{best}`? Rerun with `--to-name {best}`.");
222
307
  } else {
223
308
  self.action = format!(
224
309
  "No close matches for `{requested}`. \
@@ -430,8 +515,14 @@ pub(crate) fn resolve_name_for_cli(
430
515
  // registry-to-E6 internal helper deliberately pass `None` so
431
516
  // caller-supplied `--team` never overrides a full address.
432
517
  let parsed = normalize_bare_with_team_scope(parsed, bare_team_scope);
433
- let target_workspace = resolve_workspace(sender_workspace, parsed.workspace.as_deref())?;
434
- let state = load_state_checked(&target_workspace)?;
518
+ let requested_team = match &parsed.target {
519
+ ParsedTarget::TeamEntity { team, .. } => Some(team.as_str()),
520
+ _ => None,
521
+ };
522
+ let target_workspace = resolve_workspace(sender_workspace, parsed.workspace.as_deref())
523
+ .map_err(|error| error.with_human_scope(None, requested_team))?;
524
+ let state = load_state_checked(&target_workspace)
525
+ .map_err(|error| error.with_human_scope(Some(&target_workspace), requested_team))?;
435
526
  let transport = transport_for_cli_target(&target_workspace, &state, &parsed);
436
527
  let resolved = resolve_in_workspace(
437
528
  sender_workspace,
@@ -439,7 +530,8 @@ pub(crate) fn resolve_name_for_cli(
439
530
  &state,
440
531
  &parsed,
441
532
  transport.as_ref(),
442
- )?;
533
+ )
534
+ .map_err(|error| error.with_human_scope(Some(&target_workspace), requested_team))?;
443
535
  Ok((resolved, transport))
444
536
  }
445
537
 
@@ -487,8 +579,8 @@ pub(crate) fn parse_leader_target_workspace_and_team(
487
579
  match parsed.target {
488
580
  ParsedTarget::TeamEntity { team, entity } if entity == "leader" => {
489
581
  let state = load_state_checked(&target_workspace)?;
490
- let canonical = canonical_named_team_key(&target_workspace, &state, &team)
491
- .unwrap_or(team);
582
+ let canonical =
583
+ canonical_named_team_key(&target_workspace, &state, &team).unwrap_or(team);
492
584
  Ok(Some((target_workspace, canonical)))
493
585
  }
494
586
  _ => Ok(None),
@@ -594,7 +686,7 @@ fn resolve_in_workspace(
594
686
  ParsedTarget::TeamEntity { team, entity } => {
595
687
  let canonical = canonical_named_team_key(target_workspace, state, team)
596
688
  .unwrap_or_else(|| team.clone());
597
- if entity == "leader" {
689
+ let result = if entity == "leader" {
598
690
  resolve_leader(
599
691
  sender_workspace,
600
692
  target_workspace,
@@ -613,7 +705,11 @@ fn resolve_in_workspace(
613
705
  parsed,
614
706
  transport,
615
707
  )
616
- }
708
+ };
709
+ result.map_err(|mut error| {
710
+ error.human_team = Some(canonical);
711
+ error
712
+ })
617
713
  }
618
714
  ParsedTarget::SessionWindow { session, window } => resolve_session_window(
619
715
  sender_workspace,
@@ -976,11 +1072,7 @@ fn resolve_leader(
976
1072
  let socket = string_field(receiver, "tmux_socket")
977
1073
  .map(str::to_string)
978
1074
  .or_else(|| transport.tmux_endpoint());
979
- match crate::messaging::resolve_live_leader_channel(
980
- target_workspace,
981
- receiver,
982
- transport,
983
- ) {
1075
+ match crate::messaging::resolve_live_leader_channel(target_workspace, receiver, transport) {
984
1076
  crate::messaging::LeaderChannelResolution::Live(
985
1077
  crate::messaging::LiveLeaderChannel::DirectTmux(channel),
986
1078
  ) => Ok(ResolvedNamedAddress {
@@ -1194,8 +1286,8 @@ fn transport_for_cli_target(
1194
1286
  parsed: &ParsedNamedAddress,
1195
1287
  ) -> Box<dyn Transport> {
1196
1288
  if let ParsedTarget::TeamEntity { team, entity } = &parsed.target {
1197
- let canonical = canonical_named_team_key(target_workspace, state, team)
1198
- .unwrap_or_else(|| team.clone());
1289
+ let canonical =
1290
+ canonical_named_team_key(target_workspace, state, team).unwrap_or_else(|| team.clone());
1199
1291
  if entity == "leader" {
1200
1292
  let team_scoped_socket = team_entry(state, &canonical)
1201
1293
  .and_then(|entry| entry.get("leader_receiver"))
@@ -1302,11 +1394,8 @@ fn canonical_named_team_key(
1302
1394
  if team_entry(state, requested).is_some() || is_legacy_single_team_active(state, requested) {
1303
1395
  return Some(requested.to_string());
1304
1396
  }
1305
- let selected = crate::state::projection::select_runtime_state(
1306
- target_workspace,
1307
- Some(requested),
1308
- )
1309
- .ok()?;
1397
+ let selected =
1398
+ crate::state::projection::select_runtime_state(target_workspace, Some(requested)).ok()?;
1310
1399
  let canonical = selected
1311
1400
  .get("active_team_key")
1312
1401
  .and_then(Value::as_str)
@@ -0,0 +1,212 @@
1
+ //! Host-global route management: raw argv, no workspace selection or error log.
2
+
3
+ use super::{CmdOutput, CmdResult, ExitCode};
4
+ use crate::provider::argv_route::{self, Config, Mutation, Override, RouteError};
5
+ use crate::provider::Provider;
6
+ use serde_json::{json, Value};
7
+
8
+ pub(crate) const HELP: &str = "做什么:\n设置工具启动时额外使用的命令行参数,默认关闭。\n用法:team-agent route [status|enable|disable|show [TOOL]|set TOOL -- ARG...|add TOOL -- ARG...|clear TOOL] [--json]\n\n怎么用:\nstatus 看开关;enable 开启;disable 关闭;show 看全部或指定工具的参数。\nset 替换参数;add 追加参数;clear 清除指定工具的参数;--json 给程序读取。\nTOOL 是已支持的工具名称,如 pi/codex/claude/copilot/grok/cursor_agent。\n第一个 -- 后全是工具的字面参数,包含 --help/--json 也不会被这里解析。\n配置保存在 ~/.team-agent/argv-routing.json;影响全机,不按项目或队伍隔离。\nTEAM_AGENT_CLI_ARGV_ROUTING 环境变量优先于保存的开关。\nExamples(可复制):\nteam-agent route status\nteam-agent route set pi -- --mode text\nteam-agent route enable\n\n下一步(Next Action):\n用 route show pi 核对参数;仅影响下一次启动,不改变正在运行的对话。\n没有开启或没有配置时,工具仍按原参数启动;不了解参数用途时保持关闭。";
9
+
10
+ struct Request {
11
+ operation: String,
12
+ provider: Option<Provider>,
13
+ tokens: Vec<String>,
14
+ json: bool,
15
+ }
16
+
17
+ fn usage() -> RouteError {
18
+ RouteError {
19
+ error: "参数不完整或不正确;set/add 需要工具名、-- 和至少一个工具参数".to_string(),
20
+ reason: "argv_route_usage",
21
+ action: "请按 team-agent route --help 的 Examples 填写;例如 team-agent route set pi -- --mode text".to_string(),
22
+ config_path: None,
23
+ }
24
+ }
25
+
26
+ fn parse(args: &[String], delimiter: usize) -> Result<Request, RouteError> {
27
+ let mut json = false;
28
+ let mut positionals = Vec::new();
29
+ for token in &args[..delimiter] {
30
+ if token == "--json" {
31
+ if json {
32
+ return Err(usage());
33
+ }
34
+ json = true;
35
+ } else if token.starts_with('-') {
36
+ return Err(usage());
37
+ } else {
38
+ positionals.push(token.as_str());
39
+ }
40
+ }
41
+ let operation = positionals.first().copied().unwrap_or("status");
42
+ let provider_required = matches!(operation, "set" | "add" | "clear");
43
+ let provider_allowed = provider_required || operation == "show";
44
+ if !matches!(
45
+ operation,
46
+ "status" | "enable" | "disable" | "show" | "set" | "add" | "clear"
47
+ ) || positionals.len() > if provider_allowed { 2 } else { 1 }
48
+ || (provider_required && positionals.len() != 2)
49
+ {
50
+ return Err(usage());
51
+ }
52
+ let provider = positionals
53
+ .get(1)
54
+ .map(|raw| argv_route::parse_route_provider(raw).ok_or_else(usage))
55
+ .transpose()?;
56
+ let tokens = if matches!(operation, "set" | "add") {
57
+ if delimiter == args.len() || delimiter + 1 == args.len() {
58
+ return Err(usage());
59
+ }
60
+ let tokens = args[delimiter + 1..].to_vec();
61
+ if !argv_route::valid_tokens(&tokens) {
62
+ return Err(usage());
63
+ }
64
+ tokens
65
+ } else {
66
+ if delimiter != args.len() {
67
+ return Err(usage());
68
+ }
69
+ Vec::new()
70
+ };
71
+ Ok(Request {
72
+ operation: operation.to_string(),
73
+ provider,
74
+ tokens,
75
+ json,
76
+ })
77
+ }
78
+
79
+ fn switch_fields(
80
+ path: Option<&std::path::Path>,
81
+ persisted: Option<bool>,
82
+ present: bool,
83
+ override_state: Override,
84
+ ) -> Value {
85
+ json!({
86
+ "config_path": path,
87
+ "persisted_enabled": persisted,
88
+ "effective_enabled": override_state.effective(persisted.unwrap_or(false)),
89
+ "enabled_source": override_state.source(present),
90
+ "override_status": override_state.status(),
91
+ })
92
+ }
93
+
94
+ fn execute(request: &Request) -> Result<Value, RouteError> {
95
+ let path = argv_route::config_path()?;
96
+ let (config, present) = match request.operation.as_str() {
97
+ "enable" => argv_route::mutate(&path, Mutation::Enable(true))?,
98
+ "disable" => argv_route::mutate(&path, Mutation::Enable(false))?,
99
+ "set" | "add" | "clear" => {
100
+ let provider = request.provider.ok_or_else(usage)?;
101
+ let mutation = match request.operation.as_str() {
102
+ "set" => Mutation::Set(provider, request.tokens.clone()),
103
+ "add" => Mutation::Add(provider, request.tokens.clone()),
104
+ _ => Mutation::Clear(provider),
105
+ };
106
+ argv_route::mutate(&path, mutation)?
107
+ }
108
+ _ => argv_route::read_config(&path)?,
109
+ };
110
+ let override_state = Override::current();
111
+ let mut value = switch_fields(Some(&path), Some(config.enabled), present, override_state);
112
+ value["ok"] = json!(true);
113
+ value["operation"] = json!(request.operation);
114
+ if let Some(provider) = request.provider {
115
+ add_provider_fields(&mut value, &config, provider);
116
+ } else if request.operation == "show" {
117
+ value["providers"] = json!(config.providers);
118
+ }
119
+ if override_state != Override::Unset {
120
+ value["notice"] = json!(format!(
121
+ "当前开关由环境变量 {} 决定({})",
122
+ argv_route::ENV_NAME,
123
+ override_state.status()
124
+ ));
125
+ }
126
+ Ok(value)
127
+ }
128
+
129
+ fn add_provider_fields(value: &mut Value, config: &Config, provider: Provider) {
130
+ if let Some(key) = argv_route::route_key(provider) {
131
+ value["provider"] = json!(key);
132
+ value["configured"] = json!(config.providers.contains_key(key));
133
+ value["argv"] = json!(config.providers.get(key).cloned().unwrap_or_default());
134
+ }
135
+ }
136
+
137
+ /// Route-only error renderer: reuse redaction/formatting, never cwd log policy.
138
+ fn emit_error(error: RouteError, args: &[String], delimiter: usize, as_json: bool) -> ExitCode {
139
+ let is_usage = error.reason == "argv_route_usage";
140
+ let mut value = json!({
141
+ "ok": false, "error": error.error, "reason": error.reason,
142
+ "action": error.action, "config_path": error.config_path,
143
+ });
144
+ if !is_usage {
145
+ // Best-effort switch observation even when strict management read fails.
146
+ // No config content or invalid argv is included in the error channel.
147
+ let path = error.config_path.as_deref();
148
+ let loose = path
149
+ .and_then(|path| std::fs::read(path).ok())
150
+ .and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok());
151
+ let persisted = loose
152
+ .as_ref()
153
+ .and_then(|v| v.get("enabled"))
154
+ .and_then(Value::as_bool);
155
+ let fields = switch_fields(
156
+ path,
157
+ persisted,
158
+ path.is_some_and(std::path::Path::exists),
159
+ Override::current(),
160
+ );
161
+ if let (Some(target), Some(fields)) = (value.as_object_mut(), fields.as_object()) {
162
+ target.extend(fields.clone());
163
+ }
164
+ value["operation"] = json!(args[..delimiter]
165
+ .iter()
166
+ .find(|arg| !arg.starts_with('-'))
167
+ .map(String::as_str)
168
+ .unwrap_or("status"));
169
+ }
170
+ if is_usage {
171
+ value["next_actions"] = json!(["team-agent route --help"]);
172
+ }
173
+ if let Some(text) = super::emit(&CmdOutput::Json(value), as_json) {
174
+ if as_json {
175
+ println!("{text}");
176
+ } else {
177
+ eprintln!("{text}");
178
+ }
179
+ }
180
+ if is_usage {
181
+ if !as_json {
182
+ eprintln!("\n{HELP}");
183
+ }
184
+ ExitCode::Usage
185
+ } else {
186
+ ExitCode::Error
187
+ }
188
+ }
189
+
190
+ pub(crate) fn run(args: &[String]) -> ExitCode {
191
+ let delimiter = args
192
+ .iter()
193
+ .position(|arg| arg == "--")
194
+ .unwrap_or(args.len());
195
+ // Help and output-format controls stop at the first delimiter. Neither
196
+ // generic help scanning nor the old --no-display compatibility filter runs.
197
+ if args[..delimiter]
198
+ .iter()
199
+ .any(|arg| matches!(arg.as_str(), "--help" | "-h"))
200
+ {
201
+ println!("{HELP}");
202
+ return ExitCode::Ok;
203
+ }
204
+ let as_json = args[..delimiter].iter().any(|arg| arg == "--json");
205
+ match parse(args, delimiter) {
206
+ Ok(request) => match execute(&request) {
207
+ Ok(value) => super::emit::emit_result(CmdResult::from_json(value, request.json)),
208
+ Err(error) => emit_error(error, args, delimiter, as_json),
209
+ },
210
+ Err(error) => emit_error(error, args, delimiter, as_json),
211
+ }
212
+ }