Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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: 68 additions & 7 deletions apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,15 +191,22 @@ impl ClaudeSwapMutationOutcome {

fn validate_claude_swap_operation(
operation: ClaudeSwapAccountOperation,
list: &ClaudeSwapAccountList,
account: &ClaudeSwapAccountRow,
) -> Result<(), String> {
let expected_action = match operation {
ClaudeSwapAccountOperation::Switch => ClaudeSwapAccountAction::Switch,
ClaudeSwapAccountOperation::Reauthenticate => ClaudeSwapAccountAction::Reauthenticate,
};
if claude_swap::action_for_account(account) == Some(expected_action) {
if claude_swap::action_for_account(list, account) == Some(expected_action) {
return Ok(());
}
if !list.supports_account_switching {
return Err(
"This claude-swap adapter is read-only and does not support account switching."
.to_string(),
);
}
match operation {
ClaudeSwapAccountOperation::Switch if account.is_active => {
Err("That claude-swap account is already active.".to_string())
Expand Down Expand Up @@ -230,7 +237,7 @@ fn run_claude_swap_operation(
let before = claude_swap::read_account_list(&config.executable_path)
.map_err(|error| error.to_string())?;
let account = account_row_for_slot(&before, slot)?;
validate_claude_swap_operation(operation, account)?;
validate_claude_swap_operation(operation, &before, account)?;

let result = claude_swap::switch_account(&config.executable_path, slot)
.map_err(|error| error.to_string())?;
Expand Down Expand Up @@ -413,10 +420,15 @@ pub async fn claude_account_switch(
mod tests {
use super::*;

fn account_row(active: bool, status: &str) -> ClaudeSwapAccountRow {
fn account_list(
active: bool,
status: &str,
supports_account_switching: bool,
) -> ClaudeSwapAccountList {
let raw = serde_json::json!({
"schemaVersion": 1,
"activeAccountNumber": if active { serde_json::json!(1) } else { serde_json::Value::Null },
"supportsAccountSwitching": supports_account_switching,
"accounts": [{
"number": 1,
"email": "test@example.com",
Expand All @@ -426,6 +438,10 @@ mod tests {
});
codexbar::providers::claude::claude_swap::parse_account_list(&raw.to_string())
.expect("fixture should parse")
}

fn account_row(active: bool, status: &str) -> ClaudeSwapAccountRow {
account_list(active, status, true)
.accounts
.into_iter()
.next()
Expand All @@ -434,18 +450,63 @@ mod tests {

#[test]
fn operation_validation_uses_the_projected_action_state() {
let switchable = account_row(false, "ok");
let switchable = account_list(false, "ok", true);
assert!(
validate_claude_swap_operation(ClaudeSwapAccountOperation::Switch, &switchable).is_ok()
validate_claude_swap_operation(
ClaudeSwapAccountOperation::Switch,
&switchable,
&switchable.accounts[0]
)
.is_ok()
);

let blocked = account_row(false, "no_credentials");
let blocked = account_list(false, "no_credentials", true);
assert_eq!(
validate_claude_swap_operation(ClaudeSwapAccountOperation::Switch, &blocked),
validate_claude_swap_operation(
ClaudeSwapAccountOperation::Switch,
&blocked,
&blocked.accounts[0]
),
Err("That claude-swap account is not available for switching.".to_string())
);
}

#[test]
fn read_only_adapter_rejects_switch_and_reauthenticate() {
let expected = Err(
"This claude-swap adapter is read-only and does not support account switching."
.to_string(),
);
let inactive = account_list(false, "ok", false);
assert_eq!(
validate_claude_swap_operation(
ClaudeSwapAccountOperation::Switch,
&inactive,
&inactive.accounts[0]
),
expected
);
let foreign = account_list(true, "foreign_credential", false);
assert_eq!(
validate_claude_swap_operation(
ClaudeSwapAccountOperation::Reauthenticate,
&foreign,
&foreign.accounts[0]
),
expected
);
// The same row is accepted when the adapter supports switching.
let switching = account_list(true, "foreign_credential", true);
assert!(
validate_claude_swap_operation(
ClaudeSwapAccountOperation::Reauthenticate,
&switching,
&switching.accounts[0]
)
.is_ok()
);
}

#[test]
fn reauthentication_confirmation_requires_a_live_ok_status() {
assert!(reauthentication_is_repaired(&account_row(true, "ok")));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,31 @@ describe("ClaudeSwapAccountsSection", () => {
expect(screen.getByRole("status").textContent).toBe("ClaudeSwapSwitched");
});

it("renders no action button when the adapter is read-only", async () => {
mocks.getSettingsSnapshot.mockResolvedValue({
claudeSwapEnabled: true,
claudeSwapExecutablePath: "~/bin/cswap",
});
// Read-only adapters project `action: null` for every row, including
// inactive rows that would otherwise be switchable.
mocks.claudeSwapAccountsList.mockResolvedValue(
enabledState([
active,
{ ...switchable, action: null },
{ ...active, id: "claude-swap:4", slot: 4, label: "foreign@example.com", status: "foreign_credential", action: null },
]),
);
render(<ClaudeSwapAccountsSection t={t} />);
await screen.findByText("work@example.com");

expect(screen.getByText("personal@example.com")).toBeTruthy();
expect(screen.getAllByText("TokenAccountActive").length).toBeGreaterThan(0);
expect(screen.queryByText("ClaudeSwapSwitchButton")).toBeNull();
expect(screen.queryByText("ClaudeSwapReauthenticateButton")).toBeNull();
expect(mocks.claudeSwapAccountSwitch).not.toHaveBeenCalled();
expect(mocks.claudeSwapAccountReauthenticate).not.toHaveBeenCalled();
});

it("surfaces adapter errors without offering a switch", async () => {
mocks.getSettingsSnapshot.mockResolvedValue({
claudeSwapEnabled: true,
Expand Down
3 changes: 3 additions & 0 deletions rust/src/providers/claude/claude_swap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,9 @@ pub struct ClaudeSwapAccountRow {
pub struct ClaudeSwapAccountList {
pub active_account_number: Option<u32>,
pub accounts: Vec<ClaudeSwapAccountRow>,
/// Additive adapter capability (`supportsAccountSwitching`). Absent means
/// `true` for schema-v1 compatibility; read-only adapters report `false`.
pub supports_account_switching: bool,
}

#[derive(Debug, Clone, PartialEq)]
Expand Down
45 changes: 45 additions & 0 deletions rust/src/providers/claude/claude_swap/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,15 @@ pub fn parse_account_list(raw: &str) -> Result<ClaudeSwapAccountList, ClaudeSwap
let active_field = object.get("activeAccountNumber").ok_or_else(|| {
ClaudeSwapError::MalformedShape("missing activeAccountNumber".to_string())
})?;
let supports_account_switching = match object.get("supportsAccountSwitching") {
None => true,
Some(Value::Bool(supported)) => *supported,
Some(_) => {
return Err(ClaudeSwapError::MalformedShape(
"supportsAccountSwitching is not a boolean".to_string(),
));
}
};
let active_account_number = match active_field {
Value::Null => None,
Value::Number(_) => Some(non_negative_slot(active_field).ok_or_else(|| {
Expand Down Expand Up @@ -303,6 +312,7 @@ pub fn parse_account_list(raw: &str) -> Result<ClaudeSwapAccountList, ClaudeSwap
Ok(ClaudeSwapAccountList {
active_account_number,
accounts,
supports_account_switching,
})
}

Expand Down Expand Up @@ -475,6 +485,41 @@ mod tests {
assert_eq!(first.usage_status, ClaudeSwapUsageStatus::Ok);
}

#[test]
fn switching_capability_defaults_to_true_and_accepts_booleans() {
let parsed = parse_account_list(&list_fixture().to_string()).unwrap();
assert!(parsed.supports_account_switching);
for supported in [true, false] {
let mut fixture = list_fixture();
fixture["supportsAccountSwitching"] = json!(supported);
let parsed = parse_account_list(&fixture.to_string()).unwrap();
assert_eq!(parsed.supports_account_switching, supported);
}
}

#[test]
fn rejects_non_boolean_switching_capability() {
for value in [
json!(null),
json!(0),
json!(1),
json!("false"),
json!([]),
json!({}),
] {
let mut fixture = list_fixture();
fixture["supportsAccountSwitching"] = value.clone();
assert!(
matches!(
parse_account_list(&fixture.to_string()),
Err(ClaudeSwapError::MalformedShape(ref message))
if message == "supportsAccountSwitching is not a boolean"
),
"{value} must be rejected"
);
}
}

#[test]
fn rejects_unknown_and_missing_schema_versions() {
let mut unknown = list_fixture();
Expand Down
40 changes: 37 additions & 3 deletions rust/src/providers/claude/claude_swap/projection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,8 +227,16 @@ fn to_historical_usage(
.map(|historical| to_measurement(&historical.measurement, historical.fetched_at))
}

pub fn action_for_account(row: &ClaudeSwapAccountRow) -> Option<ClaudeSwapAccountAction> {
if row.is_active {
/// Action a row offers within `list`. Read-only adapters
/// (`supports_account_switching == false`) offer none: usage, details and the
/// active marker still render, but switching and re-authentication do not.
pub fn action_for_account(
list: &ClaudeSwapAccountList,
row: &ClaudeSwapAccountRow,
) -> Option<ClaudeSwapAccountAction> {
if !list.supports_account_switching {
None
} else if row.is_active {
(row.usage_status == ClaudeSwapUsageStatus::ForeignCredential)
.then_some(ClaudeSwapAccountAction::Reauthenticate)
} else if row.usage_status.can_switch_to() {
Expand Down Expand Up @@ -263,7 +271,7 @@ pub fn project_accounts(
} else {
label
};
let action = action_for_account(row);
let action = action_for_account(list, row);
ClaudeSwapAccount {
id: format!("claude-swap:{}", row.number),
slot: row.number,
Expand Down Expand Up @@ -363,6 +371,32 @@ mod tests {
);
}

#[test]
fn read_only_adapter_suppresses_actions_but_keeps_usage_and_active_marker() {
let mut list = list_fixture();
assert!(list.supports_account_switching);
list.supports_account_switching = false;
list.accounts[1].usage_status = ClaudeSwapUsageStatus::ForeignCredential;

let projected = project_accounts(&list, false);
assert_eq!(projected.len(), 3);
assert!(projected.iter().all(|account| account.action.is_none()));
// Active marker, identity and usage remain.
assert!(projected[0].is_active);
assert_eq!(projected[0].slot, 2);
assert_eq!(projected[0].status, "foreign_credential");
let inactive = projected.iter().find(|a| a.slot == 1).unwrap();
assert_eq!(inactive.email.as_deref(), Some("same@example.com"));
assert_eq!(inactive.five_hour.as_ref().unwrap().used_percent, 10.0);
}

#[test]
fn supporting_adapter_keeps_row_actions() {
let projected = project_accounts(&list_fixture(), false);
let actionable = projected.iter().find(|a| a.slot == 1).unwrap();
assert_eq!(actionable.action, Some(ClaudeSwapAccountAction::Switch));
}

#[test]
fn hiding_personal_info_collapses_to_ordinals() {
let projected = project_accounts(&list_fixture(), true);
Expand Down