Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
3257108
feat: add team picker account menu and shared team scope
austinywang Sep 19, 2026
b313d39
test: align dashboard team scope with Stack selection
austinywang Sep 19, 2026
04c52bb
fix: fence concurrent team mutations and async socket actions
austinywang Sep 19, 2026
0b59445
chore: normalize team picker localization diff
austinywang Sep 19, 2026
29c2783
docs: clarify Stack team scope authority
austinywang Sep 19, 2026
6147c4a
fix: keep team socket status fully asynchronous
austinywang Sep 19, 2026
ce8a0ea
Merge remote-tracking branch 'origin/main' into 13019-team-picker-swi…
austinywang Sep 19, 2026
f2df323
fix: fence VM attempts across team switches
austinywang Sep 19, 2026
8ee7488
fix: localize team picker shortcut descriptions
austinywang Sep 19, 2026
c4cc2d5
fix: expose public team selection result
austinywang Sep 19, 2026
c0511d0
fix: allow coordinator team refresh publication
austinywang Sep 19, 2026
3401e61
fix: import team picker socket dependencies
austinywang Sep 19, 2026
8460aa0
fix: return observed account flow values
austinywang Sep 19, 2026
a0ccc35
fix: import sidebar popover anchor
austinywang Sep 19, 2026
ec05ca2
fix: keep original compact account menu styling
austinywang Sep 19, 2026
9d3c871
fix: keep team checkmark neutral
austinywang Sep 19, 2026
ca047a1
fix: create teams through authenticated cmux backend
austinywang Sep 19, 2026
1b92bf1
fix: keep stack client initializer public API stable
austinywang Sep 19, 2026
89e4087
fix: align account team picker settings row
austinywang Sep 19, 2026
de97136
fix: serialize complete reloads for each development tag
austinywang Sep 20, 2026
9c1858a
fix: preserve hover team submenus and dismiss the menu together
austinywang Sep 20, 2026
c0a935c
Merge main and normalize account menu spacing
austinywang Sep 20, 2026
f04a377
fix: tighten account menu section spacing
austinywang Sep 20, 2026
0cfbacf
fix: anchor team submenu to menu edge
austinywang Sep 20, 2026
cf0abff
fix: keep account popover open while hovering team submenu
austinywang Sep 20, 2026
5a669b8
fix: track mouse movement over account popovers
austinywang Sep 20, 2026
abbf8d7
Merge origin/main and resolve current CI conflicts
austinywang Sep 20, 2026
bf27830
test: cover popover hover tracking lifecycle
austinywang Sep 20, 2026
b2037b8
Merge remote-tracking branch 'origin/main' into 13019-team-picker-swi…
austinywang Sep 20, 2026
0d0a49c
fix: close team submenu when pointer leaves its hover region
austinywang Sep 20, 2026
a44540b
Merge branch 'main' of https://github.com/manaflow-ai/cmux into 13019…
austinywang Sep 20, 2026
6d33af4
Merge origin/main and preserve team scope fencing with resource stats
austinywang Sep 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions CLI/CMUXCLI+AuthTeam.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import Foundation

