feat: dl --claude-profiles names the account behind each profile - #572
Conversation
There was a problem hiding this comment.
Sorry @JSmithRobotics, you've used your own review budget of 250,000 diff characters for the last 7 days.
You can request another review in 7 days by commenting @sourcery-ai review. Upgrade to get a review now.
Reviewer's GuideAdds Sequence diagram for listing Claude profilessequenceDiagram
participant User
participant DL
participant Profiles as claude_profiles
participant Filesystem
participant State as .claude.json
User->>DL: --claude-profiles
DL->>Profiles: from_process()
Profiles->>Filesystem: enumerate profile directories
Profiles->>Filesystem: has_credential()
alt credential file exists
Profiles->>State: account_at()
State-->>Profiles: email, organization, seat tier, accountUuid
else no credential file
Profiles-->>Profiles: account = None
end
Profiles->>Profiles: summarise() and group by accountUuid
Profiles-->>DL: ProfileSummary rows
DL-->>User: table with state, account, and shared-account footnotes
Flow diagram for Claude profile eligibilityflowchart TD
A[Profile directory] --> B{ProfileName::parse accepts name?}
B -- No --> C[Exclude from listing and completion]
B -- Yes --> D{Is name default?}
D -- Yes --> C
D -- No --> E[Offer as launchable profile]
E --> F{.credentials.json exists?}
F -- No --> G[State: not logged in; account: -]
F -- Yes --> H{.claude.json has account fields?}
H -- No --> I[State: authed; account: unknown]
H -- Yes --> J[Show email, organization, and seat tier]
J --> K[Group duplicates by accountUuid]
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
Codecov Report❌ Patch coverage is Additional details and impacted files
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
blooop
left a comment
There was a problem hiding this comment.
A listing whose stated job is catching "a second name for the login you would have got anyway" — and the accountUuid-not-display-field decision is the right basis for it.
The first finding is the one that matters: the redundancy advice can name the only login you have. No secrets leak — oauthAccount carries no token fields and only four keys are read — which I checked specifically.
§4: completions/dl.bash:114 points at test_completion_tables.py, which does not exist (the test is test/test_bash_completion.py); dl.bash:103-119 is 17 comment lines over the two lines they describe; and claude_profiles.rs:31-41 and :187-196 narrate snapshot-regeneration and review history over one expression each.
Spec axis: no spec available (no Closes #n, no linked issue), so that axis was skipped.
| .push(row.name.clone()); | ||
| } | ||
| } | ||
| by_account.retain(|_, names| names.len() > 1); |
There was a problem hiding this comment.
Rows are grouped by accountUuid with no check that two of them are the same directory, so one config dir reachable under two names is reported as redundancy — and following the advice deletes the only login.
Concretely: CLAUDE_CONFIG_DIR=~/.claude-profiles/work dl --claude-profiles. unnamed_config_dir_from_process returns that same path (clients/claude.rs:377), so the default row and the work row read one .claude.json, get one uuid, and stdout says 'default', 'work' are the same account, so all but one are spare. There is no other one. ln -s ~/.claude ~/.claude-profiles/personal gives the same output, since entry.path().is_dir() (:113) follows symlinks.
ProfileSummary::path is never printed, so nothing in the listing lets a reader notice. There is no canonicalize and no path comparison anywhere in summarise/row/note_shared_accounts, and the "no credential joins no group" guard does not apply — both rows are authed. Deduping by fs::canonicalize(&row.path) before building by_account would fix it.
There was a problem hiding this comment.
Fixed. A directory is counted once when the groups are built, by `canonicalize` where that answers and by the path as given where it does not, so the same directory under two names no longer earns the advice. Both rows are still listed with the same account against each. Only a representative row is told about its neighbours, so a third name for an already-counted directory cannot inflate the group either, which has its own test: getting that wrong makes the count of what can be deleted too high, which is the direction that loses a login.
| let Some(root) = profiles_root else { | ||
| return rows; | ||
| }; | ||
| let Ok(entries) = std::fs::read_dir(root) else { |
There was a problem hiding this comment.
let Ok(entries) = read_dir(root) else { return rows; } swallows every error kind, not only NotFound as the comment beside it says. chmod 000 ~/.claude-profiles, or a regular file at that path, prints the default row alone and exits 0 — telling a user with five profiles that they have none.
Suggest letting a non-NotFound error say so on stderr, since "no profiles" and "I could not look" are different answers here.
There was a problem hiding this comment.
Fixed, and the comment beside it now says what the code does. `summarise` is pure and has no channel, so the reason is asked for in `render_claude_profiles`, which owns stderr; `NotFound` stays silent because a root nothing has created is the ordinary state.
| } | ||
|
|
||
| fn row(name: String, path: PathBuf) -> ProfileSummary { | ||
| let authed = claude::has_credential(&path); |
There was a problem hiding this comment.
Same shape one level down: with the profile directory present but non-searchable, has_credential's is_file() is false, so an authenticated profile prints as not logged in / -. That is a wrong statement rather than a degraded one, and it is the column a reader is trusting.
There was a problem hiding this comment.
Not changed. This is the same call as the row above and I did not want to fix it by guessing: `Authed` is documented as "has a credential file" rather than "has a working one", deliberately, and the arm you want (file present, yields no token) is a real third state that the launch already distinguishes as `ProfileUnreadable`. Classifying it here means reading the file in the listing, which is a decision about the "nothing here reads a token" promise in `render_claude_profiles`' own doc, so it is yours rather than mine. Left as posted.
b60c6da to
c4f93db
Compare
c4f93db to
0b73c63
Compare
A profile's directory name is chosen by a person and verified by nothing. A profile called `work` holding a personal login reads as correct right up until the work is pushed from the wrong identity, found out about later and somewhere else, which is the exact failure profiles exist to prevent. The hard refusal added with --claude-profile catches a *misspelled* profile and does nothing about a *mislabelled* one. So the listing carries the name and the account behind it, from the three fields of Claude Code's own `.claude.json` worth showing a person choosing between logins: email address, organisation, seat tier. The name is what you type; the account column is what you get. It found something on its first run against the real directory, which is the argument for having it: two profiles on this machine report the same accountUuid, so one of them is redundant or misnamed, and nothing before this could have said so. No token is read to build it. `ProfileState` answers from the credential file's existence and never its contents, so a listing has not touched a secret -- a smaller claim than it sounds, and worth keeping for a surface likely to grow a `--json` and end up somewhere it should not. It also distinguishes a profile created and never logged in to, which a launch naming it refuses, from one whose state file says nothing, which launches fine. Every absence in that file is one answer, because they read alike to somebody drawing a table: no file, not JSON, no `oauthAccount`. Not a Deserialize struct over the whole thing, for `token_from_credentials`'s reason -- it belongs to Claude Code, has 70-odd keys this does not read, and gains more on its own schedule. A profile name beginning with a dot is now refused as well as unlisted, which settles a disagreement three places were having: a `<root>/*/` glob matches no dot-directory, so neither the shell completion nor the tool that manages the directory would ever show one, while `ProfileName::parse` accepted it. A profile you can launch but never see is a trap. One rule makes the resolver, the listing and the completion agree, and it subsumes the `.` and `..` special cases. It is marginally stricter than the `^[A-Za-z0-9._-]+$` the managing tool validates with, which accepts a leading dot it then never lists. flows::claude_profiles is new public API and moves public-api.rest.txt, as does clients::claude::Account. Still not regenerated here: the script needs a nightly this machine has not got, and CI's nightly is the one whose output means something.
The columns cannot show this, which is the argument for it. Two profiles of one account render *identically* to two colleagues who share an organisation, so the redundant one is invisible exactly where somebody is choosing between them -- and this listing exists because a profile's name proves nothing about the account behind it. Grouped on `accountUuid` and on nothing else. A shared `organizationName` is two people, and a shared `emailAddress` would be the same claim made less precisely. A profile whose state file names no account joins no group at all, because two blanks are not the same account and saying they were would be a claim about nothing. `Account::is_empty` stays deliberately blind to the id for the matching reason: an id alone renders as an empty column, so a file carrying only that is no better than a file carrying none. Said once per group as a footnote rather than per row as a column. It is a fact about a *pair*, so a column would repeat it on every row while still not saying which pair. `default` is in the grouping, which catches the most pointless profile there is: a second name for the login you would have got anyway. On the real directory it prints 'base', 'bear' are the same account, so all but one are spare. which is the finding that prompted this, and which nothing before it could have made.
Forty-seven rows, all in the tripwire file and none in the promise file: `flows::claude_profiles` is binary surface that `dl` reads and nothing promises. Worth reading one of them rather than skimming the block. `ProfileSummary::account` renders as `Option<flows::claude_profiles::Account>`, at a path a caller outside this crate can name. It did not before this branch: `Account` is declared in `clients::claude`, which is `pub(crate)`, so the field was readable while its type was unnameable -- reach `summary.account.email` and there is no way to write the type of what you are holding, or a function that takes one. Nothing warned, because the type is declared `pub` and only its module is not, and `dl` never noticed because it only ever reaches through the field. The regeneration is what made that visible: a `pub` field whose type has no row is the shape of it, and the snapshot is where you can see it. Regenerated with `scripts/public-api-snapshots.sh` on nightly 1.100.0 (2026-09-03) and cargo-public-api 0.52.0, the pin the script names.
Two review catches on the commits below, and they compound. **A profile with no `.credentials.json` still read its `.claude.json`.** That is what a logout leaves behind: the credential goes and the state file stays, holding the account that used to be signed in. So a row whose own `authed` column said "no credential" printed an email anyway, contradicting the sentence the account column is written around and which sits three lines above the code that did it. Worse than cosmetic, because `note_shared_accounts` groups on that account's uuid. A profile someone logged out of and the profile that is now the only login share a uuid, so the listing said "base and stale are the same account, so all but one are spare" -- which reads as "delete one of these", about a pair where one cannot launch anything and the other is the only account there is. Fixed in `row`, so the invariant is carried by the value rather than filtered at each reader: `account` is `None` whenever there is no credential, and the column, the grouping and anything that reads a `ProfileSummary` later all get it. **The completion offered every directory.** `dl.bash` globbed the profile root and named whatever it found, while the Rust listing filters through `profile_name_is_offerable`. A directory called `-flag` or `my profile` was therefore completed and then refused at launch -- a refusal about a name you did not type, which is worse than not completing it. The grammar is now restated in the script, which the standing rule allows only with a test beside it that diffs the copy against the first. `the_completion_offers_only_names_a_launch_accepts` is that diff and is a real one rather than a restatement: it builds a profile root, completes against it, runs `dl --claude-profiles` against the same root, and compares the two sets. A list written in the test would have had to be kept true by hand, which is the thing the rule is about. That test earned its keep immediately. The first version of the filter used `[[ "$pname" =~ ^[A-Za-z0-9_.-]+$ ]]`, and `[[ =~ ]]` honours LC_COLLATE: in a UTF-8 locale `[A-Za-z]` matches `é`, so it offered `unicode-é` while `ProfileName::parse` -- which asks `is_ascii_alphanumeric` -- refuses it. The character set is spelled out letter by letter for that reason.
Three fixes to the listing, and the first one could cost somebody an account.
**Two names for one directory were reported as a spare copy.** The footnote says
"all but one are spare", so a group has to mean separate directories you could
delete one of. Grouping on `accountUuid` alone did not check that:
`CLAUDE_CONFIG_DIR=~/.claude-profiles/work` makes the `default` row and the
`work` row the same directory, the same `.claude.json` and the same id, so
`dl --claude-profiles` printed
'default', 'work' are the same account, so all but one are spare.
about the only login on the machine. `ln -s ~/.claude ~/.claude-profiles/personal`
got there the same way, since the walk follows symlinks, and `ProfileSummary::path`
is never printed, so nothing on screen let a reader notice.
A directory is now counted once, by `canonicalize` where that answers and by the
path as given where it does not. Both rows are still listed with the same account
against each; what is gone is the advice. Only a representative row is told about
its neighbours, so a third name for an already-counted directory cannot inflate
the group either -- that arm has its own test, because getting it wrong makes the
count of what can be deleted too high, which is the direction that loses a login.
This is the same defect b60c6da fixed for a logged-out profile, one step out: a
group must mean "separately deletable", and a stale state file and a second name
for one directory both fail it.
**A profiles directory that cannot be read said "no profiles".** `read_dir`'s
error was swallowed whole, though the comment beside it justified only
`NotFound`. `chmod 000 ~/.claude-profiles`, or a plain file at that path, told a
host with five profiles it had none. `summarise` is pure and has no channel for
that, so the reason is asked for in `render_claude_profiles`, which owns stderr;
`NotFound` stays silent, since a root nothing has created is the ordinary state.
**A citation pointed nowhere.** `dl.bash` named
`the_completion_offers_only_names_a_launch_accepts` in `test_completion_tables.py`;
the test is `test_the_completion_offers_only_names_a_launch_accepts` in
`test_bash_completion.py`. `test_citations_resolve.py` did not catch it because
its `SOURCE_SUFFIXES` does not include `.bash`.
0b73c63 to
f98170c
Compare
dl --claude-profileslists the logins this host can forward, with the accountbehind each one, because a profile's name is chosen by a person and verified by
nothing.
A profile called
workholding a personal login reads as correct right up untilwork is pushed from the wrong identity, which is the exact failure profiles exist
to prevent. The name is what you type; the account column is what you get.
Read from the three fields of
.claude.jsonworth showing: email, organisation andseat tier. It distinguishes a profile with no credential from one whose state
file says nothing, which are different problems with different fixes.
No token is read to build it
The
authedcolumn is the credential file's existence and never its contents, soa listing has not touched a secret. That is a smaller claim than it sounds and worth
keeping: a listing is the surface most likely to grow a
--jsonand end upsomewhere it should not.
It is also how you find a profile that was created and never logged in to, since a
launch naming one of those refuses (#566).
It names the profiles that are two names for one account
The other thing a name cannot tell you. Two profiles of one account render
identically to two colleagues who share an organisation, so the redundant one is
invisible exactly where you are choosing between them.
Three decisions in that one line:
organisation is two people; a shared
accountUuidis one account.same account.
fact about a pair rather than about either member.
One tightening that came out of this
A profile name beginning with a dot is now refused as well as unlisted. A
<root>/*/glob matches no dot-directory, so neither the listing nor the completionwould ever show one, and a profile you can launch but never see is a trap. That
is marginally stricter than the
^[A-Za-z0-9._-]+$the managing tool validateswith, and it makes the resolver, the listing and the completion agree.
Public surface, and a defect the snapshots caught
flows::claude_profilesis binary surface:dlreads it and nothing promises it.Forty-seven rows in
public-api.rest.txt, none in the promise file.Regenerating found a real defect.
ProfileSummary::accountis apubfieldwhose type
Accountis declared inclients::claude, which ispub(crate)-- sothe field was readable while its type was not nameable. Nothing warned, because the
type is declared
puband only its module is not, anddlnever noticed because itonly ever reaches through the field. A
pubfield whose type has no snapshot row isthe visible shape of that, and it is fixed here by re-exporting
Accountfromflows::claude_profilesrather than by makingclients::claudepublic, which wouldput the token machinery on the same surface for no reason.
Reviewed once already
Two fixes on top, in one commit, and they compound.
A profile with no
.credentials.jsonstill read its.claude.json. That iswhat a logout leaves behind: the credential goes, the state file stays. So a row
whose own
authedcolumn said "no credential" printed an email anyway --contradicting the sentence the account column is written around, three lines above
the code that did it. Worse,
note_shared_accountsgroups on that account's uuid,so a profile someone logged out of and the profile that is now the only login
shared a uuid and the listing said "all but one are spare" about a pair where one
cannot launch anything.
accountis nowNonewhenever there is no credential,carried by the value rather than filtered at each reader.
The completion offered every directory while the Rust listing filters through
profile_name_is_offerable, so-flagormy profilewas completed and thenrefused at launch -- a refusal about a name you did not type.
the_completion_offers_only_names_a_launch_acceptsis the diff the standing ruleasks for, and a real one: it builds a profile root, completes against it, runs
dl --claude-profilesagainst the same root, and compares the two sets.That test earned its keep immediately. The first filter was
[[ "$pname" =~ ^[A-Za-z0-9_.-]+$ ]], and[[ =~ ]]honoursLC_COLLATE, so ina UTF-8 locale
[A-Za-z]matchesé: it offeredunicode-éwhileProfileName::parse, which asksis_ascii_alphanumeric, refuses it. Thecharacter set is spelled out letter by letter now.
🤖 Generated with Claude Code
https://claude.ai/code/session_01AdSFnBdxie6TosHVmjLY28
Summary by Sourcery
Add an account-aware Claude profile listing that safely identifies usable logins, duplicate accounts, and profile names accepted by launch and completion.
New Features:
dl --claude-profilesto list available Claude profiles with authentication state and account identity details.Bug Fixes:
Enhancements:
Documentation:
Tests:
Chores: