diff --git a/CLI/CMUXCLI+WorkspaceTemplateParameters.swift b/CLI/CMUXCLI+WorkspaceTemplateParameters.swift
new file mode 100644
index 000000000000..13ae2bb9a0ae
--- /dev/null
+++ b/CLI/CMUXCLI+WorkspaceTemplateParameters.swift
@@ -0,0 +1,148 @@
+import CmuxFoundation
+import Foundation
+
+extension CMUXCLI {
+ static let workspaceCreateHelpText = String(
+ localized: "cli.workspace.create.help",
+ defaultValue: """
+ Usage: cmux new-workspace [--name
] [--description ] [--cwd ] [--command ] [--env KEY=VALUE]... [--env-file ]... [--param KEY=VALUE]... [--layout ] [--window ] [--focus ] [--group ] [--group-placement afterCurrent|top|end] [--group-reference ]
+
+ Create a new workspace in the caller's window.
+
+ Flags:
+ --name Set a custom name for the new workspace
+ --description Set a custom description for the new workspace
+ --cwd Set the working directory for the new workspace
+ --command Send text+Enter to the new workspace after creation
+ --env KEY=VALUE Set a workspace environment variable. Repeatable.
+ Reserved CMUX_* variables cannot be overridden.
+ --env-file Load KEY=VALUE lines from a file. Repeatable.
+ --param KEY=VALUE Set a {{variable}} used by workspace strings. Repeatable.
+ --param KEY Import KEY from the invoking shell environment.
+ --layout Create workspace with a predefined split layout.
+ Layout surfaces define their own commands.
+ --window Target window (default: caller's window)
+ --focus Focus the new workspace (default: false)
+ --group Add the new workspace to a workspace group
+ --group-placement afterCurrent|top|end Placement within --group (default: top)
+ --group-reference Reference workspace for afterCurrent placement
+
+ Examples:
+ cmux new-workspace
+ cmux new-workspace --name "Build Server"
+ cmux new-workspace --cwd . --command "npm test"
+ cmux new-workspace --name "Dev {{ticket}}" --param ticket=BERKS-87
+ """
+ )
+
+ struct WorkspaceTemplateParameterOptions {
+ let values: [String: String]
+ let remaining: [String]
+ }
+
+ /// Parses repeatable `--param KEY=VALUE`, `--param=KEY=VALUE`, and
+ /// `--param KEY` arguments. The last value wins; the value-less form imports
+ /// that key from the invoking CLI process environment.
+ func parseWorkspaceTemplateParameterOptions(
+ _ args: [String],
+ commandName: String,
+ environment: [String: String] = ProcessInfo.processInfo.environment
+ ) throws -> WorkspaceTemplateParameterOptions {
+ var values: [String: String] = [:]
+ var remaining: [String] = []
+ var index = 0
+ var pastTerminator = false
+
+ while index < args.count {
+ let argument = args[index]
+ if argument == "--" {
+ pastTerminator = true
+ remaining.append(argument)
+ index += 1
+ continue
+ }
+ guard !pastTerminator else {
+ remaining.append(argument)
+ index += 1
+ continue
+ }
+
+ let rawParameter: String?
+ if argument == "--param" {
+ guard index + 1 < args.count else {
+ throw CLIError(message: String(
+ format: String(
+ localized: "cli.workspace.templateParameter.error.requiresValue",
+ defaultValue: "%@: --param requires KEY=VALUE or KEY"
+ ),
+ locale: .current,
+ commandName
+ ))
+ }
+ rawParameter = args[index + 1]
+ index += 2
+ } else if argument.hasPrefix("--param=") {
+ rawParameter = String(argument.dropFirst("--param=".count))
+ index += 1
+ } else {
+ rawParameter = nil
+ }
+
+ guard let rawParameter else {
+ remaining.append(argument)
+ index += 1
+ continue
+ }
+ let parameter = try parseWorkspaceTemplateParameter(
+ rawParameter,
+ commandName: commandName,
+ environment: environment
+ )
+ values[parameter.key] = parameter.value
+ }
+ return WorkspaceTemplateParameterOptions(values: values, remaining: remaining)
+ }
+
+ private func parseWorkspaceTemplateParameter(
+ _ raw: String,
+ commandName: String,
+ environment: [String: String]
+ ) throws -> (key: String, value: String) {
+ let key: String
+ let explicitValue: String?
+ if let equals = raw.firstIndex(of: "=") {
+ key = String(raw[.., --description , --command , --cwd , --env KEY=VALUE, --env-file , --layout , --window , --focus , --group , --group-placement , --group-reference "
+ defaultValue: "%@: unknown flag '%@'. Known flags: --name , --description , --command , --cwd , --env KEY=VALUE, --env-file , --param KEY=VALUE, --layout , --window , --focus , --group , --group-placement , --group-reference "
),
locale: .current,
commandName,
@@ -7475,7 +7480,8 @@ struct CMUXCLI {
var params: [String: Any] = [:]
try applyWindowOrCallerContext(to: ¶ms, client: client, windowRaw: windowOpt ?? windowOverride)
if let cwdOpt {
- params["cwd"] = resolvePath(cwdOpt)
+ params["cwd"] = cwdOpt
+ params["caller_cwd"] = FileManager.default.currentDirectoryPath
}
if let nameOpt { params["title"] = nameOpt }
if let descriptionOpt { params["description"] = descriptionOpt }
@@ -7486,6 +7492,12 @@ struct CMUXCLI {
if !workspaceEnv.isEmpty {
params["workspace_env"] = workspaceEnv
}
+ if !templateParameterOptions.values.isEmpty {
+ params["template_params"] = templateParameterOptions.values
+ }
+ if layoutOpt == nil, let commandOpt {
+ params["initial_input"] = unescapeSendText(commandOpt + "\\n")
+ }
if let layoutOpt {
guard let layoutData = layoutOpt.data(using: .utf8),
let layoutObj = try? JSONSerialization.jsonObject(with: layoutData) as? [String: Any] else {
@@ -7501,14 +7513,6 @@ struct CMUXCLI {
} else {
print("OK \(wsId)")
}
- if layoutOpt == nil, let commandText = commandOpt, !wsId.isEmpty {
- let text = unescapeSendText(commandText + "\\n")
- let sendParams: [String: Any] = [
- "text": text,
- "workspace_id": wsId
- ]
- _ = try client.sendV2(method: "surface.send_text", params: sendParams)
- }
}
/// Parses repeatable `--env KEY=VALUE` / `--env=KEY=VALUE` and
@@ -15730,35 +15734,7 @@ struct CMUXCLI {
cmux rename-tab --workspace workspace:2 --surface surface:5 --title "agent run"
"""
case "new-workspace":
- return """
- Usage: cmux new-workspace [--name ] [--description ] [--cwd ] [--command ] [--env KEY=VALUE]... [--env-file ]... [--layout ] [--window ] [--focus ] [--group ] [--group-placement afterCurrent|top|end] [--group-reference ]
-
- Create a new workspace in the caller's window.
-
- Flags:
- --name Set a custom name for the new workspace
- --description Set a custom description for the new workspace
- --cwd Set the working directory for the new workspace
- --command Send text+Enter to the new workspace after creation
- --env KEY=VALUE Set a workspace environment variable. Repeatable.
- Reserved CMUX_* variables cannot be overridden.
- --env-file Load KEY=VALUE lines from a file. Repeatable.
- --layout Create workspace with a predefined split layout.
- Layout surfaces define their own commands.
- --window Target window (default: caller's window)
- --focus Focus the new workspace (default: false)
- --group Add the new workspace to a workspace group
- --group-placement afterCurrent|top|end Placement within --group (default: top)
- --group-reference Reference workspace for afterCurrent placement
-
- Example:
- cmux new-workspace
- cmux new-workspace --name "Build Server"
- cmux new-workspace --name "Launch" --description "Ship checklist"
- cmux new-workspace --cwd ~/projects/myapp
- cmux new-workspace --cwd . --command "npm test"
- cmux new-workspace --name "Dev" --layout '{"direction":"horizontal","split":0.5,"children":[{"pane":{"surfaces":[{"type":"terminal","command":"vim"}]}},{"pane":{"surfaces":[{"type":"terminal","command":"npm run start"}]}}]}'
- """
+ return Self.workspaceCreateHelpText
case "list-workspaces":
return """
Usage: cmux list-workspaces [--window ]
@@ -35247,7 +35223,7 @@ export default CMUXSessionRestore;
todo [args] [--workspace ] [--window ]
move-tab-to-new-workspace [--tab ] [--surface ] [--workspace ] [--window ] [--title ] [--focus ]
list-workspaces [--window ]
- new-workspace [--name ] [--description ] [--cwd ] [--command ] [--layout ] [--window ] [--focus ] [--group ] [--group-placement afterCurrent|top|end] [--group-reference ]
+ new-workspace [--name ] [--description ] [--cwd ] [--command ] [--param KEY=VALUE]... [--layout ] [--window ] [--focus ] [--group ] [--group-placement afterCurrent|top|end] [--group-reference ]
ssh [--name ] [--port ] [--identity ] [-A|--forward-agent] [-a|--no-forward-agent] [--ssh-option ] [--window ] [--no-focus] [-- ]
ssh-tmux [--port ] [--identity ] [--no-focus] [--new-window]
ssh-session-list [--workspace | --all-workspaces]
diff --git a/CLI/cmux_layout.swift b/CLI/cmux_layout.swift
index c601a860d1b8..e1675db9daed 100644
--- a/CLI/cmux_layout.swift
+++ b/CLI/cmux_layout.swift
@@ -2,24 +2,24 @@ import Foundation
extension CMUXCLI {
static func layoutHelpText() -> String {
- """
+ String(localized: "cli.layout.help", defaultValue: """
Usage: cmux layout [flags]
- Save, list, export, open, and delete named workspace layouts.
+ Save, list, inspect, open, and delete named workspace layouts.
Subcommands:
save [--workspace [] [--overwrite] [--description ]
list [--json]
get
- open [--cwd ] [--focus ]
+ open [--cwd ] [--param KEY[=VALUE]]... [--focus ]
delete
Examples:
cmux layout save dev --overwrite
cmux layout list
cmux layout get dev
- cmux layout open dev --cwd ~/projects/myapp
- """
+ cmux layout open dev --cwd ~/projects/myapp --param port=4100
+ """)
}
func runLayoutNamespace(
@@ -136,9 +136,21 @@ extension CMUXCLI {
) throws {
let (cwdOpt, rem0) = parseOption(commandArgs, name: "--cwd")
let (focusOpt, rem1) = parseOption(rem0, name: "--focus")
- let (windowOpt, remaining) = parseOption(rem1, name: "--window")
+ let (windowOpt, rem2) = parseOption(rem1, name: "--window")
+ let templateParameterOptions = try parseWorkspaceTemplateParameterOptions(
+ rem2,
+ commandName: "layout open"
+ )
+ let remaining = templateParameterOptions.remaining
if let unknown = remaining.first(where: { $0.hasPrefix("--") }) {
- throw CLIError(message: "layout open: unknown flag '\(unknown)'")
+ throw CLIError(message: String(
+ format: String(
+ localized: "cli.layout.open.error.unknownFlag",
+ defaultValue: "layout open: unknown flag '%@'"
+ ),
+ locale: .current,
+ unknown
+ ))
}
guard let name = remaining.first?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty else {
throw CLIError(message: "layout open requires ")
@@ -146,7 +158,13 @@ extension CMUXCLI {
let windowHandle = try normalizeWindowHandle(windowOpt ?? windowOverride, client: client)
var params: [String: Any] = ["name": name]
if let windowHandle { params["window_id"] = windowHandle }
- if let cwdOpt { params["cwd"] = resolvePath(cwdOpt) }
+ if let cwdOpt {
+ params["cwd"] = cwdOpt
+ params["caller_cwd"] = FileManager.default.currentDirectoryPath
+ }
+ if !templateParameterOptions.values.isEmpty {
+ params["template_params"] = templateParameterOptions.values
+ }
try applyFocusOption(focusOpt, defaultValue: false, to: ¶ms)
let payload = try client.sendV2(method: "layout.open", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2CreationSummary(payload, idFormat: idFormat, kinds: ["workspace"]))
diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Layout/ControlCommandCoordinator+Layout.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Layout/ControlCommandCoordinator+Layout.swift
index 8b8825cd993a..732f9db8270e 100644
--- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Layout/ControlCommandCoordinator+Layout.swift
+++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Layout/ControlCommandCoordinator+Layout.swift
@@ -102,10 +102,30 @@ extension ControlCommandCoordinator {
guard let name = string(params, "name") else {
return .err(code: "invalid_params", message: "Missing or blank name", data: nil)
}
+ let templateParameters: [String: String]
+ if hasNonNull(params, "template_params") {
+ guard case .object(let raw)? = params["template_params"],
+ raw.values.allSatisfy({ value in
+ if case .string = value { return true }
+ return false
+ }),
+ let parsed = stringMap(params, "template_params") else {
+ return .err(
+ code: "invalid_params",
+ message: "template_params must be an object with string values",
+ data: nil
+ )
+ }
+ templateParameters = parsed
+ } else {
+ templateParameters = [:]
+ }
let resolution = context?.controlLayoutOpen(
routing: routingSelectors(params),
name: name,
cwd: optionalTrimmedRawString(params, "cwd"),
+ callerCwd: optionalTrimmedRawString(params, "caller_cwd"),
+ templateParameters: templateParameters,
focusRequested: bool(params, "focus") ?? false
) ?? .tabManagerUnavailable
switch resolution {
@@ -115,6 +135,12 @@ extension ControlCommandCoordinator {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
case .corruptFile(let description):
return .err(code: "invalid_state", message: "layouts.json is corrupt", data: .object(["description": .string(description)]))
+ case .missingParameters(let names):
+ return .err(
+ code: "missing_parameters",
+ message: "Missing workspace template parameters: \(names.joined(separator: ", "))",
+ data: .object(["missing_parameters": .array(names.map(JSONValue.string))])
+ )
case .opened(let workspaceID):
return .ok(.object([
"workspace_id": .string(workspaceID.uuidString),
diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Layout/ControlLayoutContext.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Layout/ControlLayoutContext.swift
index a7b9f0866f3a..978d9a330099 100644
--- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Layout/ControlLayoutContext.swift
+++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Layout/ControlLayoutContext.swift
@@ -64,6 +64,8 @@ public enum ControlLayoutOpenResolution: Sendable {
case tabManagerUnavailable
/// The store file could not be decoded.
case corruptFile(String)
+ /// Required workspace template parameters were not supplied.
+ case missingParameters([String])
/// The workspace was opened.
case opened(workspaceID: UUID)
/// An unexpected store error occurred.
@@ -105,6 +107,8 @@ public protocol ControlLayoutContext: AnyObject {
routing: ControlRoutingSelectors,
name: String,
cwd: String?,
+ callerCwd: String?,
+ templateParameters: [String: String],
focusRequested: Bool
) -> ControlLayoutOpenResolution
diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorLayoutTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorLayoutTests.swift
new file mode 100644
index 000000000000..9fe5a687e5c3
--- /dev/null
+++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorLayoutTests.swift
@@ -0,0 +1,117 @@
+import Foundation
+import Testing
+@testable import CmuxControlSocket
+
+@MainActor
+private final class FakeLayoutControlCommandContext: ControlCommandContext {
+ struct OpenInvocation: Equatable {
+ let name: String
+ let cwd: String?
+ let callerCwd: String?
+ let templateParameters: [String: String]
+ let focusRequested: Bool
+ }
+
+ var openResolution: ControlLayoutOpenResolution = .tabManagerUnavailable
+ var openInvocation: OpenInvocation?
+
+ func controlLayoutOpen(
+ routing: ControlRoutingSelectors,
+ name: String,
+ cwd: String?,
+ callerCwd: String?,
+ templateParameters: [String: String],
+ focusRequested: Bool
+ ) -> ControlLayoutOpenResolution {
+ openInvocation = OpenInvocation(
+ name: name,
+ cwd: cwd,
+ callerCwd: callerCwd,
+ templateParameters: templateParameters,
+ focusRequested: focusRequested
+ )
+ return openResolution
+ }
+}
+
+@MainActor
+@Suite("ControlCommandCoordinator layout domain")
+struct ControlCommandCoordinatorLayoutTests {
+ @Test func layoutOpenForwardsTemplateParameters() {
+ let context = FakeLayoutControlCommandContext()
+ let workspaceID = UUID()
+ context.openResolution = .opened(workspaceID: workspaceID)
+ let coordinator = ControlCommandCoordinator(context: context)
+
+ let result = coordinator.handle(ControlRequest(
+ id: .int(1),
+ method: "layout.open",
+ params: [
+ "name": .string("Ticket Dev"),
+ "cwd": .string("/tmp/{{ticket}}"),
+ "caller_cwd": .string("/Users/tester/project"),
+ "focus": .bool(false),
+ "template_params": .object([
+ "ticket": .string("BERKS-87"),
+ "vitePort": .string("5174"),
+ ]),
+ ]
+ ))
+
+ #expect(context.openInvocation == .init(
+ name: "Ticket Dev",
+ cwd: "/tmp/{{ticket}}",
+ callerCwd: "/Users/tester/project",
+ templateParameters: ["ticket": "BERKS-87", "vitePort": "5174"],
+ focusRequested: false
+ ))
+ #expect(result == .ok(.object([
+ "workspace_id": .string(workspaceID.uuidString),
+ "workspace_ref": .string("workspace:1"),
+ ])))
+ }
+
+ @Test func layoutOpenRejectsNonStringTemplateParameterValue() {
+ let context = FakeLayoutControlCommandContext()
+ let coordinator = ControlCommandCoordinator(context: context)
+
+ let result = coordinator.handle(ControlRequest(
+ id: .int(1),
+ method: "layout.open",
+ params: [
+ "name": .string("Ticket Dev"),
+ "template_params": .object([
+ "ticket": .string("BERKS-87"),
+ "vitePort": .int(5174),
+ ]),
+ ]
+ ))
+
+ #expect(result == .err(
+ code: "invalid_params",
+ message: "template_params must be an object with string values",
+ data: nil
+ ))
+ #expect(context.openInvocation == nil)
+ }
+
+ @Test func layoutOpenReturnsStructuredMissingParameters() {
+ let context = FakeLayoutControlCommandContext()
+ context.openResolution = .missingParameters(["ticket", "vitePort"])
+ let coordinator = ControlCommandCoordinator(context: context)
+
+ let result = coordinator.handle(ControlRequest(
+ id: .int(1),
+ method: "layout.open",
+ params: ["name": .string("Ticket Dev")]
+ ))
+
+ #expect(result == .err(
+ code: "missing_parameters",
+ message: "Missing workspace template parameters: ticket, vitePort",
+ data: .object([
+ "missing_parameters": .array([.string("ticket"), .string("vitePort")]),
+ ])
+ ))
+ }
+}
diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlLayoutContextTestStubs.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlLayoutContextTestStubs.swift
index 964d76cc9ecf..ea305ebf0ed6 100644
--- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlLayoutContextTestStubs.swift
+++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlLayoutContextTestStubs.swift
@@ -18,6 +18,8 @@ extension ControlLayoutContext {
routing: ControlRoutingSelectors,
name: String,
cwd: String?,
+ callerCwd: String?,
+ templateParameters: [String: String],
focusRequested: Bool
) -> ControlLayoutOpenResolution { .tabManagerUnavailable }
diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigValues/Template/CmuxTemplate.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigValues/Template/CmuxTemplate.swift
new file mode 100644
index 000000000000..2126bbe04932
--- /dev/null
+++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigValues/Template/CmuxTemplate.swift
@@ -0,0 +1,157 @@
+import Foundation
+
+/// Literal text that may contain cmux `{{variable}}` placeholders.
+///
+/// The grammar intentionally matches custom-command variables from #6898:
+/// `{{name}}` and `{{name=default}}`, with a letter-or-underscore-leading name
+/// containing letters, digits, `_`, or `-`. Invalid template expressions are
+/// preserved. Prefixing a recognized placeholder with `\` keeps it literal and
+/// removes that escaping backslash.
+public struct CmuxTemplate: Sendable {
+ /// The unresolved source text.
+ public let rawValue: String
+
+ /// Creates a template from unresolved source text.
+ ///
+ /// - Parameter rawValue: Text that may contain cmux placeholders.
+ public init(_ rawValue: String) {
+ self.rawValue = rawValue
+ }
+
+ /// Variables in first-occurrence order, de-duplicated by name.
+ ///
+ /// When a name occurs more than once, the first occurrence's inline default
+ /// wins, matching the custom-command behavior from #6898.
+ public var variables: [CmuxTemplateVariable] {
+ var seen = Set()
+ return matches.compactMap { match in
+ guard let variable = match.variable,
+ seen.insert(variable.name).inserted else {
+ return nil
+ }
+ return variable
+ }
+ }
+
+ /// Whether the text contains a recognized, unescaped cmux placeholder.
+ public var containsVariables: Bool {
+ matches.contains { $0.variable != nil }
+ }
+
+ /// Replaces recognized placeholders found in `values` and preserves any
+ /// unresolved placeholders verbatim.
+ ///
+ /// Substitution is literal because workspace values also fill paths, URLs,
+ /// titles, and environment values. Shell-command callers that accept
+ /// interactive, untrusted values should quote those values before passing
+ /// them here, as #6898 does for custom commands.
+ ///
+ /// - Parameter values: Values keyed by placeholder name.
+ /// - Returns: The substituted text.
+ public func substituting(_ values: [String: String]) -> String {
+ let matches = matches
+ guard !matches.isEmpty else { return rawValue }
+
+ let characters = Array(rawValue)
+ var result = ""
+ result.reserveCapacity(characters.count)
+ var cursor = 0
+ for match in matches {
+ result.append(contentsOf: characters[cursor..
+ let isEscaped: Bool
+ }
+
+ private var matches: [Match] {
+ guard rawValue.contains("{{") else { return [] }
+ let characters = Array(rawValue)
+ var result: [Match] = []
+ var index = 0
+
+ while index + 1 < characters.count {
+ let isEscaped = characters[index] == "\\"
+ && index + 2 < characters.count
+ && characters[index + 1] == "{"
+ && characters[index + 2] == "{"
+ let openingIndex = isEscaped ? index + 1 : index
+ guard characters[openingIndex] == "{",
+ openingIndex + 1 < characters.count,
+ characters[openingIndex + 1] == "{" else {
+ index += 1
+ continue
+ }
+
+ let innerStart = openingIndex + 2
+ guard let boundary = Self.candidateBoundary(in: characters, from: innerStart) else {
+ break
+ }
+ if characters[boundary] == "{" {
+ // Include the preceding character so escapes and overlapping
+ // opening braces keep their normal recognition semantics.
+ index = boundary - 1
+ continue
+ }
+ let close = boundary
+ let inner = characters[innerStart.. Int? {
+ var index = start
+ while index + 1 < characters.count {
+ if characters[index] == "{" {
+ return index
+ }
+ if characters[index] == "}", characters[index + 1] == "}" {
+ return index
+ }
+ index += 1
+ }
+ return nil
+ }
+
+ private static func parseVariable(_ inner: String) -> CmuxTemplateVariable? {
+ let name: String
+ let defaultValue: String?
+ if let equals = inner.firstIndex(of: "=") {
+ name = inner[.. String {
+ let values = try resolvedValues(for: [template])
+ return template.substituting(values)
+ }
+
+ /// Resolves a batch atomically after preflighting every required variable.
+ ///
+ /// - Parameter templates: Templates in deterministic traversal order.
+ /// - Returns: Concrete strings in the same order.
+ /// - Throws: ``CmuxTemplateResolutionError/missingVariables(_:)`` listing
+ /// every missing name once, in first-occurrence order.
+ public func resolve(_ templates: [CmuxTemplate]) throws -> [String] {
+ let values = try resolvedValues(for: templates)
+ return templates.map { $0.substituting(values) }
+ }
+
+ /// Computes the single value map used to render a batch of templates.
+ ///
+ /// - Parameter templates: Templates in deterministic traversal order.
+ /// - Returns: Values for every recognized variable.
+ /// - Throws: ``CmuxTemplateResolutionError/missingVariables(_:)`` listing
+ /// every missing name once, in first-occurrence order.
+ public func resolvedValues(for templates: [CmuxTemplate]) throws -> [String: String] {
+ var values: [String: String] = [:]
+ var missing: [String] = []
+ var seen = Set()
+
+ for variable in templates.flatMap(\.variables) where seen.insert(variable.name).inserted {
+ if let value = value(for: variable) {
+ values[variable.name] = value
+ } else {
+ missing.append(variable.name)
+ }
+ }
+ guard missing.isEmpty else {
+ throw CmuxTemplateResolutionError.missingVariables(missing)
+ }
+ return values
+ }
+
+ /// Describes the editable values exposed by a configuration UI.
+ ///
+ /// Inputs preserve first-occurrence order and use the same precedence as
+ /// final resolution, so an editor can show the value that launches would
+ /// otherwise select automatically.
+ public func parameterInputs(for templates: [CmuxTemplate]) -> [CmuxTemplateParameterInput] {
+ var seen = Set()
+ return templates.flatMap(\.variables).compactMap { variable in
+ guard seen.insert(variable.name).inserted else { return nil }
+ return CmuxTemplateParameterInput(
+ name: variable.name,
+ suggestedValue: value(for: variable)
+ )
+ }
+ }
+
+ private func value(for variable: CmuxTemplateVariable) -> String? {
+ explicitValues[variable.name]
+ ?? definitionValues[variable.name]
+ ?? workspaceEnvironment[variable.name]
+ ?? processEnvironment[variable.name]
+ ?? variable.defaultValue
+ }
+}
diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigValues/Template/CmuxTemplateVariable.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigValues/Template/CmuxTemplateVariable.swift
new file mode 100644
index 000000000000..3de98490ef27
--- /dev/null
+++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigValues/Template/CmuxTemplateVariable.swift
@@ -0,0 +1,39 @@
+import Foundation
+
+/// A named `{{variable}}` placeholder and its optional inline default value.
+public struct CmuxTemplateVariable: Equatable, Sendable {
+ /// The placeholder name.
+ public let name: String
+
+ /// The value after `=` in `{{name=default}}`, or `nil` when none was declared.
+ public let defaultValue: String?
+
+ /// Creates a parsed template variable.
+ ///
+ /// - Parameters:
+ /// - name: The placeholder name.
+ /// - defaultValue: The optional inline default value.
+ public init(name: String, defaultValue: String?) {
+ self.name = name
+ self.defaultValue = defaultValue
+ }
+
+ /// Returns whether a name is valid in a cmux template placeholder.
+ ///
+ /// Names start with a letter or `_` and continue with letters, digits, `_`,
+ /// or `-`, matching the custom-command variable grammar from #6898.
+ ///
+ /// - Parameter name: The candidate placeholder name.
+ /// - Returns: `true` when the name follows the cmux template grammar.
+ public static func isValidName(_ name: String) -> Bool {
+ guard let first = name.unicodeScalars.first,
+ CharacterSet.letters.contains(first) || first == "_" else {
+ return false
+ }
+ return name.unicodeScalars.allSatisfy { trailingNameCharacters.contains($0) }
+ }
+
+ private static let trailingNameCharacters = CharacterSet.letters
+ .union(.decimalDigits)
+ .union(CharacterSet(charactersIn: "_-"))
+}
diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/CmuxTemplateTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/CmuxTemplateTests.swift
new file mode 100644
index 000000000000..bfcf9f1c146a
--- /dev/null
+++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/CmuxTemplateTests.swift
@@ -0,0 +1,93 @@
+import Testing
+@testable import CmuxFoundation
+
+@Suite struct CmuxTemplateTests {
+ @Test func parsesPriorArtPlaceholderGrammarInFirstOccurrenceOrder() {
+ let template = CmuxTemplate("{{ticket}} {{apiPort=8080}} {{ticket=ignored}} {{bad.name}}")
+
+ #expect(template.variables == [
+ CmuxTemplateVariable(name: "ticket", defaultValue: nil),
+ CmuxTemplateVariable(name: "apiPort", defaultValue: "8080"),
+ ])
+ }
+
+ @Test func substitutesLiteralValuesAndUnescapesLiteralPlaceholders() throws {
+ let template = CmuxTemplate(#"https://example.test/{{ticket}}/\{{upstream}}?q={{query}}"#)
+ let resolver = CmuxTemplateResolver(
+ explicitValues: ["ticket": "BERKS-87", "query": "a/b & c"]
+ )
+
+ #expect(try resolver.resolve(template) == "https://example.test/BERKS-87/{{upstream}}?q=a/b & c")
+ }
+
+ @Test func resolvesWithDocumentedPrecedence() throws {
+ let templates = [
+ CmuxTemplate("{{explicit}} {{definition}} {{workspaceEnv}} {{processEnv}} {{inline=inline-default}}"),
+ ]
+ let resolver = CmuxTemplateResolver(
+ explicitValues: [
+ "explicit": "explicit-value",
+ "definition": "explicit-wins",
+ ],
+ definitionValues: [
+ "definition": "definition-value",
+ "workspaceEnv": "definition-wins",
+ ],
+ workspaceEnvironment: [
+ "workspaceEnv": "workspace-value",
+ "processEnv": "workspace-wins",
+ ],
+ processEnvironment: [
+ "processEnv": "process-value",
+ "inline": "process-wins",
+ ]
+ )
+
+ #expect(try resolver.resolve(templates[0])
+ == "explicit-value explicit-wins definition-wins workspace-wins process-wins")
+ }
+
+ @Test func reportsAllMissingVariablesOnceInTraversalOrder() {
+ let resolver = CmuxTemplateResolver(processEnvironment: [:])
+
+ #expect(throws: CmuxTemplateResolutionError.missingVariables(["ticket", "apiPort", "vitePort"])) {
+ try resolver.resolve([
+ CmuxTemplate("{{ticket}} {{apiPort}}"),
+ CmuxTemplate("{{ticket}} {{vitePort}}"),
+ ])
+ }
+ }
+
+ @Test func ignoresInvalidAndEscapedPlaceholderFormsWhenCheckingMissingValues() throws {
+ let resolver = CmuxTemplateResolver(processEnvironment: [:])
+
+ #expect(try resolver.resolve(CmuxTemplate(#"{{bad.name}} {{1port}} \{{literal}}"#))
+ == "{{bad.name}} {{1port}} {{literal}}")
+ }
+
+ @Test func malformedPlaceholderDoesNotHideNestedValidPlaceholder() throws {
+ let resolver = CmuxTemplateResolver(explicitValues: ["ticket": "BERKS-87"])
+ let resolved = try resolver.resolve(CmuxTemplate("{{bad{{ticket}}"))
+
+ #expect(resolved == "{{badBERKS-87")
+ }
+
+ @Test func parameterInputsPreserveOrderAndExposeEditableSuggestedValues() {
+ let resolver = CmuxTemplateResolver(
+ definitionValues: ["ticket": "CMUX-8059"],
+ workspaceEnvironment: ["region": "workspace-region"],
+ processEnvironment: ["owner": "austin"]
+ )
+
+ #expect(resolver.parameterInputs(for: [
+ CmuxTemplate("{{ticket}} {{missing}}"),
+ CmuxTemplate("{{region}} {{owner}} {{port=4100}} {{ticket=ignored}}"),
+ ]) == [
+ CmuxTemplateParameterInput(name: "ticket", suggestedValue: "CMUX-8059"),
+ CmuxTemplateParameterInput(name: "missing", suggestedValue: nil),
+ CmuxTemplateParameterInput(name: "region", suggestedValue: "workspace-region"),
+ CmuxTemplateParameterInput(name: "owner", suggestedValue: "austin"),
+ CmuxTemplateParameterInput(name: "port", suggestedValue: "4100"),
+ ])
+ }
+}
diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings
index 53c8f4adb2a1..8e71891592e7 100644
--- a/Resources/Localizable.xcstrings
+++ b/Resources/Localizable.xcstrings
@@ -44622,15342 +44622,15016 @@
}
}
},
- "cli.workspace.create.error.envFileRequiresValue": {
+ "cli.layout.help": {
"extractionState": "manual",
"localizations": {
- "en": {
+ "ar": {
"stringUnit": {
"state": "translated",
- "value": "%@: --env-file requires "
+ "value": "الاستخدام: cmux layout [flags]\n\nحفظ وإدراج وفحص وفتح وحذف تخطيطات مساحة العمل المسماة.\n\nالأوامر الفرعية:\n save [--workspace ][] [--overwrite] [--description ]\n list [--json]\n get \n open [--cwd ] [--param KEY[=VALUE]]... [--focus ]\n delete \n\nأمثلة:\n cmux layout save dev --overwrite\n cmux layout list\n cmux layout get dev\n cmux layout open dev --cwd ~/projects/myapp --param port=4100"
}
},
- "ja": {
+ "bs": {
"stringUnit": {
"state": "translated",
- "value": "%@: --env-file には が必要です"
+ "value": "Upotreba: cmux layout [flags]\n\nSačuvajte, izlistajte, pregledajte, otvorite i izbrišite imenovane rasporede radnog prostora.\n\nPodnaredbe:\n save [--workspace ][] [--overwrite] [--description ]\n list [--json]\n get \n open [--cwd ] [--param KEY[=VALUE]]... [--focus ]\n delete \n\nprimjeri:\n cmux layout save dev --overwrite\n cmux layout list\n cmux layout get dev\n cmux layout open dev --cwd ~/projects/myapp --param port=4100"
}
},
- "ko": {
+ "da": {
"stringUnit": {
"state": "translated",
- "value": "%@: --env-file에는 가 필요합니다"
+ "value": "Anvendelse: cmux layout [flags]\n\nGem, angiv, inspicér, åbn og slet navngivne arbejdsrumslayouts.\n\nUnderkommandoer:\n save [--workspace ][] [--overwrite] [--description ]\n list [--json]\n get \n open [--cwd ] [--param KEY[=VALUE]]... [--focus ]\n delete \n\nEksempler:\n cmux layout save dev --overwrite\n cmux layout list\n cmux layout get dev\n cmux layout open dev --cwd ~/projects/myapp --param port=4100"
}
},
- "uk": {
+ "de": {
"stringUnit": {
"state": "translated",
- "value": "%@: --env-file потребує "
+ "value": "Verwendung: cmux layout [flags]\n\nBenannte Arbeitsbereichslayouts speichern, auflisten, prüfen, öffnen und löschen.\n\nUnterbefehle:\n save [--workspace ][] [--overwrite] [--description ]\n list [--json]\n get \n open [--cwd ] [--param KEY[=VALUE]]... [--focus ]\n delete \n\nBeispiele:\n cmux layout save dev --overwrite\n cmux layout list\n cmux layout get dev\n cmux layout open dev --cwd ~/projects/myapp --param port=4100"
}
- }
- }
- },
- "cli.workspace.create.error.envRequiresValue": {
- "extractionState": "manual",
- "localizations": {
+ },
"en": {
"stringUnit": {
"state": "translated",
- "value": "%@: --env requires KEY=VALUE"
+ "value": "Usage: cmux layout [flags]\n\nSave, list, inspect, open, and delete named workspace layouts.\n\nSubcommands:\n save [--workspace ][] [--overwrite] [--description ]\n list [--json]\n get \n open [--cwd ] [--param KEY[=VALUE]]... [--focus ]\n delete \n\nExamples:\n cmux layout save dev --overwrite\n cmux layout list\n cmux layout get dev\n cmux layout open dev --cwd ~/projects/myapp --param port=4100"
}
},
- "ja": {
+ "es": {
"stringUnit": {
"state": "translated",
- "value": "%@: --env には KEY=VALUE が必要です"
+ "value": "Uso: cmux layout [flags]\n\nGuarde, enumere, inspeccione, abra y elimine diseños de espacios de trabajo con nombre.\n\nSubcomandos:\n save [--workspace ][] [--overwrite] [--description ]\n list [--json]\n get \n open [--cwd ] [--param KEY[=VALUE]]... [--focus ]\n delete \n\nEjemplos:\n cmux layout save dev --overwrite\n cmux layout list\n cmux layout get dev\n cmux layout open dev --cwd ~/projects/myapp --param port=4100"
}
},
- "ko": {
+ "fr": {
"stringUnit": {
"state": "translated",
- "value": "%@: --env에는 KEY=VALUE가 필요합니다"
+ "value": "Utilisation : cmux layout [flags]\n\nEnregistrez, répertoriez, inspectez, ouvrez et supprimez les dispositions d'espace de travail nommées.\n\nSous-commandes :\n save [--workspace ][] [--overwrite] [--description ]\n list [--json]\n get \n open [--cwd ] [--param KEY[=VALUE]]... [--focus ]\n delete \n\nExemples :\n cmux layout save dev --overwrite\n cmux layout list\n cmux layout get dev\n cmux layout open dev --cwd ~/projects/myapp --param port=4100"
}
},
- "uk": {
- "stringUnit": {
- "state": "translated",
- "value": "%@: --env потребує KEY=VALUE"
- }
- }
- }
- },
- "cli.workspace.create.error.unknownFlag": {
- "extractionState": "manual",
- "localizations": {
- "en": {
+ "it": {
"stringUnit": {
"state": "translated",
- "value": "%@: unknown flag '%@'. Known flags: --name , --description , --command , --cwd , --env KEY=VALUE, --env-file , --layout , --window , --focus , --group , --group-placement , --group-reference "
+ "value": "Utilizzo: cmux layout [flags]\n\nSalva, elenca, esamina, apri ed elimina i layout dell'area di lavoro con nome.\n\nSottocomandi:\n save [--workspace ][] [--overwrite] [--description ]\n list [--json]\n get \n open [--cwd ] [--param KEY[=VALUE]]... [--focus ]\n delete \n\nEsempi:\n cmux layout save dev --overwrite\n cmux layout list\n cmux layout get dev\n cmux layout open dev --cwd ~/projects/myapp --param port=4100"
}
},
"ja": {
"stringUnit": {
"state": "translated",
- "value": "%@: 不明なフラグ '%@'。利用可能なフラグ: --name , --description , --command , --cwd , --env KEY=VALUE, --env-file , --layout , --window , --focus , --group , --group-placement , --group-reference "
+ "value": "使い方: cmux layout <サブコマンド> [フラグ]\n\n名前付きワークスペースレイアウトを保存、一覧表示、取得、開く、削除します。\n\nサブコマンド:\n save [--workspace ][] [--overwrite] [--description ]\n list [--json]\n get \n open [--cwd ] [--param KEY[=VALUE]]... [--focus ]\n delete \n\n例:\n cmux layout save dev --overwrite\n cmux layout list\n cmux layout get dev\n cmux layout open dev --cwd ~/projects/myapp --param port=4100"
}
},
- "ko": {
+ "km": {
"stringUnit": {
"state": "translated",
- "value": "%@: 알 수 없는 플래그 '%@'. 사용 가능한 플래그: --name , --description , --command , --cwd , --env KEY=VALUE, --env-file , --layout , --window , --focus , --group , --group-placement , --group-reference "
+ "value": "ការប្រើប្រាស់៖ cmux layout [flags]\n\nរក្សាទុក រាយបញ្ជី ពិនិត្យ បើក និងលុបប្លង់កន្លែងធ្វើការដែលមានឈ្មោះ។\n\nពាក្យបញ្ជារង៖\n save [--workspace ][] [--overwrite] [--description ]\n list [--json]\n get \n open [--cwd ] [--param KEY[=VALUE]]... [--focus ]\n delete \n\nឧទាហរណ៍៖\n cmux layout save dev --overwrite\n cmux layout list\n cmux layout get dev\n cmux layout open dev --cwd ~/projects/myapp --param port=4100"
}
},
- "uk": {
- "stringUnit": {
- "state": "translated",
- "value": "%@: невідомий прапорець '%@'. Доступні прапорці: --name , --description , --command , --cwd , --env KEY=VALUE, --env-file , --layout , --window ]