extension CMUXCLI {
/// Implements `cmux auth team` through the same authenticated socket
/// actions used by the sidebar picker.
func runAuthTeamCommand(
commandArgs: [String],
client: SocketClient,
jsonOutput: Bool
) throws {
let subcommand = commandArgs.first?.lowercased() ?? "list"
switch subcommand {
case "list":
let response = try client.sendV2(method: "auth.team.list")
if jsonOutput {
print(jsonString(response))
} else {
let teams = response["teams"] as? [[String: Any]] ?? []
let selected = response["selected_team_id"] as? String
for team in teams {
guard let id = team["id"] as? String else { continue }
let name = team["display_name"] as? String ?? id
print("\(id == selected ? "*" : " ") \(name) (\(id))")
}
}
case "use":
guard commandArgs.count == 2, !commandArgs[1].isEmpty else {
throw CLIError(message: String(
localized: "cli.auth.team.useUsage",
defaultValue: "Usage: cmux auth team use <team-id>"
))
}
let response = try client.sendV2(
method: "auth.team.use",
params: ["team_id": commandArgs[1]]
)
if jsonOutput {
print(jsonString(response))
} else {
let selected = response["selected_team_id"] as? String ?? commandArgs[1]
print(String(format: String(
localized: "cli.auth.team.selected",
defaultValue: "Selected team: %@"
), selected))
}
case "create":
let displayName = commandArgs.dropFirst().joined(separator: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !displayName.isEmpty else {
throw CLIError(message: String(
localized: "cli.auth.team.createUsage",
defaultValue: "Usage: cmux auth team create <name>"
))
}
let response = try client.sendV2(
method: "auth.team.create",
params: ["display_name": displayName]
)
if jsonOutput {
print(jsonString(response))
} else {
let selected = response["selected_team_id"] as? String ?? displayName
print(String(format: String(
localized: "cli.auth.team.created",
defaultValue: "Created and selected team: %@"
), selected))
}
default:
throw CLIError(message: String(
localized: "cli.auth.team.usage",
defaultValue: "Usage: cmux auth team <list|use <team-id>|create <name>>"
))
}
}
}
30 changes: 15 additions & 15 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5311,15 +5311,13 @@ struct CMUXCLI {
}
}
defer { client.close() }

try authenticateClientIfNeeded(
client,
explicitPassword: socketPasswordArg,
socketPath: resolvedSocketPath,
responseTimeout: cursorHookSocketTimeout,
deadline: cursorHookDeadline
)

let idFormat = try resolvedIDFormat(jsonOutput: jsonOutput, raw: idFormatArg)
// Workspace inspection JSON is a scripting boundary: keep stable UUIDs
// beside renumberable refs unless the caller explicitly chooses a format.
Expand All @@ -5335,33 +5333,26 @@ struct CMUXCLI {
throw error
}
}

