diff --git a/CLI/CMUXCLI+Coderouter.swift b/CLI/CMUXCLI+Coderouter.swift index ea2b4d337bdd..08a04103f1f5 100644 --- a/CLI/CMUXCLI+Coderouter.swift +++ b/CLI/CMUXCLI+Coderouter.swift @@ -6,14 +6,16 @@ import Foundation // The CLI is presentation only; each verb maps to one `coderouter.*` socket // method handled by the app's `CoderouterClient`, which holds the Stack // session. Every other `cmux coderouter ...` verb, and all of `cmux cr ...`, -// is exec'd into the installed CodeRouter CLI before any socket is opened. +// is exec'd into the CodeRouter CLI before any socket is opened +// (CMUXCLI+CoderouterPassthrough.swift, which also installs it when missing). extension CMUXCLI { static let coderouterUsage = """ Usage: cmux coderouter [options] Team settings for the cmux coderouter model plane that Cloud machines route codex, claude, pi, and opencode through. Any other verb, and every - `cmux cr ...`, runs the installed CodeRouter CLI unchanged. + `cmux cr ...`, runs the CodeRouter CLI unchanged, offering to install it + first when this machine has none. cmux coderouter status [--team ] [--json] Sign-in state, selected team, and the team's Claude upstream accounts. diff --git a/CLI/CMUXCLI+CoderouterPassthrough.swift b/CLI/CMUXCLI+CoderouterPassthrough.swift new file mode 100644 index 000000000000..081be6143027 --- /dev/null +++ b/CLI/CMUXCLI+CoderouterPassthrough.swift @@ -0,0 +1,300 @@ +import Darwin +import Foundation + +// `cmux cr ...`, and every `cmux coderouter ...` verb cmux does not own +// (CMUXCLI+Coderouter.swift), run the separately distributed CodeRouter CLI. +// This file owns that passthrough end to end: locating the executable, +// bootstrapping it on a machine that has none, and replacing this process +// with it. The cmux socket is never opened on this path. +extension CMUXCLI { + /// The installer documented at https://cmux.com/coderouter. Every message + /// quotes this exact command, and the interactive bootstrap runs it: cmux + /// fetches the script and hands it to `sh` as two separate steps, so a + /// failed or truncated download never reaches the shell. The script itself + /// verifies the binary's checksum against the release manifest. + static let coderouterInstallerURL = "https://cmux.com/coderouter/install.sh" + static let coderouterInstallCommand = "curl -fsSL \(coderouterInstallerURL) | sh" + + private static let coderouterExecutableNames = ["coderouter", "cr"] + + /// Where install.sh puts the binary: `$CODEROUTER_INSTALL/bin`, default + /// `$HOME/.coderouter/bin`, computed exactly as the script does. + static func coderouterInstallBinDirectory(environment: [String: String]) -> URL { + let explicitRoot = environment["CODEROUTER_INSTALL"]? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let root: URL + if !explicitRoot.isEmpty { + root = URL(fileURLWithPath: explicitRoot, isDirectory: true) + } else { + let home = environment["HOME"].flatMap { $0.isEmpty ? nil : $0 } ?? NSHomeDirectory() + root = URL(fileURLWithPath: home, isDirectory: true) + .appendingPathComponent(".coderouter", isDirectory: true) + } + return root.appendingPathComponent("bin", isDirectory: true) + } + + /// CodeRouter and its installer are independent executables. They never + /// receive cmux's ambient terminal or control-plane context: CMUX_* and + /// CMUXD_* may carry socket paths, capabilities, passwords, auth state, or + /// internal paths. There is intentionally no auth handoff; a future one + /// must be explicit and narrowly allowlisted. + static func coderouterChildEnvironment(from environment: [String: String]) -> [String: String] { + environment.filter { key, _ in + !key.hasPrefix("CMUX_") && !key.hasPrefix("CMUXD_") + } + } + + func runCoderouterAlias(commandArgs: [String]) throws { + let environment = ProcessInfo.processInfo.environment + let executablePath: String + if let installed = resolveCoderouterExecutable(environment: environment) { + executablePath = installed + } else { + executablePath = try bootstrapCoderouter(environment: environment) + } + try execCoderouter(at: executablePath, commandArgs: commandArgs, environment: environment) + } + + /// PATH first (`coderouter`, then `cr`), exactly as before, then the + /// installer's bin directory, so an install whose shell-profile line has + /// not reached this process still runs. No hit involves the network. + func resolveCoderouterExecutable(environment: [String: String]) -> String? { + for name in Self.coderouterExecutableNames { + if let path = resolveExecutableInPath(name, searchPath: environment["PATH"]) { + return path + } + } + return resolveExecutableInPath( + "coderouter", + searchPath: Self.coderouterInstallBinDirectory(environment: environment).path + ) + } + + // MARK: - Bootstrap + + /// Returns the path of a freshly installed CodeRouter CLI, or throws exit + /// 127 carrying the exact install command whenever this process cannot or + /// may not install: no terminal to ask on, a declined offer, a failed + /// download, or a failed installer. The offer and the question go to + /// stderr so stdout stays CodeRouter's; the answer comes from stdin, so + /// both must be terminals. + private func bootstrapCoderouter(environment: [String: String]) throws -> String { + let installCommand = Self.coderouterInstallCommand + guard isatty(STDIN_FILENO) == 1, isatty(STDERR_FILENO) == 1 else { + throw Self.coderouterUnavailable(String( + format: Self.localizedPassthroughString( + "cli.coderouter.bootstrap.notInstalled", + defaultValue: "CodeRouter CLI is not installed. Install it, then retry:\n %@" + ), + installCommand + )) + } + + let binDirectory = Self.coderouterInstallBinDirectory(environment: environment) + cliWriteStderr(String( + format: Self.localizedPassthroughString( + "cli.coderouter.bootstrap.offer", + defaultValue: "CodeRouter CLI is not installed. cmux can install it now by running the official installer:\n %1$@\nThis downloads the checksum-verified CodeRouter binary into %2$@ and adds that directory to your shell PATH." + ), + installCommand, + binDirectory.path + ) + "\n") + cliWriteStderr(Self.localizedPassthroughString( + "cli.coderouter.bootstrap.confirm", + defaultValue: "Install CodeRouter now? [y/N] " + )) + let answer = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + guard answer.hasPrefix("y") else { + throw Self.coderouterUnavailable(String( + format: Self.localizedPassthroughString( + "cli.coderouter.bootstrap.declined", + defaultValue: "CodeRouter was not installed. Install it later with:\n %@" + ), + installCommand + )) + } + + cliWriteStderr(Self.localizedPassthroughString( + "cli.coderouter.bootstrap.installing", + defaultValue: "Installing CodeRouter..." + ) + "\n") + let childEnvironment = Self.coderouterChildEnvironment(from: environment) + let installer = try fetchCoderouterInstaller(environment: environment, childEnvironment: childEnvironment) + defer { installer.cleanUp() } + let status = try Self.runCoderouterChild( + executable: "/bin/sh", + arguments: [installer.scriptURL.path], + environment: childEnvironment + ) + guard status == 0 else { + throw Self.coderouterUnavailable(String( + format: Self.localizedPassthroughString( + "cli.coderouter.bootstrap.installerFailed", + defaultValue: "The CodeRouter installer exited with status %1$d. Retry, or install it with:\n %2$@" + ), + status, + installCommand + )) + } + guard let installed = resolveCoderouterExecutable(environment: environment) else { + throw Self.coderouterUnavailable(String( + format: Self.localizedPassthroughString( + "cli.coderouter.bootstrap.installedNotFound", + defaultValue: "The CodeRouter installer finished, but no CodeRouter CLI was found in %@. Add its bin directory to PATH and retry." + ), + binDirectory.path + )) + } + return installed + } + + private struct CoderouterInstallerScript { + let scriptURL: URL + /// Owned by cmux only when the script was downloaded here. + let temporaryDirectory: URL? + + func cleanUp() { + guard let temporaryDirectory else { return } + try? FileManager.default.removeItem(at: temporaryDirectory) + } + } + + /// Downloads install.sh into a private temporary directory with the same + /// transport rules the script applies to its own downloads (HTTPS only, + /// TLS 1.2+). curl's stderr is inherited, so a network failure shows its + /// own diagnosis before cmux's summary line. + private func fetchCoderouterInstaller( + environment: [String: String], + childEnvironment: [String: String] + ) throws -> CoderouterInstallerScript { +#if DEBUG + // Tests run the bootstrap against a local stand-in for install.sh so + // the offer, the install, the re-resolve, and the exec are exercised + // without the network. Not compiled into release builds. + if let override = environment["CMUX_CODEROUTER_INSTALLER_SCRIPT"]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty { + return CoderouterInstallerScript( + scriptURL: URL(fileURLWithPath: override, isDirectory: false), + temporaryDirectory: nil + ) + } +#endif + let fileManager = FileManager.default + let directory = fileManager.temporaryDirectory + .appendingPathComponent("cmux-coderouter-install-\(UUID().uuidString)", isDirectory: true) + try fileManager.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + let scriptURL = directory.appendingPathComponent("install.sh", isDirectory: false) + let status = try Self.runCoderouterChild( + executable: "/usr/bin/curl", + arguments: [ + "--proto", "=https", + "--tlsv1.2", + "-fsSL", + "--max-time", "60", + Self.coderouterInstallerURL, + "-o", scriptURL.path, + ], + environment: childEnvironment, + standardInput: FileHandle.nullDevice + ) + guard status == 0 else { + try? fileManager.removeItem(at: directory) + throw Self.coderouterUnavailable(String( + format: Self.localizedPassthroughString( + "cli.coderouter.bootstrap.downloadFailed", + defaultValue: "Could not download the CodeRouter installer (curl exited with status %1$d). Retry, or install it with:\n %2$@" + ), + status, + Self.coderouterInstallCommand + )) + } + return CoderouterInstallerScript(scriptURL: scriptURL, temporaryDirectory: directory) + } + + /// Runs a bootstrap child on this terminal and waits for it. Returns its + /// exit status, or 128 plus the signal number when a signal ended it, the + /// shell convention the user already knows. + private static func runCoderouterChild( + executable: String, + arguments: [String], + environment: [String: String], + standardInput: FileHandle? = nil + ) throws -> Int32 { + let process = Process() + process.executableURL = URL(fileURLWithPath: executable, isDirectory: false) + process.arguments = arguments + process.environment = environment + if let standardInput { + process.standardInput = standardInput + } + try cliRunProcess(process) + process.waitUntilExit() + if process.terminationReason == .uncaughtSignal { + return 128 + process.terminationStatus + } + return process.terminationStatus + } + + // MARK: - Exec + + /// Replace this process with the CodeRouter CLI so stdin/stdout/stderr, + /// signals, and the child exit status retain their normal terminal + /// semantics. The argv is built directly; arguments such as prompts, + /// paths, and shell metacharacters are never interpreted by a shell. + private func execCoderouter( + at executablePath: String, + commandArgs: [String], + environment: [String: String] + ) throws { + let childEnvironment = Self.coderouterChildEnvironment(from: environment) + var argv = ([executablePath] + commandArgs).map { strdup($0) } + let environmentStrings = childEnvironment.keys.sorted().map { key in + "\(key)=\(childEnvironment[key] ?? "")" + } + var environmentPointers = environmentStrings.map { strdup($0) } + defer { + for item in argv { + free(item) + } + for item in environmentPointers { + free(item) + } + } + argv.append(nil) + environmentPointers.append(nil) + + let executionError = cliExecFailureErrno { + executablePath.withCString { executable in + _ = execve(executable, &argv, &environmentPointers) + } + } + let errorText = String(cString: strerror(executionError)) + cliDebugLog( + "cli.coderouter.exec_failed executable=\(executablePath) " + + "errno=\(executionError) error=\(errorText)" + ) + throw Self.coderouterUnavailable(Self.localizedPassthroughString( + "cli.coderouter.error.launchFailed", + defaultValue: "Could not start the required CLI. Check the installation and try again." + )) + } + + // MARK: - Presentation + + /// Every way of not running CodeRouter exits 127, the "command not found" + /// status scripts already handle; the reason is on stderr. + private static func coderouterUnavailable(_ message: String) -> CLIError { + CLIError(message: message, exitCode: 127) + } + + /// An explicit `AppleLanguages` override wins, then the app bundle's + /// catalog in the user's language, then the English default. + private static func localizedPassthroughString(_ key: String, defaultValue: String) -> String { + CMUXDiffViewerLocalization.string(key, defaultValue: defaultValue) + } +} diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 2da4b4ed0794..9c409ec85663 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -4939,11 +4939,11 @@ struct CMUXCLI { } private func localizedCoderouterAliases() -> String { - let defaultValue = "coderouter|cr [coderouter-args...] (aliases for the installed CodeRouter CLI)" + let defaultValue = "coderouter|cr [coderouter-args...] (aliases for the CodeRouter CLI; offers to install it when missing)" let bundle = CLIExecutableLocator.enclosingAppBundle() ?? .main let catalogValue = String( localized: "cli.coderouter.aliases", - defaultValue: "coderouter|cr [coderouter-args...] (aliases for the installed CodeRouter CLI)", + defaultValue: "coderouter|cr [coderouter-args...] (aliases for the CodeRouter CLI; offers to install it when missing)", bundle: bundle ) let explicitValue = CMUXDiffViewerLocalization.string( @@ -4968,93 +4968,6 @@ struct CMUXCLI { return explicitValue == defaultValue ? catalogValue : explicitValue } - private func localizedCoderouterNotFound() -> String { - let defaultValue = "Required CLI not found. Install the command and retry." - let bundle = CLIExecutableLocator.enclosingAppBundle() ?? .main - let catalogValue = String( - localized: "cli.coderouter.error.notFound", - defaultValue: "Required CLI not found. Install the command and retry.", - bundle: bundle - ) - let explicitValue = CMUXDiffViewerLocalization.string( - "cli.coderouter.error.notFound", - defaultValue: defaultValue - ) - return explicitValue == defaultValue ? catalogValue : explicitValue - } - - private func localizedCoderouterLaunchFailed() -> String { - let defaultValue = "Could not start the required CLI. Check the installation and try again." - let bundle = CLIExecutableLocator.enclosingAppBundle() ?? .main - let catalogValue = String( - localized: "cli.coderouter.error.launchFailed", - defaultValue: "Could not start the required CLI. Check the installation and try again.", - bundle: bundle - ) - let explicitValue = CMUXDiffViewerLocalization.string( - "cli.coderouter.error.launchFailed", - defaultValue: defaultValue - ) - return explicitValue == defaultValue ? catalogValue : explicitValue - } - - /// Run the separately installed CodeRouter CLI without routing through the - /// cmux socket. Replace this process after resolving the executable so - /// stdin/stdout/stderr, signals, and the child exit status retain their - /// normal terminal semantics. The argv is built directly; arguments such - /// as prompts, paths, and shell metacharacters are never interpreted by a - /// shell. - private func runCoderouterAlias(commandArgs: [String]) throws { - let candidates = ["coderouter", "cr"] - guard let executablePath = candidates.lazy - .compactMap({ resolveExecutableInPath($0) }) - .first else { - throw CLIError( - message: localizedCoderouterNotFound(), - exitCode: 127 - ) - } - - // CodeRouter is an independent executable. Do not hand it cmux's ambient - // terminal/control-plane context: CMUX_* and CMUXD_* may carry socket - // paths, capabilities, passwords, auth state, or internal paths. There is - // intentionally no auth handoff here; a future handoff must be explicit - // and narrowly allowlisted. - let childEnvironment = ProcessInfo.processInfo.environment.filter { key, _ in - !key.hasPrefix("CMUX_") && !key.hasPrefix("CMUXD_") - } - var argv = ([executablePath] + commandArgs).map { strdup($0) } - let environmentStrings = childEnvironment.keys.sorted().map { key in - "\(key)=\(childEnvironment[key] ?? "")" - } - var environment = environmentStrings.map { strdup($0) } - defer { - for item in argv { - free(item) - } - for item in environment { - free(item) - } - } - argv.append(nil) - environment.append(nil) - - let executionError = cliExecFailureErrno { - executablePath.withCString { executable in - _ = execve(executable, &argv, &environment) - } - } - let errorText = String(cString: strerror(executionError)) - cliDebugLog( - "cli.coderouter.exec_failed executable=\(executablePath) " - + "errno=\(executionError) error=\(errorText)" - ) - throw CLIError( - message: localizedCoderouterLaunchFailed(), - exitCode: 127 - ) - } - func run() async throws { let processEnv = ProcessInfo.processInfo.environment let cliBundleIdentifier = CLISocketPathResolver.currentAppBundleIdentifier() @@ -5128,10 +5041,11 @@ struct CMUXCLI { let status = try CodexTeamsAppServerSupervisor(arguments: rawCommandArgs).run() exit(status) } - // `cmux cr ...` is always the installed CodeRouter CLI. `cmux coderouter` - // keeps that passthrough except for the verbs cmux owns (`status`, - // `machines`, `claude`, help), which manage the team's model plane - // through the app socket (CMUXCLI+Coderouter.swift). + // `cmux cr ...` is always the CodeRouter CLI, bootstrapped on first use + // when this machine has none (CMUXCLI+CoderouterPassthrough.swift). + // `cmux coderouter` keeps that passthrough except for the verbs cmux + // owns (`status`, `machines`, `claude`, help), which manage the team's + // model plane through the app socket (CMUXCLI+Coderouter.swift). if command == "cr" || (command == "coderouter" && !Self.isCmuxOwnedCoderouterInvocation(rawCommandArgs)) { try runCoderouterAlias(commandArgs: rawCommandArgs) return @@ -16222,7 +16136,7 @@ struct CMUXCLI { SSHAgentSocketResolver().optionValue(named: key, in: options) } - private func cliDebugLog(_ message: @autoclosure () -> String) { + func cliDebugLog(_ message: @autoclosure () -> String) { #if DEBUG let trimmedExplicit = ProcessInfo.processInfo.environment["CMUX_DEBUG_LOG"]? .trimmingCharacters(in: .whitespacesAndNewlines) @@ -25665,7 +25579,7 @@ struct CMUXCLI { private static let legacyOmoPluginName = "oh-my-opencode" private static let openCodeSessionPluginConfigSpec = "./plugins/cmux-session.js" - private func resolveExecutableInPath(_ name: String, searchPath: String? = nil) -> String? { + func resolveExecutableInPath(_ name: String, searchPath: String? = nil) -> String? { let entries = (searchPath ?? ProcessInfo.processInfo.environment["PATH"])? .split(separator: ":") .map(String.init) ?? [] diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 93a49ada11b5..2f33617c5e80 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -42125,497 +42125,508 @@ "localizations": { "ar": { "stringUnit": { - "state": "translated", + "state": "needs_review", "value": "coderouter|cr [coderouter-args...] (بدائل لـ CodeRouter CLI المثبّت)" } }, "bs": { "stringUnit": { - "state": "translated", + "state": "needs_review", "value": "coderouter|cr [coderouter-args...] (aliasi za instalirani CodeRouter CLI)" } }, "da": { "stringUnit": { - "state": "translated", + "state": "needs_review", "value": "coderouter|cr [coderouter-args...] (aliasser for den installerede CodeRouter CLI)" } }, "de": { "stringUnit": { - "state": "translated", + "state": "needs_review", "value": "coderouter|cr [coderouter-args...] (Aliase für die installierte CodeRouter-CLI)" } }, "en": { "stringUnit": { "state": "translated", - "value": "coderouter|cr [coderouter-args...] (aliases for the installed CodeRouter CLI)" + "value": "coderouter|cr [coderouter-args...] (aliases for the CodeRouter CLI; offers to install it when missing)" } }, "es": { "stringUnit": { - "state": "translated", + "state": "needs_review", "value": "coderouter|cr [coderouter-args...] (alias para la CLI de CodeRouter instalada)" } }, "fr": { "stringUnit": { - "state": "translated", + "state": "needs_review", "value": "coderouter|cr [coderouter-args...] (alias de la CLI CodeRouter installée)" } }, "it": { "stringUnit": { - "state": "translated", + "state": "needs_review", "value": "coderouter|cr [coderouter-args...] (alias per la CLI CodeRouter installata)" } }, "ja": { "stringUnit": { "state": "translated", - "value": "coderouter|cr [coderouter-args...] (インストール済み CodeRouter CLI のエイリアス)" + "value": "coderouter|cr [coderouter-args...] (CodeRouter CLI のエイリアス。未インストール時はインストールを提案)" } }, "km": { "stringUnit": { - "state": "translated", + "state": "needs_review", "value": "coderouter|cr [coderouter-args...] (ឈ្មោះកាត់សម្រាប់ CodeRouter CLI ដែលបានដំឡើង)" } }, "ko": { "stringUnit": { - "state": "translated", + "state": "needs_review", "value": "coderouter|cr [coderouter-args...] (설치된 CodeRouter CLI의 별칭)" } }, "nb": { "stringUnit": { - "state": "translated", + "state": "needs_review", "value": "coderouter|cr [coderouter-args...] (aliaser for den installerte CodeRouter CLI)" } }, "pl": { "stringUnit": { - "state": "translated", + "state": "needs_review", "value": "coderouter|cr [coderouter-args...] (aliasy zainstalowanego CodeRouter CLI)" } }, "pt-BR": { "stringUnit": { - "state": "translated", + "state": "needs_review", "value": "coderouter|cr [coderouter-args...] (aliases para a CLI do CodeRouter instalada)" } }, "ru": { "stringUnit": { - "state": "translated", + "state": "needs_review", "value": "coderouter|cr [coderouter-args...] (псевдонимы для установленного CodeRouter CLI)" } }, "th": { "stringUnit": { - "state": "translated", + "state": "needs_review", "value": "coderouter|cr [coderouter-args...] (ชื่อเรียกแทนสำหรับ CodeRouter CLI ที่ติดตั้งไว้)" } }, "tr": { "stringUnit": { - "state": "translated", + "state": "needs_review", "value": "coderouter|cr [coderouter-args...] (yüklü CodeRouter CLI için takma adlar)" } }, "uk": { "stringUnit": { - "state": "translated", + "state": "needs_review", "value": "coderouter|cr [coderouter-args...] (псевдоніми для встановленого CodeRouter CLI)" } }, "zh-Hans": { "stringUnit": { - "state": "translated", + "state": "needs_review", "value": "coderouter|cr [coderouter-args...] (已安装 CodeRouter CLI 的别名)" } }, "zh-Hant": { "stringUnit": { - "state": "translated", + "state": "needs_review", "value": "coderouter|cr [coderouter-args...] (已安裝 CodeRouter CLI 的別名)" } } } }, - "cli.coderouter.commands": { + "cli.coderouter.bootstrap.confirm": { "extractionState": "manual", "localizations": { - "ar": { - "stringUnit": { - "state": "translated", - "value": "coderouter [--team ] [--json] (إعدادات مستوى النماذج للفريق؛ تُمرَّر الأوامر الأخرى كما هي)" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "coderouter [--team ] [--json] (postavke modelne ravni tima; ostale komande se prosljeđuju)" - } - }, - "da": { + "en": { "stringUnit": { "state": "translated", - "value": "coderouter [--team ] [--json] (teamets modelplan-indstillinger; andre kommandoer sendes videre)" + "value": "Install CodeRouter now? [y/N] " } }, - "de": { + "ja": { "stringUnit": { "state": "translated", - "value": "coderouter [--team ] [--json] (Team-Einstellungen der Modellebene; andere Befehle werden durchgereicht)" + "value": "今すぐ CodeRouter をインストールしますか? [y/N] " } - }, + } + } + }, + "cli.coderouter.bootstrap.declined": { + "extractionState": "manual", + "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "coderouter [--team ] [--json] (team model-plane settings; other verbs pass through)" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "coderouter [--team ] [--json] (ajustes del plano de modelos del equipo; los demás verbos se pasan sin cambios)" + "value": "CodeRouter was not installed. Install it later with:\n %@" } }, - "fr": { + "ja": { "stringUnit": { "state": "translated", - "value": "coderouter [--team ] [--json] (réglages du plan de modèles de l'équipe ; les autres verbes sont transmis tels quels)" + "value": "CodeRouter はインストールされませんでした。後で次のコマンドでインストールできます:\n %@" } - }, - "it": { + } + } + }, + "cli.coderouter.bootstrap.downloadFailed": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "coderouter [--team ] [--json] (impostazioni del piano modelli del team; gli altri comandi passano inalterati)" + "value": "Could not download the CodeRouter installer (curl exited with status %1$d). Retry, or install it with:\n %2$@" } }, "ja": { "stringUnit": { "state": "translated", - "value": "coderouter [--team ] [--json] (チームのモデルプレーン設定。その他のサブコマンドはそのまま渡されます)" + "value": "CodeRouter インストーラーをダウンロードできませんでした (curl の終了ステータス: %1$d)。もう一度お試しいただくか、次のコマンドでインストールしてください:\n %2$@" } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "coderouter [--team ] [--json] (ការកំណត់ model plane របស់ក្រុម; ពាក្យបញ្ជាផ្សេងទៀតត្រូវបានបញ្ជូនបន្ត)" - } - }, - "ko": { + } + } + }, + "cli.coderouter.bootstrap.installedNotFound": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "coderouter [--team ] [--json] (팀 모델 플레인 설정; 다른 하위 명령은 그대로 전달됩니다)" + "value": "The CodeRouter installer finished, but no CodeRouter CLI was found in %@. Add its bin directory to PATH and retry." } }, - "nb": { + "ja": { "stringUnit": { "state": "translated", - "value": "coderouter [--team ] [--json] (teamets modellplan-innstillinger; andre kommandoer sendes videre)" + "value": "CodeRouter インストーラーは完了しましたが、%@ に CodeRouter CLI が見つかりませんでした。その bin ディレクトリを PATH に追加して、もう一度お試しください。" } - }, - "pl": { + } + } + }, + "cli.coderouter.bootstrap.installerFailed": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "coderouter [--team ] [--json] (ustawienia płaszczyzny modeli zespołu; pozostałe polecenia są przekazywane dalej)" + "value": "The CodeRouter installer exited with status %1$d. Retry, or install it with:\n %2$@" } }, - "pt-BR": { + "ja": { "stringUnit": { "state": "translated", - "value": "coderouter [--team ] [--json] (configurações do plano de modelos da equipe; outros verbos são repassados)" + "value": "CodeRouter インストーラーが終了ステータス %1$d で終了しました。もう一度お試しいただくか、次のコマンドでインストールしてください:\n %2$@" } - }, - "ru": { + } + } + }, + "cli.coderouter.bootstrap.installing": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "coderouter [--team ] [--json] (настройки модельного плана команды; остальные команды передаются как есть)" + "value": "Installing CodeRouter..." } }, - "th": { + "ja": { "stringUnit": { "state": "translated", - "value": "coderouter [--team ] [--json] (การตั้งค่า model plane ของทีม; คำสั่งอื่นส่งผ่านตามเดิม)" + "value": "CodeRouter をインストールしています..." } - }, - "tr": { + } + } + }, + "cli.coderouter.bootstrap.notInstalled": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "coderouter [--team ] [--json] (ekibin model düzlemi ayarları; diğer komutlar olduğu gibi iletilir)" + "value": "CodeRouter CLI is not installed. Install it, then retry:\n %@" } }, - "uk": { + "ja": { "stringUnit": { "state": "translated", - "value": "coderouter [--team ] [--json] (налаштування модельного плану команди; інші команди передаються без змін)" + "value": "CodeRouter CLI がインストールされていません。次のコマンドでインストールしてから、もう一度お試しください:\n %@" } - }, - "zh-Hans": { + } + } + }, + "cli.coderouter.bootstrap.offer": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "coderouter [--team ] [--json] (团队模型平面设置;其他子命令原样透传)" + "value": "CodeRouter CLI is not installed. cmux can install it now by running the official installer:\n %1$@\nThis downloads the checksum-verified CodeRouter binary into %2$@ and adds that directory to your shell PATH." } }, - "zh-Hant": { + "ja": { "stringUnit": { "state": "translated", - "value": "coderouter [--team ] [--json] (團隊模型平面設定;其他子命令原樣透傳)" + "value": "CodeRouter CLI がインストールされていません。cmux は公式インストーラーを実行して今すぐインストールできます:\n %1$@\nチェックサムを検証した CodeRouter バイナリを %2$@ にダウンロードし、そのディレクトリをシェルの PATH に追加します。" } } } }, - "cli.coderouter.error.launchFailed": { + "cli.coderouter.commands": { "extractionState": "manual", "localizations": { "ar": { "stringUnit": { "state": "translated", - "value": "تعذر تشغيل أداة سطر الأوامر المطلوبة. تحقّق من التثبيت ثم أعد المحاولة." + "value": "coderouter [--team ] [--json] (إعدادات مستوى النماذج للفريق؛ تُمرَّر الأوامر الأخرى كما هي)" } }, "bs": { "stringUnit": { "state": "translated", - "value": "Traženi CLI nije moguće pokrenuti. Provjerite instalaciju i pokušajte ponovo." + "value": "coderouter [--team ] [--json] (postavke modelne ravni tima; ostale komande se prosljeđuju)" } }, "da": { "stringUnit": { "state": "translated", - "value": "Den nødvendige CLI kunne ikke startes. Kontrollér installationen, og prøv igen." + "value": "coderouter [--team ] [--json] (teamets modelplan-indstillinger; andre kommandoer sendes videre)" } }, "de": { "stringUnit": { "state": "translated", - "value": "Die erforderliche CLI konnte nicht gestartet werden. Überprüfen Sie die Installation und versuchen Sie es erneut." + "value": "coderouter [--team ] [--json] (Team-Einstellungen der Modellebene; andere Befehle werden durchgereicht)" } }, "en": { "stringUnit": { "state": "translated", - "value": "Could not start the required CLI. Check the installation and try again." + "value": "coderouter [--team ] [--json] (team model-plane settings; other verbs pass through)" } }, "es": { "stringUnit": { "state": "translated", - "value": "No se pudo iniciar la CLI necesaria. Comprueba la instalación y vuelve a intentarlo." + "value": "coderouter [--team ] [--json] (ajustes del plano de modelos del equipo; los demás verbos se pasan sin cambios)" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Impossible de démarrer la CLI requise. Vérifiez l’installation, puis réessayez." + "value": "coderouter [--team ] [--json] (réglages du plan de modèles de l'équipe ; les autres verbes sont transmis tels quels)" } }, "it": { "stringUnit": { "state": "translated", - "value": "Impossibile avviare la CLI richiesta. Verifica l'installazione e riprova." + "value": "coderouter [--team ] [--json] (impostazioni del piano modelli del team; gli altri comandi passano inalterati)" } }, "ja": { "stringUnit": { "state": "translated", - "value": "必要な CLI を起動できませんでした。インストールを確認して、もう一度お試しください。" + "value": "coderouter [--team ] [--json] (チームのモデルプレーン設定。その他のサブコマンドはそのまま渡されます)" } }, "km": { "stringUnit": { "state": "translated", - "value": "មិនអាចចាប់ផ្ដើម CLI ដែលត្រូវការ។ សូមពិនិត្យការដំឡើង ហើយព្យាយាមម្ដងទៀត។" + "value": "coderouter [--team ] [--json] (ការកំណត់ model plane របស់ក្រុម; ពាក្យបញ្ជាផ្សេងទៀតត្រូវបានបញ្ជូនបន្ត)" } }, "ko": { "stringUnit": { "state": "translated", - "value": "필요한 CLI를 시작할 수 없습니다. 설치를 확인한 후 다시 시도하세요." + "value": "coderouter [--team ] [--json] (팀 모델 플레인 설정; 다른 하위 명령은 그대로 전달됩니다)" } }, "nb": { "stringUnit": { "state": "translated", - "value": "Kunne ikke starte den nødvendige CLI-en. Kontroller installasjonen og prøv igjen." + "value": "coderouter [--team ] [--json] (teamets modellplan-innstillinger; andre kommandoer sendes videre)" } }, "pl": { "stringUnit": { "state": "translated", - "value": "Nie można uruchomić wymaganej biblioteki CLI. Sprawdź instalację i spróbuj ponownie." + "value": "coderouter [--team ] [--json] (ustawienia płaszczyzny modeli zespołu; pozostałe polecenia są przekazywane dalej)" } }, "pt-BR": { "stringUnit": { "state": "translated", - "value": "Não foi possível iniciar a CLI necessária. Verifique a instalação e tente novamente." + "value": "coderouter [--team ] [--json] (configurações do plano de modelos da equipe; outros verbos são repassados)" } }, "ru": { "stringUnit": { "state": "translated", - "value": "Не удалось запустить необходимый CLI. Проверьте установку и повторите попытку." + "value": "coderouter [--team ] [--json] (настройки модельного плана команды; остальные команды передаются как есть)" } }, "th": { "stringUnit": { "state": "translated", - "value": "ไม่สามารถเริ่ม CLI ที่จำเป็นได้ โปรดตรวจสอบการติดตั้งแล้วลองอีกครั้ง" + "value": "coderouter [--team ] [--json] (การตั้งค่า model plane ของทีม; คำสั่งอื่นส่งผ่านตามเดิม)" } }, "tr": { "stringUnit": { "state": "translated", - "value": "Gerekli CLI başlatılamadı. Kurulumu kontrol edip tekrar deneyin." + "value": "coderouter [--team ] [--json] (ekibin model düzlemi ayarları; diğer komutlar olduğu gibi iletilir)" } }, "uk": { "stringUnit": { "state": "translated", - "value": "Не вдалося запустити потрібний CLI. Перевірте встановлення та повторіть спробу." + "value": "coderouter [--team ] [--json] (налаштування модельного плану команди; інші команди передаються без змін)" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "无法启动所需的 CLI。请检查安装并重试。" + "value": "coderouter [--team ] [--json] (团队模型平面设置;其他子命令原样透传)" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "無法啟動必要的 CLI。請檢查安裝後再試一次。" + "value": "coderouter [--team ] [--json] (團隊模型平面設定;其他子命令原樣透傳)" } } } }, - "cli.coderouter.error.notFound": { + "cli.coderouter.error.launchFailed": { "extractionState": "manual", "localizations": { "ar": { "stringUnit": { "state": "translated", - "value": "تعذر العثور على أداة سطر الأوامر المطلوبة. ثبّت الأمر ثم أعد المحاولة." + "value": "تعذر تشغيل أداة سطر الأوامر المطلوبة. تحقّق من التثبيت ثم أعد المحاولة." } }, "bs": { "stringUnit": { "state": "translated", - "value": "Traženi CLI nije pronađen. Instalirajte naredbu i pokušajte ponovo." + "value": "Traženi CLI nije moguće pokrenuti. Provjerite instalaciju i pokušajte ponovo." } }, "da": { "stringUnit": { "state": "translated", - "value": "Den nødvendige CLI blev ikke fundet. Installer kommandoen, og prøv igen." + "value": "Den nødvendige CLI kunne ikke startes. Kontrollér installationen, og prøv igen." } }, "de": { "stringUnit": { "state": "translated", - "value": "Die erforderliche CLI wurde nicht gefunden. Installieren Sie den Befehl und versuchen Sie es erneut." + "value": "Die erforderliche CLI konnte nicht gestartet werden. Überprüfen Sie die Installation und versuchen Sie es erneut." } }, "en": { "stringUnit": { "state": "translated", - "value": "Required CLI not found. Install the command and retry." + "value": "Could not start the required CLI. Check the installation and try again." } }, "es": { "stringUnit": { "state": "translated", - "value": "No se encontró la CLI necesaria. Instala el comando y vuelve a intentarlo." + "value": "No se pudo iniciar la CLI necesaria. Comprueba la instalación y vuelve a intentarlo." } }, "fr": { "stringUnit": { "state": "translated", - "value": "La CLI requise est introuvable. Installez la commande, puis réessayez." + "value": "Impossible de démarrer la CLI requise. Vérifiez l’installation, puis réessayez." } }, "it": { "stringUnit": { "state": "translated", - "value": "La CLI richiesta non è stata trovata. Installa il comando e riprova." + "value": "Impossibile avviare la CLI richiesta. Verifica l'installazione e riprova." } }, "ja": { "stringUnit": { "state": "translated", - "value": "必要な CLI が見つかりません。コマンドをインストールして、もう一度お試しください。" + "value": "必要な CLI を起動できませんでした。インストールを確認して、もう一度お試しください。" } }, "km": { "stringUnit": { "state": "translated", - "value": "រកមិនឃើញ CLI ដែលត្រូវការ។ សូមដំឡើងពាក្យបញ្ជា ហើយព្យាយាមម្ដងទៀត។" + "value": "មិនអាចចាប់ផ្ដើម CLI ដែលត្រូវការ។ សូមពិនិត្យការដំឡើង ហើយព្យាយាមម្ដងទៀត។" } }, "ko": { "stringUnit": { "state": "translated", - "value": "필요한 CLI를 찾을 수 없습니다. 명령을 설치한 후 다시 시도하세요." + "value": "필요한 CLI를 시작할 수 없습니다. 설치를 확인한 후 다시 시도하세요." } }, "nb": { "stringUnit": { "state": "translated", - "value": "Fant ikke den nødvendige CLI-en. Installer kommandoen og prøv igjen." + "value": "Kunne ikke starte den nødvendige CLI-en. Kontroller installasjonen og prøv igjen." } }, "pl": { "stringUnit": { "state": "translated", - "value": "Nie znaleziono wymaganej CLI. Zainstaluj polecenie i spróbuj ponownie." + "value": "Nie można uruchomić wymaganej biblioteki CLI. Sprawdź instalację i spróbuj ponownie." } }, "pt-BR": { "stringUnit": { "state": "translated", - "value": "A CLI necessária não foi encontrada. Instale o comando e tente novamente." + "value": "Não foi possível iniciar a CLI necessária. Verifique a instalação e tente novamente." } }, "ru": { "stringUnit": { "state": "translated", - "value": "Не удалось найти необходимый CLI. Установите команду и повторите попытку." + "value": "Не удалось запустить необходимый CLI. Проверьте установку и повторите попытку." } }, "th": { "stringUnit": { "state": "translated", - "value": "ไม่พบ CLI ที่จำเป็น โปรดติดตั้งคำสั่งแล้วลองอีกครั้ง" + "value": "ไม่สามารถเริ่ม CLI ที่จำเป็นได้ โปรดตรวจสอบการติดตั้งแล้วลองอีกครั้ง" } }, "tr": { "stringUnit": { "state": "translated", - "value": "Gerekli CLI bulunamadı. Komutu yükleyip tekrar deneyin." + "value": "Gerekli CLI başlatılamadı. Kurulumu kontrol edip tekrar deneyin." } }, "uk": { "stringUnit": { "state": "translated", - "value": "Не знайдено потрібний CLI. Установіть команду та повторіть спробу." + "value": "Не вдалося запустити потрібний CLI. Перевірте встановлення та повторіть спробу." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "未找到所需的 CLI。请安装该命令后重试。" + "value": "无法启动所需的 CLI。请检查安装并重试。" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "找不到必要的 CLI。請安裝該命令後再試一次。" + "value": "無法啟動必要的 CLI。請檢查安裝後再試一次。" } } } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 3f865c4b8eb9..85bf0658965e 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -595,6 +595,7 @@ A5D4120DA1B2C3D4E5F60718 /* CLIAuthAliasTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D4120EA1B2C3D4E5F60718 /* CLIAuthAliasTests.swift */; }; 805500000000000000000001 /* CLIBrowserEvalOutputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 805500000000000000000002 /* CLIBrowserEvalOutputTests.swift */; }; CA11E4D0FA017DE500000B101 /* CLICallerWorkspaceDefaultTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA11E4D0FA017DE500000F102 /* CLICallerWorkspaceDefaultTests.swift */; }; + C0DE60C0000000000000A036 /* CLICoderouterBootstrapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE60C0000000000000A035 /* CLICoderouterBootstrapTests.swift */; }; C0DE60C0000000000000A032 /* CLICoderouterCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE60C0000000000000A031 /* CLICoderouterCommandTests.swift */; }; C0D3F1F00000000000000103 /* CLICodexHookTimeoutRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0D3F1F00000000000000104 /* CLICodexHookTimeoutRegressionTests.swift */; }; C0D3F1F10000000000000103 /* CLICodexHookTimeoutRegressionTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0D3F1F10000000000000104 /* CLICodexHookTimeoutRegressionTestSupport.swift */; }; @@ -816,6 +817,7 @@ 2DA8D48D99FB520EB179B015 /* CMUXCLI+ClaudePushNotificationHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF59318F1D5B2195AC77C28 /* CMUXCLI+ClaudePushNotificationHook.swift */; }; C10D0A100000000000000001 /* CMUXCLI+CloudDomains.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10D0A100000000000000002 /* CMUXCLI+CloudDomains.swift */; }; C0DE60C0000000000000A022 /* CMUXCLI+Coderouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE60C0000000000000A021 /* CMUXCLI+Coderouter.swift */; }; + C0DE60C0000000000000A034 /* CMUXCLI+CoderouterPassthrough.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE60C0000000000000A033 /* CMUXCLI+CoderouterPassthrough.swift */; }; C0D3F1F00000000000000101 /* CMUXCLI+CodexFireAndForgetHooks.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0D3F1F00000000000000102 /* CMUXCLI+CodexFireAndForgetHooks.swift */; }; D96290030000000000000001 /* CMUXCLI+CodexResumeBindingVerification.swift in Sources */ = {isa = PBXBuildFile; fileRef = D96290040000000000000001 /* CMUXCLI+CodexResumeBindingVerification.swift */; }; B90000D2A1B2C3D4E5F60719 /* CMUXCLI+CommandSuggestions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B90000D1A1B2C3D4E5F60719 /* CMUXCLI+CommandSuggestions.swift */; }; @@ -3996,6 +3998,7 @@ A5D4120EA1B2C3D4E5F60718 /* CLIAuthAliasTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIAuthAliasTests.swift; sourceTree = ""; }; 805500000000000000000002 /* CLIBrowserEvalOutputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIBrowserEvalOutputTests.swift; sourceTree = ""; }; CA11E4D0FA017DE500000F102 /* CLICallerWorkspaceDefaultTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLICallerWorkspaceDefaultTests.swift; sourceTree = ""; }; + C0DE60C0000000000000A035 /* CLICoderouterBootstrapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLICoderouterBootstrapTests.swift; sourceTree = ""; }; C0DE60C0000000000000A031 /* CLICoderouterCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLICoderouterCommandTests.swift; sourceTree = ""; }; C0D3F1F00000000000000104 /* CLICodexHookTimeoutRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLICodexHookTimeoutRegressionTests.swift; sourceTree = ""; }; C0D3F1F10000000000000104 /* CLICodexHookTimeoutRegressionTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLICodexHookTimeoutRegressionTestSupport.swift; sourceTree = ""; }; @@ -4195,6 +4198,7 @@ 4CF59318F1D5B2195AC77C28 /* CMUXCLI+ClaudePushNotificationHook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+ClaudePushNotificationHook.swift"; sourceTree = ""; }; C10D0A100000000000000002 /* CMUXCLI+CloudDomains.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+CloudDomains.swift"; sourceTree = ""; }; C0DE60C0000000000000A021 /* CMUXCLI+Coderouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+Coderouter.swift"; sourceTree = ""; }; + C0DE60C0000000000000A033 /* CMUXCLI+CoderouterPassthrough.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+CoderouterPassthrough.swift"; sourceTree = ""; }; C0D3F1F00000000000000102 /* CMUXCLI+CodexFireAndForgetHooks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+CodexFireAndForgetHooks.swift"; sourceTree = ""; }; D96290040000000000000001 /* CMUXCLI+CodexResumeBindingVerification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+CodexResumeBindingVerification.swift"; sourceTree = ""; }; B90000D1A1B2C3D4E5F60719 /* CMUXCLI+CommandSuggestions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+CommandSuggestions.swift"; sourceTree = ""; }; @@ -9033,6 +9037,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef C11218000000000000000051 /* CLIError.swift */, REE0CA0000000000000000C1 /* CMUXCLI+Remotes.swift */, C0DE60C0000000000000A021 /* CMUXCLI+Coderouter.swift */, + C0DE60C0000000000000A033 /* CMUXCLI+CoderouterPassthrough.swift */, 1E4DB33FA55F1B13C60EFFC8 /* SSHPTYAttachReconnectInputFilter.swift */, E60610200000000000000001 /* SSHPTYAttachReconnectInputFilterControl.swift */, E60610100000000000000001 /* SSHPTYAttachReconnectInputFilterSequenceMatch.swift */, @@ -9949,6 +9954,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef A5D4120CA1B2C3D4E5F60718 /* CLILegacyHookAliasTests.swift */, A5D4120EA1B2C3D4E5F60718 /* CLIAuthAliasTests.swift */, C0DE60C0000000000000A031 /* CLICoderouterCommandTests.swift */, + C0DE60C0000000000000A035 /* CLICoderouterBootstrapTests.swift */, A5D4120EA1B2C3D4E5F60F02 /* CLIVMTransferTests.swift */, C0DE35530000000000000102 /* BundledCLILinkageTests.swift */, C37800000000000000000004 /* VMDefaultCloudCommandTests.swift */, @@ -12825,6 +12831,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 2DA8D48D99FB520EB179B015 /* CMUXCLI+ClaudePushNotificationHook.swift in Sources */, C10D0A100000000000000001 /* CMUXCLI+CloudDomains.swift in Sources */, C0DE60C0000000000000A022 /* CMUXCLI+Coderouter.swift in Sources */, + C0DE60C0000000000000A034 /* CMUXCLI+CoderouterPassthrough.swift in Sources */, C0D3F1F00000000000000101 /* CMUXCLI+CodexFireAndForgetHooks.swift in Sources */, D96290030000000000000001 /* CMUXCLI+CodexResumeBindingVerification.swift in Sources */, B90000D2A1B2C3D4E5F60719 /* CMUXCLI+CommandSuggestions.swift in Sources */, @@ -13184,6 +13191,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef A5D4120DA1B2C3D4E5F60718 /* CLIAuthAliasTests.swift in Sources */, 805500000000000000000001 /* CLIBrowserEvalOutputTests.swift in Sources */, CA11E4D0FA017DE500000B101 /* CLICallerWorkspaceDefaultTests.swift in Sources */, + C0DE60C0000000000000A036 /* CLICoderouterBootstrapTests.swift in Sources */, C0DE60C0000000000000A032 /* CLICoderouterCommandTests.swift in Sources */, C0D3F1F00000000000000103 /* CLICodexHookTimeoutRegressionTests.swift in Sources */, C0D3F1F10000000000000103 /* CLICodexHookTimeoutRegressionTestSupport.swift in Sources */, diff --git a/cmuxTests/CLIAuthAliasTests.swift b/cmuxTests/CLIAuthAliasTests.swift index 58386deabbea..bdc4b8f67c24 100644 --- a/cmuxTests/CLIAuthAliasTests.swift +++ b/cmuxTests/CLIAuthAliasTests.swift @@ -319,11 +319,11 @@ struct CLICoderouterAliasTests { #expect(!result.timedOut, Comment(rawValue: result.stderr)) #expect(result.status == 0, Comment(rawValue: result.stderr)) #expect( - result.stdout.contains("インストール済み CodeRouter CLI のエイリアス"), + result.stdout.contains("CodeRouter CLI のエイリアス。未インストール時はインストールを提案"), Comment(rawValue: result.stdout) ) #expect( - !result.stdout.contains("aliases for the installed CodeRouter CLI"), + !result.stdout.contains("aliases for the CodeRouter CLI"), Comment(rawValue: result.stdout) ) } @@ -427,44 +427,6 @@ struct CLICoderouterAliasTests { #endif } - @Test("reports an actionable error when neither executable exists") - func missingExecutableIsActionable() throws { - let cliPath = try BundledCLITestSupport.bundledCLIPath( - for: BundledCLILinkageTests.self - ) - let fileManager = FileManager.default - let root = fileManager.temporaryDirectory - .appendingPathComponent("cmux-coderouter-missing-\(UUID().uuidString)", isDirectory: true) - let socketPath = makeSocketPath("missing") - defer { try? fileManager.removeItem(at: root) } - try fileManager.createDirectory(at: root, withIntermediateDirectories: true) - - let result = runCLI( - cliPath: cliPath, - arguments: ["coderouter", "login"], - environment: [ - "PATH": root.path, - "CMUX_SOCKET_PATH": socketPath, - "CMUX_SOCKET_CAPABILITY": "missing-capability", - "CMUX_SOCKET_PASSWORD": "missing-password", - "CMUX_CLI_SENTRY_DISABLED": "1", - ] - ) - - #expect(!result.timedOut) - #expect(result.status == 127, Comment(rawValue: result.stderr)) - #expect(result.stdout.isEmpty) - #expect(result.stderr.contains("Required CLI not found")) - #expect(result.stderr.contains("Install the command")) - #expect(!result.stderr.contains("CodeRouter")) - #expect(!result.stderr.contains("coderouter")) - #expect(!result.stderr.contains("PATH")) - #expect(!result.stderr.contains(root.path)) - #expect(!result.stderr.contains(socketPath)) - #expect(!result.stderr.contains("missing-capability")) - #expect(!result.stderr.contains("missing-password")) - } - private func runCLI( cliPath: String, arguments: [String], diff --git a/cmuxTests/CLICoderouterBootstrapTests.swift b/cmuxTests/CLICoderouterBootstrapTests.swift new file mode 100644 index 000000000000..018c36ccb9d3 --- /dev/null +++ b/cmuxTests/CLICoderouterBootstrapTests.swift @@ -0,0 +1,431 @@ +import Darwin +import Foundation +import Testing + +/// `cmux cr` on a machine that has no CodeRouter CLI +/// (https://github.com/manaflow-ai/cmux/issues/12139). +/// +/// Every test isolates HOME and PATH so the developer's own CodeRouter install, +/// `~/.coderouter`, and `~/.cmux` never take part, and none of them reaches the +/// network: the interactive bootstrap runs a local stand-in for install.sh +/// through the Debug-only `CMUX_CODEROUTER_INSTALLER_SCRIPT` seam. The stand-in +/// honors the real installer's contract (a `coderouter` executable in +/// `$HOME/.coderouter/bin`) so the re-resolve and exec after installing are the +/// production code path. +@Suite("CodeRouter CLI bootstrap") +struct CLICoderouterBootstrapTests { + static let installCommand = "curl -fsSL https://cmux.com/coderouter/install.sh | sh" + static let confirmPrompt = "Install CodeRouter now? [y/N]" + + enum InstallRoot: CustomStringConvertible { + case installerDefault + case explicitCoderouterInstall + + var description: String { + switch self { + case .installerDefault: return "~/.coderouter" + case .explicitCoderouterInstall: return "$CODEROUTER_INSTALL" + } + } + } + + @Test("non-interactive: prints the exact install command, exits 127, touches nothing") + func nonInteractiveMissingBinaryPrintsInstallCommand() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + + let result = fixture.runCLI( + arguments: ["cr", "--version"], + extraEnvironment: [ + "CMUX_SOCKET_CAPABILITY": "missing-capability", + "CMUX_SOCKET_PASSWORD": "missing-password", + ] + ) + + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 127, Comment(rawValue: result.stderr)) + #expect(result.stdout.isEmpty) + #expect(result.stderr.contains(Self.installCommand), Comment(rawValue: result.stderr)) + // No terminal to answer on, so no question is asked. + #expect(!result.stderr.contains("[y/N]"), Comment(rawValue: result.stderr)) + #expect(!result.stderr.contains(fixture.root.path)) + #expect(!result.stderr.contains(fixture.socketPath)) + #expect(!result.stderr.contains("missing-capability")) + #expect(!result.stderr.contains("missing-password")) + #expect(!fixture.fileManager.fileExists(atPath: fixture.installerDefaultRoot.path)) + } + + @Test( + "an install the shell PATH does not know yet is still exec'd, with no prompt", + arguments: [InstallRoot.installerDefault, InstallRoot.explicitCoderouterInstall] + ) + func installerLocationIsResolvedWithoutPATH(installRoot: InstallRoot) throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let root: URL + var extraEnvironment: [String: String] = [:] + switch installRoot { + case .installerDefault: + root = fixture.installerDefaultRoot + case .explicitCoderouterInstall: + root = fixture.root.appendingPathComponent("custom-install-root", isDirectory: true) + extraEnvironment["CODEROUTER_INSTALL"] = root.path + } + try fixture.writeExecutable( + """ + #!/bin/sh + printf '<%s>\\n' "$@" > "$HOME/coderouter-args" + printf 'installed coderouter\\n' + exit 41 + """, + at: root.appendingPathComponent("bin/coderouter", isDirectory: false) + ) + + let result = fixture.runCLI(arguments: ["cr", "login", "--device-auth"], extraEnvironment: extraEnvironment) + + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 41, Comment(rawValue: result.stderr)) + #expect(result.stdout == "installed coderouter\n") + #expect(result.stderr.isEmpty, Comment(rawValue: result.stderr)) + #expect(try fixture.readHomeFile("coderouter-args") == "\n<--device-auth>\n") + } + +#if DEBUG + @Test("terminal: offers the documented installer once, runs it after y, then execs the install") + func terminalOfferInstallsAndExecs() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let installerScript = try fixture.writeFakeInstaller() + + let result = try fixture.runCLIUnderPTY( + arguments: ["cr", "--version"], + input: "y\n", + extraEnvironment: ["CMUX_CODEROUTER_INSTALLER_SCRIPT": installerScript.path] + ) + + #expect(!result.timedOut, Comment(rawValue: result.transcript)) + #expect(result.transcript.contains(Self.installCommand), Comment(rawValue: result.transcript)) + #expect(result.transcript.contains(Self.confirmPrompt), Comment(rawValue: result.transcript)) + #expect(fixture.installerRan, Comment(rawValue: result.transcript)) + #expect(result.transcript.contains("fake installer done"), Comment(rawValue: result.transcript)) + // The installed binary receives the original arguments and its exit status is ours. + #expect(try fixture.readHomeFile("coderouter-args") == "<--version>\n") + #expect(result.transcript.contains("installed coderouter ran"), Comment(rawValue: result.transcript)) + #expect(result.status == 41, Comment(rawValue: result.transcript)) + } +#endif + + @Test("terminal: declining installs nothing, prints the install command, exits 127") + func terminalDeclineKeepsMachineUntouched() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let installerScript = try fixture.writeFakeInstaller() + + let result = try fixture.runCLIUnderPTY( + arguments: ["cr", "--version"], + input: "n\n", + extraEnvironment: ["CMUX_CODEROUTER_INSTALLER_SCRIPT": installerScript.path] + ) + + #expect(!result.timedOut, Comment(rawValue: result.transcript)) + #expect(result.status == 127, Comment(rawValue: result.transcript)) + #expect(result.transcript.contains(Self.confirmPrompt), Comment(rawValue: result.transcript)) + #expect(result.transcript.contains(Self.installCommand), Comment(rawValue: result.transcript)) + #expect(!fixture.installerRan, Comment(rawValue: result.transcript)) + #expect(!fixture.fileManager.fileExists(atPath: fixture.installerDefaultRoot.path)) + } + + @Test("an existing install is exec'd unchanged and the installer never runs") + func existingInstallNeverRunsTheInstaller() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let installerScript = try fixture.writeFakeInstaller() + let pathDirectory = fixture.root.appendingPathComponent("path-bin", isDirectory: true) + try fixture.writeExecutable( + """ + #!/bin/sh + printf '<%s>\\n' "$@" > "$HOME/coderouter-args" + printf 'path coderouter\\n' + exit 41 + """, + at: pathDirectory.appendingPathComponent("coderouter", isDirectory: false) + ) + + // A terminal with `y` already typed: were cmux to ask, it would install. + let result = try fixture.runCLIUnderPTY( + arguments: ["cr", "--version"], + input: "y\n", + extraEnvironment: [ + "PATH": pathDirectory.path, + "CMUX_CODEROUTER_INSTALLER_SCRIPT": installerScript.path, + ] + ) + + #expect(!result.timedOut, Comment(rawValue: result.transcript)) + #expect(result.status == 41, Comment(rawValue: result.transcript)) + #expect(result.transcript.contains("path coderouter"), Comment(rawValue: result.transcript)) + #expect(!result.transcript.contains(Self.confirmPrompt), Comment(rawValue: result.transcript)) + #expect(try fixture.readHomeFile("coderouter-args") == "<--version>\n") + #expect(!fixture.installerRan, Comment(rawValue: result.transcript)) + #expect(!fixture.fileManager.fileExists(atPath: fixture.installerDefaultRoot.path)) + } + + // MARK: - Fixture + + struct ProcessResult { + let status: Int32 + let stdout: String + let stderr: String + let timedOut: Bool + } + + struct PTYResult { + let status: Int32 + let transcript: String + let timedOut: Bool + } + + private final class TranscriptBuffer: @unchecked Sendable { + private let lock = NSLock() + private var data = Data() + + func append(_ bytes: [UInt8], count: Int) { + lock.lock() + data.append(contentsOf: bytes[0.. String { + try String(contentsOf: home.appendingPathComponent(name, isDirectory: false), encoding: .utf8) + } + + /// The CLI's environment: the test host's, minus every cmux control value, + /// with HOME and PATH pointed at this fixture and the socket at a path that + /// does not exist, so nothing here can reach a running cmux. + func environment(merging extra: [String: String]) -> [String: String] { + var environment = ProcessInfo.processInfo.environment + for key in environment.keys where key.hasPrefix("CMUX_") || key.hasPrefix("CMUXD_") { + environment.removeValue(forKey: key) + } + environment.removeValue(forKey: "CODEROUTER_INSTALL") + environment["HOME"] = home.path + environment["CFFIXED_USER_HOME"] = home.path + environment["PATH"] = emptyPathDirectory.path + environment["AppleLanguages"] = "(en)" + environment["AppleLocale"] = "en_US" + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment.merge(extra) { _, newValue in newValue } + return environment + } + + func writeExecutable(_ contents: String, at url: URL) throws { + try fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try contents.write(to: url, atomically: true, encoding: .utf8) + try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + } + + /// A stand-in for install.sh that records that it ran and installs a fake + /// `coderouter` where the real installer puts it. PATH is empty, so every + /// tool is addressed absolutely. + func writeFakeInstaller() throws -> URL { + let url = root.appendingPathComponent("fake-install.sh", isDirectory: false) + try writeExecutable( + """ + #!/bin/sh + set -eu + /usr/bin/touch "$HOME/installer-ran" + /bin/mkdir -p "$HOME/.coderouter/bin" + /bin/cat > "$HOME/.coderouter/bin/coderouter" <<'CODEROUTER' + #!/bin/sh + printf '<%s>\\n' "$@" > "$HOME/coderouter-args" + printf 'installed coderouter ran\\n' + exit 41 + CODEROUTER + /bin/chmod 755 "$HOME/.coderouter/bin/coderouter" + printf 'fake installer done\\n' + """, + at: url + ) + return url + } + + /// Runs the CLI with piped stdio and stdin at /dev/null: the non-interactive case. + func runCLI(arguments: [String], extraEnvironment: [String: String] = [:]) -> ProcessResult { + let process = Process() + process.executableURL = URL(fileURLWithPath: cliPath) + process.arguments = arguments + process.environment = environment(merging: extraEnvironment) + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardInput = FileHandle.nullDevice + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + let finished = DispatchSemaphore(value: 0) + process.terminationHandler = { _ in finished.signal() } + do { + try process.run() + } catch { + return ProcessResult(status: 127, stdout: "", stderr: error.localizedDescription, timedOut: false) + } + let timedOut = Self.waitOrKill(process, finished: finished, timeout: 10) + let stdout = String(decoding: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) + let stderr = String(decoding: stderrPipe.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) + return ProcessResult( + status: timedOut ? 124 : process.terminationStatus, + stdout: stdout, + stderr: stderr, + timedOut: timedOut + ) + } + + /// Runs the CLI with all three stdio fds on a pseudo-terminal, the way a + /// person at a terminal would, and answers the prompt with `input`. + func runCLIUnderPTY( + arguments: [String], + input: String, + extraEnvironment: [String: String] = [:] + ) throws -> PTYResult { + var masterFD: Int32 = -1 + var slaveFD: Int32 = -1 + guard openpty(&masterFD, &slaveFD, nil, nil, nil) == 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + func slaveHandle() throws -> FileHandle { + let fd = dup(slaveFD) + guard fd >= 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + return FileHandle(fileDescriptor: fd, closeOnDealloc: true) + } + let stdinHandle: FileHandle + let stdoutHandle: FileHandle + let stderrHandle: FileHandle + do { + stdinHandle = try slaveHandle() + stdoutHandle = try slaveHandle() + stderrHandle = try slaveHandle() + } catch { + Darwin.close(slaveFD) + Darwin.close(masterFD) + throw error + } + Darwin.close(slaveFD) + let master = masterFD + + let process = Process() + process.executableURL = URL(fileURLWithPath: cliPath) + process.arguments = arguments + process.environment = environment(merging: extraEnvironment) + process.standardInput = stdinHandle + process.standardOutput = stdoutHandle + process.standardError = stderrHandle + let finished = DispatchSemaphore(value: 0) + process.terminationHandler = { _ in finished.signal() } + do { + try process.run() + } catch { + Darwin.close(master) + throw error + } + // Only the CLI holds the slave now; when it (or what it exec'd) exits, + // the master read below reports end of file. + stdinHandle.closeFile() + stdoutHandle.closeFile() + stderrHandle.closeFile() + + // The line discipline queues the answer until the CLI reads stdin, so + // it can be written before the prompt appears. + let inputBytes = Array(input.utf8) + var written = 0 + while written < inputBytes.count { + let count = inputBytes[written...].withUnsafeBufferPointer { buffer in + Darwin.write(master, buffer.baseAddress, buffer.count) + } + if count < 0 { + if errno == EINTR { continue } + break + } + written += count + } + + let transcript = TranscriptBuffer() + let drained = DispatchSemaphore(value: 0) + DispatchQueue.global(qos: .userInitiated).async { + var buffer = [UInt8](repeating: 0, count: 4096) + while true { + let count = Darwin.read(master, &buffer, buffer.count) + if count > 0 { + transcript.append(buffer, count: count) + continue + } + if count < 0, errno == EINTR { + continue + } + break + } + drained.signal() + } + + let timedOut = Self.waitOrKill(process, finished: finished, timeout: 10) + _ = drained.wait(timeout: .now() + 2) + Darwin.close(master) + return PTYResult( + status: timedOut ? 124 : process.terminationStatus, + transcript: transcript.text, + timedOut: timedOut + ) + } + + private static func waitOrKill(_ process: Process, finished: DispatchSemaphore, timeout: TimeInterval) -> Bool { + if finished.wait(timeout: .now() + timeout) == .success { + return false + } + Darwin.kill(process.processIdentifier, SIGKILL) + _ = finished.wait(timeout: .now() + 2) + return true + } + } +} diff --git a/cmuxTests/CLICoderouterCommandTests.swift b/cmuxTests/CLICoderouterCommandTests.swift index 36f3e7bc31a4..e3771226e589 100644 --- a/cmuxTests/CLICoderouterCommandTests.swift +++ b/cmuxTests/CLICoderouterCommandTests.swift @@ -438,8 +438,10 @@ extension CLINotifyProcessIntegrationRegressionTests { } func testCoderouterUnknownVerbStillPassesThroughToTheInstalledCLI() throws { - // With an empty PATH the passthrough cannot find `coderouter`/`cr`; the - // point is that the socket is never consulted for a non-cmux verb. + // With an empty PATH and an empty HOME (the passthrough also looks in + // the installer's ~/.coderouter/bin) there is no `coderouter`/`cr` to + // run; the point is that the socket is never consulted for a non-cmux + // verb. let emptyPath = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-empty-path-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: emptyPath, withIntermediateDirectories: true) @@ -448,7 +450,11 @@ extension CLINotifyProcessIntegrationRegressionTests { let (result, state) = try runCoderouterCLI( ["coderouter", "accounts"], socketName: "coderouter-passthrough", - extraEnvironment: ["PATH": emptyPath.path], + extraEnvironment: [ + "PATH": emptyPath.path, + "HOME": emptyPath.path, + "CFFIXED_USER_HOME": emptyPath.path, + ], waitForSocket: false ) { _, _ in nil } diff --git a/docs/cli-contract.md b/docs/cli-contract.md index cb7e23c6e5cd..fef299d1de58 100644 --- a/docs/cli-contract.md +++ b/docs/cli-contract.md @@ -89,7 +89,7 @@ Environment: | `automation` | Manage config-backed event rules: `list`, `show `, dry-run `test --event `, `enable`, `disable`, `logs`, and `reload`. Rules live in `~/.cmuxterm/automations.json`; actions are dispatched by the running app. | | `sessions [list]` | List saved agent session records without requiring a running cmux socket. Filters: `--agent `, `--session `, `--workspace `, `--surface `, `--cwd `. Overrides: `--state-dir `, `--codex-home `. Text output defaults to 100 results; `--limit ` takes a positive integer and `--all` removes the limit. Supports `--json`. | | `auth` | Manage auth status, login, and logout through the app. | -| `coderouter`, `cr` | `cmux coderouter ` manages the team's coderouter model plane through the app (sign-in state, per-machine usage, the team's Claude upstream accounts). Every other `cmux coderouter ...` verb and all of `cmux cr ...` exec the installed CodeRouter CLI (`coderouter` or `cr` on PATH) unchanged, exit 127 when it is missing. | +| `coderouter`, `cr` | `cmux coderouter ` manages the team's coderouter model plane through the app (sign-in state, per-machine usage, the team's Claude upstream accounts). Every other `cmux coderouter ...` verb and all of `cmux cr ...` exec the CodeRouter CLI unchanged with the `CMUX_*`/`CMUXD_*` environment stripped: `coderouter` or `cr` on PATH first, then the official installer's `~/.coderouter/bin/coderouter` (`$CODEROUTER_INSTALL/bin` when set), never with a network call. When neither exists and stdin and stderr are terminals, cmux shows the documented installer `curl -fsSL https://cmux.com/coderouter/install.sh | sh`, says what it does (checksum-verified binary into `~/.coderouter/bin`, PATH line in the shell profile), asks once (`Install CodeRouter now? [y/N]`), and after `y` fetches the script, runs it with `sh`, and execs the new install with the original arguments. Any other outcome (non-interactive, declined, download or installer failure) prints that install command on stderr and exits 127. | | `vm`, `cloud` | Manage cloud VMs. `cloud` is an alias for `vm`. | | `remotes`, `remote` | Manage remote Macs in the team device registry so they appear in the iOS app's device list. `remote` is an alias for `remotes`. | | `rpc` | Call a raw v2 socket method with optional JSON params. | @@ -339,7 +339,7 @@ Remotes subcommands: | `remotes add ` | Register or update a remote with one or more `--route `. Supports `--tag` and `--json`. Idempotent on `` (re-adding updates routes). The host must be a Tailscale address the phone can authenticate to (CGNAT `100.64.x.x`-`100.127.x.x` or `*.ts.net`); loopback, plain LAN IPs, and bare hostnames are rejected. | | `remotes remove ` | Remove a remote you registered. Aliases `rm`, `delete`. Supports `--json`. | -CodeRouter subcommands (cmux-owned; anything else passes through to the installed CodeRouter CLI): +CodeRouter subcommands (cmux-owned; anything else passes through to the CodeRouter CLI, which `cmux cr` offers to install when the machine has none): | Command | Contract | | --- | --- |