From 1ceaaa2d349460fc10f0e226ac4096be21997483 Mon Sep 17 00:00:00 2001 From: Zhongxuan Wang Date: Thu, 25 Jun 2026 11:56:09 -0700 Subject: [PATCH 1/3] fix(cli): parse codex `plugin list` text output instead of --json Codex CLI 0.136.0's `codex plugin list` has no `--json` flag, so the codex plugin host-registration probe in `nemo-relay doctor --plugin codex` (and `--plugin all`) failed with "unexpected argument '--json' found". Parse the `codex plugin list` text table instead, matching the existing `codex_marketplace_registered` text parsing. Claude Code keeps `--json` because its CLI still supports it. Fixes #313 Signed-off-by: Zhongxuan Wang --- crates/cli/src/plugin_install/host.rs | 32 +++++--- .../tests/coverage/plugin_install_tests.rs | 75 ++++++++++--------- 2 files changed, 60 insertions(+), 47 deletions(-) diff --git a/crates/cli/src/plugin_install/host.rs b/crates/cli/src/plugin_install/host.rs index a6f5a87e9..48ef9a783 100644 --- a/crates/cli/src/plugin_install/host.rs +++ b/crates/cli/src/plugin_install/host.rs @@ -220,17 +220,27 @@ fn codex_plugin_registered( options: &PluginInstallOptions, runner: &dyn CommandRunner, ) -> Result { - let output = run_capture_command( - "codex", - &["plugin".into(), "list".into(), "--json".into()], - options, - runner, - )?; - let plugins = parse_json_command_output("codex plugin list --json", output)?; - Ok(plugins - .get("installed") - .and_then(Value::as_array) - .is_some_and(|plugins| plugins.iter().any(plugin_entry_matches))) + // Codex `plugin list` has no `--json` flag, so parse its text table the same way + // `codex_marketplace_registered` parses `plugin marketplace list`. + let output = run_capture_command("codex", &["plugin".into(), "list".into()], options, runner)?; + let plugin_id = format!("{PLUGIN_NAME}@{MARKETPLACE_NAME}"); + Ok(output + .stdout + .lines() + .any(|line| codex_plugin_line_installed(line, &plugin_id))) +} + +// Codex `plugin list` rows are ` `. An installed +// plugin's status starts with `installed` (e.g. `installed, enabled`), while an +// available-but-uninstalled one is `not installed`. +fn codex_plugin_line_installed(line: &str, plugin_id: &str) -> bool { + let mut columns = line.split_whitespace(); + if columns.next() != Some(plugin_id) { + return false; + } + columns + .next() + .is_some_and(|status| status.starts_with("installed")) } fn codex_marketplace_registered( diff --git a/crates/cli/tests/coverage/plugin_install_tests.rs b/crates/cli/tests/coverage/plugin_install_tests.rs index 4d6535190..1d08495cd 100644 --- a/crates/cli/tests/coverage/plugin_install_tests.rs +++ b/crates/cli/tests/coverage/plugin_install_tests.rs @@ -559,21 +559,17 @@ fn host_command_helpers_cover_dry_run_missing_failure_and_reporting() { ); let runner = MockRunner::default() - .with_executable("codex", "/bin/codex") - .with_capture_output("/bin/codex plugin list --json", "not json") - .with_capture_output("/bin/codex plugin marketplace list", ""); + .with_executable("claude", "/bin/claude") + .with_capture_output("/bin/claude plugin list --json", "not json"); assert!( - host_registration_report(PluginHost::Codex, &normal, &runner) + host_registration_report(PluginHost::ClaudeCode, &normal, &runner) .unwrap_err() .contains("failed to parse") ); let runner = MockRunner::default() .with_executable("codex", "/bin/codex") - .with_capture_output( - "/bin/codex plugin list --json", - json!({"installed": []}).to_string(), - ) + .with_capture_output("/bin/codex plugin list", "PLUGIN STATUS VERSION PATH\n") .with_capture_output("/bin/codex plugin marketplace list", "MARKETPLACE ROOT\n"); let error = validate_host_registration(PluginHost::Codex, &normal, &runner).unwrap_err(); assert!( @@ -618,30 +614,41 @@ fn host_registration_report_accepts_claude_and_codex_shape_variants() { assert!(report.host_marketplace_registered); } - for plugin_entry in [ - json!({"id": plugin_id.clone()}), - json!({"pluginId": plugin_id.clone()}), - json!({"name": PLUGIN_NAME, "marketplaceName": MARKETPLACE_NAME}), - ] { - let runner = MockRunner::default() - .with_executable("codex", "/bin/codex") - .with_capture_output( - "/bin/codex plugin list --json", - json!({"installed": [plugin_entry]}).to_string(), - ) - .with_capture_output( - "/bin/codex plugin marketplace list", - format!("{MARKETPLACE_NAME} /tmp/nemo-relay-local\n"), - ); - let report = host_registration_report(PluginHost::Codex, &normal, &runner).unwrap(); - assert!(report.ok()); - } + // Codex `plugin list` is a text table; an installed row registers the plugin. + let runner = MockRunner::default() + .with_executable("codex", "/bin/codex") + .with_capture_output( + "/bin/codex plugin list", + format!("{plugin_id} installed, enabled 0.4.0 /tmp/nemo-relay-plugin\n"), + ) + .with_capture_output( + "/bin/codex plugin marketplace list", + format!("{MARKETPLACE_NAME} /tmp/nemo-relay-local\n"), + ); + let report = host_registration_report(PluginHost::Codex, &normal, &runner).unwrap(); + assert!(report.ok()); + + // A row that is present but `not installed` must not count as registered. + let runner = MockRunner::default() + .with_executable("codex", "/bin/codex") + .with_capture_output( + "/bin/codex plugin list", + format!("{plugin_id} not installed\n"), + ) + .with_capture_output( + "/bin/codex plugin marketplace list", + format!("{MARKETPLACE_NAME} /tmp/nemo-relay-local\n"), + ); + let report = host_registration_report(PluginHost::Codex, &normal, &runner).unwrap(); + assert!(!report.host_plugin_registered); + assert!(report.host_marketplace_registered); + // A plugin installed under a different marketplace id must not match. let runner = MockRunner::default() .with_executable("codex", "/bin/codex") .with_capture_output( - "/bin/codex plugin list --json", - json!({"installed": [{"name": PLUGIN_NAME, "marketplaceName": "other"}]}).to_string(), + "/bin/codex plugin list", + format!("{PLUGIN_NAME}@other installed, enabled 0.4.0 /tmp/other\n"), ) .with_capture_output("/bin/codex plugin marketplace list", "other /tmp/other\n"); let report = host_registration_report(PluginHost::Codex, &normal, &runner).unwrap(); @@ -1328,13 +1335,9 @@ fn doctor_json_uses_quiet_plugin_report() { .with_executable("nemo-relay", "/bin/nemo-relay") .with_executable("codex", "/bin/codex") .with_capture_output( - "/bin/codex plugin list --json", - json!({ - "installed": [ - { "pluginId": "nemo-relay-plugin@nemo-relay-local" } - ] - }) - .to_string(), + "/bin/codex plugin list", + "PLUGIN STATUS VERSION PATH\n\ + nemo-relay-plugin@nemo-relay-local installed, enabled 0.4.0 /tmp/nemo-relay-plugin\n", ) .with_capture_output( "/bin/codex plugin marketplace list", @@ -1357,7 +1360,7 @@ fn doctor_json_uses_quiet_plugin_report() { assert_eq!( runner.capture_commands(), vec![ - "/bin/codex plugin list --json", + "/bin/codex plugin list", "/bin/codex plugin marketplace list" ] ); From be06cea8ec52c748e9dcf41127d77a12742c346e Mon Sep 17 00:00:00 2001 From: Zhongxuan Wang Date: Thu, 25 Jun 2026 14:24:31 -0700 Subject: [PATCH 2/3] test(cli): relocate claude plugin-list parse-error coverage Codex no longer parses JSON after the --json removal, so move the parse_json_command_output parse-error assertion out of the codex-focused helper test and into host_registration_report_surfaces_capture_status_and_stderr_variants, keeping the coverage with the host (Claude Code) that still uses --json. Signed-off-by: Zhongxuan Wang --- .../tests/coverage/plugin_install_tests.rs | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/crates/cli/tests/coverage/plugin_install_tests.rs b/crates/cli/tests/coverage/plugin_install_tests.rs index 1d08495cd..03b3343cb 100644 --- a/crates/cli/tests/coverage/plugin_install_tests.rs +++ b/crates/cli/tests/coverage/plugin_install_tests.rs @@ -558,15 +558,6 @@ fn host_command_helpers_cover_dry_run_missing_failure_and_reporting() { .contains(": boom") ); - let runner = MockRunner::default() - .with_executable("claude", "/bin/claude") - .with_capture_output("/bin/claude plugin list --json", "not json"); - assert!( - host_registration_report(PluginHost::ClaudeCode, &normal, &runner) - .unwrap_err() - .contains("failed to parse") - ); - let runner = MockRunner::default() .with_executable("codex", "/bin/codex") .with_capture_output("/bin/codex plugin list", "PLUGIN STATUS VERSION PATH\n") @@ -662,6 +653,17 @@ fn host_registration_report_surfaces_capture_status_and_stderr_variants() { let dir = tempdir().unwrap(); let normal = options(dir.path()); + // Non-JSON `claude plugin list` output surfaces a parse error. Codex no longer + // has a JSON path, so this only applies to the host that still uses `--json`. + let runner = MockRunner::default() + .with_executable("claude", "/bin/claude") + .with_capture_output("/bin/claude plugin list --json", "not json"); + assert!( + host_registration_report(PluginHost::ClaudeCode, &normal, &runner) + .unwrap_err() + .contains("failed to parse") + ); + let runner = MockRunner::default() .with_executable("claude", "/bin/claude") .with_capture_status( From df5e275ecaa4fe5a3e5304702ebc519e10347db4 Mon Sep 17 00:00:00 2001 From: Zhongxuan Wang Date: Thu, 25 Jun 2026 14:30:52 -0700 Subject: [PATCH 3/3] refactor(cli): trim non-essential comments in codex plugin-list fix Signed-off-by: Zhongxuan Wang --- crates/cli/src/plugin_install/host.rs | 6 +----- crates/cli/tests/coverage/plugin_install_tests.rs | 5 ----- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/crates/cli/src/plugin_install/host.rs b/crates/cli/src/plugin_install/host.rs index 48ef9a783..3bc2355fe 100644 --- a/crates/cli/src/plugin_install/host.rs +++ b/crates/cli/src/plugin_install/host.rs @@ -220,8 +220,7 @@ fn codex_plugin_registered( options: &PluginInstallOptions, runner: &dyn CommandRunner, ) -> Result { - // Codex `plugin list` has no `--json` flag, so parse its text table the same way - // `codex_marketplace_registered` parses `plugin marketplace list`. + // Codex `plugin list` has no `--json` flag (unlike Claude Code). let output = run_capture_command("codex", &["plugin".into(), "list".into()], options, runner)?; let plugin_id = format!("{PLUGIN_NAME}@{MARKETPLACE_NAME}"); Ok(output @@ -230,9 +229,6 @@ fn codex_plugin_registered( .any(|line| codex_plugin_line_installed(line, &plugin_id))) } -// Codex `plugin list` rows are ` `. An installed -// plugin's status starts with `installed` (e.g. `installed, enabled`), while an -// available-but-uninstalled one is `not installed`. fn codex_plugin_line_installed(line: &str, plugin_id: &str) -> bool { let mut columns = line.split_whitespace(); if columns.next() != Some(plugin_id) { diff --git a/crates/cli/tests/coverage/plugin_install_tests.rs b/crates/cli/tests/coverage/plugin_install_tests.rs index 03b3343cb..dc8b706d3 100644 --- a/crates/cli/tests/coverage/plugin_install_tests.rs +++ b/crates/cli/tests/coverage/plugin_install_tests.rs @@ -605,7 +605,6 @@ fn host_registration_report_accepts_claude_and_codex_shape_variants() { assert!(report.host_marketplace_registered); } - // Codex `plugin list` is a text table; an installed row registers the plugin. let runner = MockRunner::default() .with_executable("codex", "/bin/codex") .with_capture_output( @@ -619,7 +618,6 @@ fn host_registration_report_accepts_claude_and_codex_shape_variants() { let report = host_registration_report(PluginHost::Codex, &normal, &runner).unwrap(); assert!(report.ok()); - // A row that is present but `not installed` must not count as registered. let runner = MockRunner::default() .with_executable("codex", "/bin/codex") .with_capture_output( @@ -634,7 +632,6 @@ fn host_registration_report_accepts_claude_and_codex_shape_variants() { assert!(!report.host_plugin_registered); assert!(report.host_marketplace_registered); - // A plugin installed under a different marketplace id must not match. let runner = MockRunner::default() .with_executable("codex", "/bin/codex") .with_capture_output( @@ -653,8 +650,6 @@ fn host_registration_report_surfaces_capture_status_and_stderr_variants() { let dir = tempdir().unwrap(); let normal = options(dir.path()); - // Non-JSON `claude plugin list` output surfaces a parse error. Codex no longer - // has a JSON path, so this only applies to the host that still uses `--json`. let runner = MockRunner::default() .with_executable("claude", "/bin/claude") .with_capture_output("/bin/claude plugin list --json", "not json");