let capturesSocketErrorsInsideCommand = ["claude-hook", "codex-hook", "feed-hook", "hooks"].contains(command) // Backwards compatibility aliases stay hidden from help.
do {
switch command {
case "automation":
try runAutomationCommand(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput)

case "__sidebar_footer_icon_balance":
let response = try sendV1Command("__sidebar_footer_icon_balance", client: client)
print(response)

case "__internal_flags":
let response = try sendV1Command("__internal_flags", client: client)
print(response)

case "ping":
let response = try sendV1Command("ping", client: client)
print(response)

case "iroh-diag":
let response = try sendV1Command("iroh_diag", client: client)
print(response)

case "capabilities":
let response = try client.sendV2(method: "system.capabilities")
print(jsonString(formatIDs(response, mode: idFormat)))

case "agent-hibernation":
try runAgentHibernation(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput)

Expand Down Expand Up @@ -5459,9 +5450,17 @@ struct CMUXCLI {
} else {
print("Sign-out requested but state hasn't cleared yet. Run `cmux auth status` to confirm.")
}

case "team":
try runAuthTeamCommand(
commandArgs: Array(authArgs.dropFirst()),
client: client,
jsonOutput: jsonOutput
)
default:
throw CLIError(message: "Usage: cmux auth <status|login|logout>")
throw CLIError(message: String(
localized: "cli.auth.usage",
defaultValue: "Usage: cmux auth <status|login|logout|team>"
))
}

case "agent":
Expand Down Expand Up @@ -18194,13 +18193,14 @@ struct CMUXCLI {
case "billing":
return "Usage: cmux billing checkout --plan <go|pro|max> [--no-open]\n\nCreate checkout for the signed-in cmux account. Max is $200/month. Payment requires browser confirmation. --no-open or --json returns the URL without opening a browser."
case "auth":
return """
Usage: cmux auth <status|login|logout>
return String(localized: "cli.auth.help", defaultValue: """
Usage: cmux auth <status|login|logout|team>

status Print whether the user is signed in (add `cmux --json` for JSON).
login Open the sign-in popup on the cmux web app and wait for it to finish.
logout Clear the current session.
"""
team List teams or select one (`team list|use <team-id>|create <name>`).
""")
case "login":
return """
Usage: cmux login
Expand Down Expand Up @@ -41155,7 +41155,7 @@ export default CMUXSessionRestore;
capabilities
events [--after <seq>] [--cursor-file <path>] [--name <event>] [--category <category>] [--reconnect] [--limit <n>] [--no-ack] [--no-heartbeat]
automation <list|show|test|enable|disable|logs|reload> [args]
auth <status|login|logout>
auth <status|login|logout|team>
login | logout (aliases for auth login/logout)
\(localizedCoderouterAliases())
\(localizedCoderouterCommands())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,23 @@ public protocol AuthClient: Sendable {
/// - Returns: The user's teams; empty when no user is signed in.
func listTeams() async throws -> [CMUXAuthTeam]

/// Read Stack Auth's cross-device selected team, when the backend exposes
/// one for the current session.
func selectedTeamID() async throws -> String?

/// Persist the user's selected team in Stack Auth.
///
/// The local coordinator keeps a durable fallback for offline launch, but
/// this server value is the cross-device source of truth used by the web
/// dashboard and other cmux clients.
/// - Parameter id: The team id to select, or `nil` to clear the selection.
func setSelectedTeam(id: String?) async throws

/// Create a Stack Auth team and add the current user as its creator.
/// - Parameter displayName: The team's display name.
/// - Returns: The newly-created team summary.
func createTeam(displayName: String) async throws -> CMUXAuthTeam

/// Send a magic-link email and return the opaque nonce to combine with the
/// user-entered code.
/// - Parameters:
Expand Down Expand Up @@ -102,3 +119,25 @@ public protocol AuthClient: Sendable {
/// could be resolved (offline, dead server).
func freshAccessToken(accessToken: String?, refreshToken: String) async -> String?
}

/// Errors returned when a team operation cannot be performed by an auth client.
public enum AuthClientError: Error, Equatable, Sendable {
/// The client does not support team mutation (used by test-only clients).
case unsupported
/// The requested team is not in the authenticated user's membership list.
case teamNotAvailable
/// The requested team name is empty after trimming.
case invalidTeamName
}

public extension AuthClient {
func selectedTeamID() async throws -> String? { nil }

func setSelectedTeam(id: String?) async throws {
throw AuthClientError.unsupported
}

func createTeam(displayName: String) async throws -> CMUXAuthTeam {
throw AuthClientError.unsupported
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,18 @@ public import StackAuth
/// safe to inject as `any AuthClient`.
public struct StackAuthClient: AuthClient {
private let stack: StackClientApp
private let apiBaseURL: URL?

/// Wrap a Stack client app.
/// - Parameter stack: The configured Stack client to delegate to.
public init(stack: StackClientApp) {
self.stack = stack
self.apiBaseURL = nil
}

private init(stack: StackClientApp, apiBaseURL: URL?) {
self.stack = stack
self.apiBaseURL = apiBaseURL
}

/// Build a Stack client from resolved config and a token-store choice.
Expand Down Expand Up @@ -42,7 +49,8 @@ public struct StackAuthClient: AuthClient {
tokenStore: tokenStore,
noAutomaticPrefetch: noAutomaticPrefetch,
oauthBrowserSessionPrivacy: oauthBrowserSessionPrivacy
)
),
apiBaseURL: URL(string: config.apiBaseURL)
)
}

Expand Down Expand Up @@ -78,6 +86,57 @@ public struct StackAuthClient: AuthClient {
return summaries
}

public func selectedTeamID() async throws -> String? {
guard let user = try await stack.getUser(or: .returnNull) else { return nil }
return await user.selectedTeam?.id
}

public func setSelectedTeam(id: String?) async throws {
guard let user = try await stack.getUser(or: .returnNull) else {
throw AuthClientError.unsupported
}
try await user.setSelectedTeam(id: id)
}

public func createTeam(displayName: String) async throws -> CMUXAuthTeam {
if let apiBaseURL {
return try await createTeamThroughCmuxBackend(displayName: displayName, apiBaseURL: apiBaseURL)
}
guard let user = try await stack.getUser(or: .returnNull) else {
throw AuthClientError.unsupported
}
let team = try await user.createTeam(displayName: displayName)
return CMUXAuthTeam(
id: team.id,
displayName: await team.displayName
)
}

private func createTeamThroughCmuxBackend(
displayName: String,
apiBaseURL: URL
) async throws -> CMUXAuthTeam {
guard let accessToken = await stack.getAccessToken(),
let refreshToken = await stack.getRefreshToken() else {
throw AuthClientError.unsupported
}
let endpoint = apiBaseURL.appendingPathComponent("api/subrouter/teams")
var request = URLRequest(url: endpoint)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
request.setValue(refreshToken, forHTTPHeaderField: "X-Stack-Refresh-Token")
request.httpBody = try JSONEncoder().encode(CreateTeamRequest(displayName: displayName))
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse,
(200..<300).contains(http.statusCode) else {
throw AuthClientError.unsupported
}
let created = try JSONDecoder().decode(CreateTeamResponse.self, from: data)
return CMUXAuthTeam(id: created.team.id, displayName: created.team.name)
}

public func sendMagicLinkEmail(email: String, callbackURL: String) async throws -> String {
try await stack.sendMagicLinkEmail(email: email, callbackUrl: callbackURL)
}
Expand Down Expand Up @@ -156,3 +215,16 @@ extension CurrentUser {
)
}
}

private struct CreateTeamRequest: Encodable {
let displayName: String
}

private struct CreateTeamResponse: Decodable {
let team: TeamSummary

struct TeamSummary: Decodable {
let id: String
let name: String
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
public import CMUXAuthCore

public extension AuthCoordinator {
/// Persist a team selection on Stack Auth before changing the local
/// projection. Every UI surface uses this action so a rejected request
/// leaves the current cloud scope and open work untouched.
/// - Parameter id: A team id from ``availableTeams``.
func selectTeam(id: String?) async throws {
if let id, !availableTeams.contains(where: { $0.id == id }) {
throw AuthClientError.teamNotAvailable
}
teamMutationGeneration &+= 1
let mutationGeneration = teamMutationGeneration
let sessionGeneration = self.sessionGeneration
try await client.setSelectedTeam(id: id)
guard sessionGeneration == self.sessionGeneration,
mutationGeneration == teamMutationGeneration,
isAuthenticated else {
throw AuthError.unauthorized
}
selectedTeamID = id
Comment thread
austinywang marked this conversation as resolved.
}

/// Creates a team, refreshes membership, and selects the new team.
/// - Parameter displayName: The display name entered by the user.
/// - Returns: The authoritative newly-created team.
func createTeam(displayName: String) async throws -> CMUXAuthTeam {
let trimmed = displayName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { throw AuthClientError.invalidTeamName }
guard isAuthenticated else { throw AuthError.unauthorized }
teamMutationGeneration &+= 1
let mutationGeneration = teamMutationGeneration
let generation = sessionGeneration
let created = try await client.createTeam(displayName: trimmed)
guard generation == sessionGeneration,
mutationGeneration == teamMutationGeneration,
isAuthenticated else {
throw AuthError.unauthorized
}
var refreshed = try await client.listTeams()
guard generation == sessionGeneration,
mutationGeneration == teamMutationGeneration,
isAuthenticated else {
throw AuthError.unauthorized
}
if !refreshed.contains(where: { $0.id == created.id }) {
refreshed.append(created)
}
availableTeams = refreshed
try await selectTeam(id: created.id)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return created
}
}
Loading
Loading