From b29a298595796a3e30f06da14e9c9e6bc822a5b1 Mon Sep 17 00:00:00 2001 From: nebullii <110523786+nebullii@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:54:42 -0400 Subject: [PATCH 1/6] Add regressions for unknown and valueless hooks setup options cmux hooks setup and uninstall, and the setup-hooks and uninstall-hooks aliases, skip options they don't recognize. A typo such as --agnt=codex with --yes falls back to every agent and writes Pi's extension, and a bare --agent does the same. Refs #15717 --- tests/test-execution.toml | 5 ++ tests/test_cli_hooks_setup_arguments.py | 91 +++++++++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 tests/test_cli_hooks_setup_arguments.py diff --git a/tests/test-execution.toml b/tests/test-execution.toml index 06843587147b..0d9561a99a68 100644 --- a/tests/test-execution.toml +++ b/tests/test-execution.toml @@ -1491,6 +1491,11 @@ path = "tests/test_claude_hook_spool.py" lane = "macos-cli-product" requirements = ["cmux-cli"] +[[test]] +path = "tests/test_cli_hooks_setup_arguments.py" +lane = "macos-cli-product" +requirements = ["cmux-cli"] + [[test]] path = "tests/test_ci_machine_failure.py" lane = "linux-guard" diff --git a/tests/test_cli_hooks_setup_arguments.py b/tests/test_cli_hooks_setup_arguments.py new file mode 100644 index 000000000000..b29392e0e3c2 --- /dev/null +++ b/tests/test_cli_hooks_setup_arguments.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""`cmux hooks setup` and `uninstall` reject options they don't understand. + +An unknown or valueless option used to be skipped, so the command fell back +to every agent and rewrote their configs. Each case runs the real CLI against +a temporary home with no agent binaries on PATH. Pi hooks install without a +binary, so an unfiltered setup always writes Pi's extension there; a rejected +command must exit non-zero and write nothing. +""" +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +from claude_teams_test_utils import resolve_cmux_cli + + +class HooksSetupArgumentTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix='cmux-hooks-args-') + self.addCleanup(self.temp.cleanup) + self.home = Path(self.temp.name) / 'home' + self.home.mkdir() + self.cli = str(resolve_cmux_cli()) + + def run_cli(self, *args): + # A minimal environment so no agent config override points outside + # the temporary home. + env = { + 'HOME': str(self.home), + 'CFFIXED_USER_HOME': str(self.home), + 'PATH': '/usr/bin:/bin:/usr/sbin:/sbin', + 'TMPDIR': os.environ.get('TMPDIR', '/tmp'), + 'CMUX_CLI_SENTRY_DISABLED': '1', + 'CMUX_SOCKET_PATH': str(Path(self.temp.name) / 'no-socket.sock'), + } + return subprocess.run([self.cli, *args], env=env, capture_output=True, + text=True, timeout=30, check=False) + + def written_files(self): + return sorted(str(p.relative_to(self.home)) for p in self.home.rglob('*') if p.is_file()) + + def assert_rejected(self, args, message): + result = self.run_cli(*args) + output = result.stdout + result.stderr + self.assertNotEqual(result.returncode, 0, f'{args} should fail:\n{output}') + self.assertIn(message, output, f'{args}:\n{output}') + self.assertEqual(self.written_files(), [], f'{args} wrote files:\n{output}') + + def test_unknown_option_is_rejected_before_writing(self): + for subcommand in ('setup', 'uninstall'): + for args in (['--agnt', 'codex'], ['--yes', '--agnt=codex']): + with self.subTest(subcommand=subcommand, args=args): + self.assert_rejected(['hooks', subcommand, *args], 'Unknown option --agnt') + + def test_agent_option_requires_a_value(self): + for args in (['hooks', 'setup', '--agent'], + ['hooks', 'setup', '--agent', '--yes'], + ['hooks', 'setup', '--agent='], + ['hooks', 'uninstall', '--agent']): + with self.subTest(args=args): + self.assert_rejected(args, '--agent requires a value') + + def test_legacy_aliases_reject_unknown_options(self): + for command in ('setup-hooks', 'uninstall-hooks'): + with self.subTest(command=command): + self.assert_rejected([command, '--agnt', 'codex'], 'Unknown option --agnt') + + def test_help_prints_usage_without_writing(self): + for args in (['hooks', 'setup', '--help'], ['hooks', 'uninstall', '-h']): + with self.subTest(args=args): + result = self.run_cli(*args) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn('Usage: cmux hooks setup', result.stdout) + self.assertEqual(self.written_files(), []) + + def test_documented_forms_still_work(self): + for args in (['hooks', 'setup', 'pi', '--yes'], + ['hooks', 'setup', '--agent', 'pi', '-y'], + ['hooks', 'setup', '--agent=pi'], + ['hooks', 'setup', 'pi', '--agent', 'pi'], + ['hooks', 'setup', '--uninstall', 'pi']): + with self.subTest(args=args): + result = self.run_cli(*args) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn('pi', result.stdout) + + +if __name__ == '__main__': + unittest.main() From a638b3013a4f3cafc132a7301089fc9126cffca6 Mon Sep 17 00:00:00 2001 From: nebullii <110523786+nebullii@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:11:18 -0400 Subject: [PATCH 2/6] Reject unknown and valueless options in cmux hooks setup and uninstall One parser now reads the target and mode for hooks setup, hooks uninstall, and the setup-hooks and uninstall-hooks aliases. It rejects an option it doesn't know, --agent without a value (including --agent= and --agent followed by another flag), and more than one target, before any agent config is touched. --agent , --agent=, --yes/-y, --uninstall and a positional agent keep working. Fixes #15717 --- CLI/cmux.swift | 72 ++++++++++++++----- Resources/Localizable.xcstrings | 118 ++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+), 19 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index b257328d25c8..f84da93158c8 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -5325,7 +5325,7 @@ struct CMUXCLI { return } } - if command == "setup-hooks" || command == "uninstall-hooks" { try runSetupHooks(uninstall: command == "uninstall-hooks"); return } // Backwards compatibility for old hook setup docs/scripts. + if command == "setup-hooks" || command == "uninstall-hooks" { try runSetupHooks(uninstall: command == "uninstall-hooks", arguments: try Self.parseHooksSetupArguments(commandArgs)); return } // Backwards compatibility for old hook setup docs/scripts. if (command == "codex-hook" || command == "feed-hook"), processEnv["CMUX_SURFACE_ID"]?.isEmpty != false, processEnv["CMUX_WORKSPACE_ID"]?.isEmpty != false, !commandArgs.contains(where: { $0 == "--workspace" || $0 == "--surface" || $0.hasPrefix("--workspace=") || $0.hasPrefix("--surface=") }) { print("{}"); return } // Backwards compatibility for old installed hooks outside cmux terminals. if command == "hooks" { @@ -42243,14 +42243,14 @@ export default { case "setup": try runSetupHooks( uninstall: false, - positionalAgentFilter: try Self.hooksSetupPositionalAgentFilter(from: Array(commandArgs.dropFirst())) + arguments: try Self.parseHooksSetupArguments(Array(commandArgs.dropFirst())) ) return true case "uninstall": try runSetupHooks( uninstall: true, - positionalAgentFilter: try Self.hooksSetupPositionalAgentFilter(from: Array(commandArgs.dropFirst())) + arguments: try Self.parseHooksSetupArguments(Array(commandArgs.dropFirst())) ) return true @@ -42430,34 +42430,68 @@ export default { } } - private static func hooksSetupPositionalAgentFilter(from args: [String]) throws -> String? { - var skipNext = false + /// The target and mode `cmux hooks setup`/`uninstall` (and the legacy + /// `setup-hooks`/`uninstall-hooks`) were asked for. + private struct HooksSetupArguments { + var flagAgent: String? var positionalAgent: String? - for arg in args { - if skipNext { - skipNext = false - continue + var uninstall = false + } + + /// Parses setup/uninstall arguments, rejecting anything it doesn't know + /// so a typo can't fall back to every agent. + private static func parseHooksSetupArguments(_ args: [String]) throws -> HooksSetupArguments { + var parsed = HooksSetupArguments() + func setFlagAgent(_ value: String) throws { + guard !value.isEmpty, !value.hasPrefix("-") else { + throw CLIError(message: String( + localized: "cli.hooks.setup.error.agentRequiresValue", + defaultValue: "--agent requires a value. Usage: cmux hooks setup [agent] [--agent ] [--yes|-y]" + )) } + if let existing = parsed.flagAgent, existing != value { + throw CLIError(message: "Conflicting hooks target: use either --agent or a positional target, not both") + } + parsed.flagAgent = value + } + var index = 0 + while index < args.count { + let arg = args[index] + index += 1 switch arg { case "--agent": - skipNext = true - case "--yes", "-y", "--uninstall": + try setFlagAgent(index < args.count ? args[index] : "") + index += 1 + case "--yes", "-y": continue + case "--uninstall": + parsed.uninstall = true default: - if !arg.hasPrefix("-") { - if positionalAgent != nil { + if arg.hasPrefix("--agent=") { + try setFlagAgent(String(arg.dropFirst("--agent=".count))) + } else if arg.hasPrefix("-") { + let name = arg.split(separator: "=", maxSplits: 1).first.map(String.init) ?? arg + throw CLIError(message: String.localizedStringWithFormat( + String( + localized: "cli.hooks.setup.error.unknownOption", + defaultValue: "Unknown option %@. Usage: cmux hooks setup [agent] [--agent ] [--yes|-y]" + ), + name + )) + } else { + if parsed.positionalAgent != nil { throw CLIError(message: "Too many hooks targets: specify at most one positional agent") } - positionalAgent = arg + parsed.positionalAgent = arg } } } - return positionalAgent + return parsed } - private func runSetupHooks(uninstall: Bool = false, positionalAgentFilter: String? = nil) throws { - let args = ProcessInfo.processInfo.arguments - let flagAgentFilter = optionValue(args, name: "--agent") + private func runSetupHooks(uninstall: Bool = false, arguments: HooksSetupArguments) throws { + let flagAgentFilter = arguments.flagAgent + let positionalAgentFilter = arguments.positionalAgent if let flagAgentFilter, let positionalAgentFilter { guard let flagDef = Self.agentDef(named: flagAgentFilter) else { throw CLIError(message: "Unknown hooks target: \(flagAgentFilter)") @@ -42479,7 +42513,7 @@ export default { } else { agentFilterDef = nil } - let isUninstall = uninstall || args.contains("--uninstall") + let isUninstall = uninstall || arguments.uninstall let fm = FileManager.default let verb = isUninstall ? "uninstalling" : "installing" print("cmux hooks \(isUninstall ? "uninstall" : "setup"): \(verb) agent hooks") diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 594f97f97522..27be4328bd49 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -618912,6 +618912,124 @@ } } } + }, + "cli.hooks.setup.error.agentRequiresValue": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "--agent requires a value. Usage: cmux hooks setup [agent] [--agent ] [--yes|-y]" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يتطلب --agent قيمة. الاستخدام: cmux hooks setup [agent] [--agent ] [--yes|-y]" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "--agent erfordert einen Wert. Verwendung: cmux hooks setup [agent] [--agent ] [--yes|-y]" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "--agent requiere un valor. Uso: cmux hooks setup [agent] [--agent ] [--yes|-y]" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "--agent nécessite une valeur. Utilisation : cmux hooks setup [agent] [--agent ] [--yes|-y]" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "--agent には値が必要です。使い方: cmux hooks setup [agent] [--agent ] [--yes|-y]" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "--agent에는 값이 필요합니다. 사용법: cmux hooks setup [agent] [--agent ] [--yes|-y]" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "--agent 需要一个值。用法:cmux hooks setup [agent] [--agent ] [--yes|-y]" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "--agent 需要一個值。用法:cmux hooks setup [agent] [--agent ] [--yes|-y]" + } + } + } + }, + "cli.hooks.setup.error.unknownOption": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown option %@. Usage: cmux hooks setup [agent] [--agent ] [--yes|-y]" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "خيار غير معروف %@. الاستخدام: cmux hooks setup [agent] [--agent ] [--yes|-y]" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Unbekannte Option %@. Verwendung: cmux hooks setup [agent] [--agent ] [--yes|-y]" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Opción desconocida %@. Uso: cmux hooks setup [agent] [--agent ] [--yes|-y]" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Option inconnue %@. Utilisation : cmux hooks setup [agent] [--agent ] [--yes|-y]" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明なオプション %@。使い方: cmux hooks setup [agent] [--agent ] [--yes|-y]" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알 수 없는 옵션 %@. 사용법: cmux hooks setup [agent] [--agent ] [--yes|-y]" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未知选项 %@。用法:cmux hooks setup [agent] [--agent ] [--yes|-y]" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未知的選項 %@。用法:cmux hooks setup [agent] [--agent ] [--yes|-y]" + } + } + } } }, "version": "1.0" From c3861d42948ab38092ed895db7b607f7a665ed5a Mon Sep 17 00:00:00 2001 From: nebullii <110523786+nebullii@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:13:27 -0400 Subject: [PATCH 3/6] Run the hooks setup argument test with stdin closed A setup case without --yes reaches Pi's confirmation prompt when Pi isn't installed yet, so an empty stdin keeps it from waiting on the runner. --- tests/test_cli_hooks_setup_arguments.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_cli_hooks_setup_arguments.py b/tests/test_cli_hooks_setup_arguments.py index b29392e0e3c2..117ea78c86f0 100644 --- a/tests/test_cli_hooks_setup_arguments.py +++ b/tests/test_cli_hooks_setup_arguments.py @@ -35,8 +35,9 @@ def run_cli(self, *args): 'CMUX_CLI_SENTRY_DISABLED': '1', 'CMUX_SOCKET_PATH': str(Path(self.temp.name) / 'no-socket.sock'), } - return subprocess.run([self.cli, *args], env=env, capture_output=True, - text=True, timeout=30, check=False) + # No stdin, so a confirmation prompt can't wait on the test runner. + return subprocess.run([self.cli, *args], env=env, stdin=subprocess.DEVNULL, + capture_output=True, text=True, timeout=30, check=False) def written_files(self): return sorted(str(p.relative_to(self.home)) for p in self.home.rglob('*') if p.is_file()) From bd725d5f925abe55fb0ed58c4300e7afcf590fa4 Mon Sep 17 00:00:00 2001 From: nebullii <110523786+nebullii@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:26:20 -0400 Subject: [PATCH 4/6] Give repeated --agent values their own localized error, and test agent aliases --agent codex --agent pi reused the message about mixing --agent with a positional target, which isn't the conflict, and it wasn't localized. It now says --agent was given more than once with different values. The test also covers the documented agent aliases (agy, rovo) through the positional, --agent and --agent= forms. --- CLI/cmux.swift | 5 ++- Resources/Localizable.xcstrings | 59 +++++++++++++++++++++++++ tests/test_cli_hooks_setup_arguments.py | 14 ++++++ 3 files changed, 77 insertions(+), 1 deletion(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index f84da93158c8..fd22dd063559 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -42450,7 +42450,10 @@ export default { )) } if let existing = parsed.flagAgent, existing != value { - throw CLIError(message: "Conflicting hooks target: use either --agent or a positional target, not both") + throw CLIError(message: String( + localized: "cli.hooks.setup.error.conflictingAgent", + defaultValue: "--agent was given more than once with different values. Specify one agent." + )) } parsed.flagAgent = value } diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 27be4328bd49..b5754cba0adb 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -619030,6 +619030,65 @@ } } } + }, + "cli.hooks.setup.error.conflictingAgent": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "--agent was given more than once with different values. Specify one agent." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم تمرير --agent أكثر من مرة بقيم مختلفة. حدد وكيلًا واحدًا." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "--agent wurde mehrmals mit unterschiedlichen Werten angegeben. Gib einen Agenten an." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "--agent se indicó más de una vez con valores distintos. Indica un solo agente." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "--agent a été indiqué plusieurs fois avec des valeurs différentes. Indiquez un seul agent." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "--agent が異なる値で複数回指定されました。エージェントを 1 つだけ指定してください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "--agent가 서로 다른 값으로 여러 번 지정되었습니다. 에이전트를 하나만 지정하세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "--agent 被多次指定且值不同。请只指定一个代理。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "--agent 被多次指定且值不同。請只指定一個代理程式。" + } + } + } } }, "version": "1.0" diff --git a/tests/test_cli_hooks_setup_arguments.py b/tests/test_cli_hooks_setup_arguments.py index 117ea78c86f0..6ac804e1874f 100644 --- a/tests/test_cli_hooks_setup_arguments.py +++ b/tests/test_cli_hooks_setup_arguments.py @@ -63,6 +63,10 @@ def test_agent_option_requires_a_value(self): with self.subTest(args=args): self.assert_rejected(args, '--agent requires a value') + def test_repeated_agent_with_different_values_is_rejected(self): + self.assert_rejected(['hooks', 'setup', '--agent', 'codex', '--agent', 'pi'], + '--agent was given more than once with different values') + def test_legacy_aliases_reject_unknown_options(self): for command in ('setup-hooks', 'uninstall-hooks'): with self.subTest(command=command): @@ -88,5 +92,15 @@ def test_documented_forms_still_work(self): self.assertIn('pi', result.stdout) + def test_agent_name_aliases_still_resolve(self): + for args, agent in ((['hooks', 'setup', 'agy', '--yes'], 'antigravity'), + (['hooks', 'setup', '--agent', 'rovo', '--yes'], 'rovodev'), + (['hooks', 'uninstall', '--agent=agy', '--yes'], 'antigravity')): + with self.subTest(args=args): + result = self.run_cli(*args) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn(f' {agent}:', result.stdout) + + if __name__ == '__main__': unittest.main() From b7a7e0c257edf801035ac4328a41802ca8cc3e70 Mon Sep 17 00:00:00 2001 From: nebullii <110523786+nebullii@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:40:30 -0400 Subject: [PATCH 5/6] Expect the hooks setup argument test in the CLI product lane The registry test pins the macos-cli-product lane listing, so it now lists test_cli_hooks_setup_arguments.py next to the hook spool test and validates its registration too. --- tests/test_ci_test_execution_registry.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/test_ci_test_execution_registry.py b/tests/test_ci_test_execution_registry.py index d5f6f7028e27..2465270c49aa 100644 --- a/tests/test_ci_test_execution_registry.py +++ b/tests/test_ci_test_execution_registry.py @@ -63,8 +63,14 @@ def test_cli_product_lane_selects_the_hook_spool_regression(self) -> None: "--lane", "macos-cli-product", "--list"], cwd=ROOT, capture_output=True, text=True, check=True, ) - self.assertEqual(result.stdout.splitlines(), ["tests/test_claude_hook_spool.py"]) - errors, _, _ = validator.validate(ROOT, added={"tests/test_claude_hook_spool.py"}) + self.assertEqual( + result.stdout.splitlines(), + ["tests/test_claude_hook_spool.py", "tests/test_cli_hooks_setup_arguments.py"], + ) + errors, _, _ = validator.validate( + ROOT, + added={"tests/test_claude_hook_spool.py", "tests/test_cli_hooks_setup_arguments.py"}, + ) self.assertEqual(errors, []) def make_root(self, *, tests: list[str], registry: str) -> Path: From a191123e319c28d2c726eab0c21c37f7e697af12 Mon Sep 17 00:00:00 2001 From: nebullii <110523786+nebullii@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:47:23 -0400 Subject: [PATCH 6/6] Test that a positional target and --agent must agree hooks setup codex --agent pi has to stop with the conflicting-target error and write nothing. --- tests/test_cli_hooks_setup_arguments.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_cli_hooks_setup_arguments.py b/tests/test_cli_hooks_setup_arguments.py index 6ac804e1874f..03aaa5b658aa 100644 --- a/tests/test_cli_hooks_setup_arguments.py +++ b/tests/test_cli_hooks_setup_arguments.py @@ -67,6 +67,9 @@ def test_repeated_agent_with_different_values_is_rejected(self): self.assert_rejected(['hooks', 'setup', '--agent', 'codex', '--agent', 'pi'], '--agent was given more than once with different values') + def test_positional_and_agent_option_must_agree(self): + self.assert_rejected(['hooks', 'setup', 'codex', '--agent', 'pi'], 'Conflicting hooks target') + def test_legacy_aliases_reject_unknown_options(self): for command in ('setup-hooks', 'uninstall-hooks'): with self.subTest(command=command):