Skip to content

feat: dl --claude-profiles names the account behind each profile - #572

Merged
blooop merged 5 commits into
mainfrom
feat/claude-profiles-list
Sep 4, 2026
Merged

feat: dl --claude-profiles names the account behind each profile#572
blooop merged 5 commits into
mainfrom
feat/claude-profiles-list

Conversation

@JSmithRobotics

@JSmithRobotics JSmithRobotics commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

dl --claude-profiles lists the logins this host can forward, with the account
behind each one
, because a profile's name is chosen by a person and verified by
nothing.

A profile called work holding a personal login reads as correct right up until
work 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.

NAME       AUTHED         ACCOUNT
default    authed         josh@example.com (Acme, max)
work       authed         josh@acme.example (Acme Inc, team)
spare      no credential  --

Read from the three fields of .claude.json worth showing: email, organisation and
seat 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 authed column is the credential file's existence and never its contents, so
a 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 --json and end up
somewhere 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.

base and bear are the same account, so all but one are spare.

Three decisions in that one line:

  • Grouped on the account's own id, never on a display field. A shared
    organisation is two people; a shared accountUuid is one account.
  • A profile naming no account joins no group, because two blanks are not the
    same account.
  • Said once per group as a footnote, not per row as a column, because it is a
    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 completion
would 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 validates
with, and it makes the resolver, the listing and the completion agree.

Public surface, and a defect the snapshots caught

flows::claude_profiles is binary surface: dl reads it and nothing promises it.
Forty-seven rows in public-api.rest.txt, none in the promise file.

Regenerating found a real defect. ProfileSummary::account is a pub field
whose type Account is declared in clients::claude, which is pub(crate) -- so
the field was readable while its type was not nameable. 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. A pub field whose type has no snapshot row is
the visible shape of that, and it is fixed here by re-exporting Account from
flows::claude_profiles rather than by making clients::claude public, which would
put 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.json still read its .claude.json. That is
what a logout leaves behind: the credential goes, the state file stays. So a row
whose own authed column 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_accounts groups 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. account is now None whenever 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 -flag or my profile was completed and then
refused at launch -- a refusal about a name you did not type.
the_completion_offers_only_names_a_launch_accepts is the diff the standing rule
asks for, and a real one: it builds a profile root, completes against it, runs
dl --claude-profiles against the same root, and compares the two sets.

That test earned its keep immediately. The first filter was
[[ "$pname" =~ ^[A-Za-z0-9_.-]+$ ]], and [[ =~ ]] honours LC_COLLATE, so in
a UTF-8 locale [A-Za-z] matches é: it offered unicode-é while
ProfileName::parse, which asks is_ascii_alphanumeric, refuses it. The
character 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:

  • Add dl --claude-profiles to list available Claude profiles with authentication state and account identity details.
  • Identify duplicate profiles belonging to the same account while avoiding false duplicates for shared directories or missing account data.

Bug Fixes:

  • Prevent stale account state from appearing on profiles without credentials.
  • Report unreadable profile roots instead of silently presenting incomplete results.
  • Keep profile validation, listing, and shell completion consistent, including rejecting hidden and invalid names.

Enhancements:

  • Avoid reading credential contents when generating the profile listing and distinguish missing credentials from unknown account state.

Documentation:

  • Document the new Claude profile listing command and its account information in the changelog, README, and workspace tools guide.

Tests:

  • Add coverage for account display, duplicate-account grouping, stale state, invalid names, directory aliases, and completion/listing consistency.

Chores:

  • Expose the account type through the Claude profiles flow without making the token client module public.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@sourcery-ai

sourcery-ai Bot commented Sep 4, 2026

Copy link
Copy Markdown

Reviewer's Guide

Adds dl --claude-profiles, which enumerates launchable Claude profiles, reports account identity from non-secret state metadata, distinguishes credential presence from missing account data, and flags duplicate account profiles while aligning profile validation with shell completion and launch behavior.

Sequence diagram for listing Claude profiles

sequenceDiagram
    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
Loading

Flow diagram for Claude profile eligibility

flowchart 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]
Loading

File-Level Changes

Change Details Files
Add account-aware Claude profile discovery and rendering without reading credential contents.
  • Introduce Account parsing from .claude.json for email, organisation, seat tier, and opaque account UUID.
  • Represent credential presence separately from account metadata, including authed, not logged in, -, and unknown states.
  • Add --claude-profiles command wiring, deterministic table output, and duplicate-account footnotes.
  • Re-export the public account type from the flow module and regenerate the public API snapshot.
rust/devlaunch-core/src/clients/claude.rs
rust/devlaunch-core/src/flows/claude_profiles.rs
rust/devlaunch-core/src/flows/mod.rs
rust/dl/src/cli.rs
rust/dl/src/commands.rs
rust/devlaunch-core/public-api.rest.txt
Make profile-name validation consistent across launching, listing, and shell completion.
  • Reject leading-dot profile names in the shared parser.
  • Filter completion candidates to the exact accepted ASCII name grammar.
  • Add end-to-end completion tests comparing offered names with dl --claude-profiles, including locale-sensitive Unicode cases.
rust/devlaunch-core/src/clients/claude.rs
rust/devlaunch-core/completions/dl.bash
test/test_bash_completion.py
Document the new profile listing and its safety and duplicate-account semantics.
  • Add CLI reference entries and usage examples.
  • Document credential-file existence checks, account-state fallbacks, account-UUID grouping, and hidden-name rejection.
  • Add changelog coverage.
README.md
docs/workspace-tools.md
CHANGELOG.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@codecov

codecov Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 76.84211% with 88 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.59%. Comparing base (c3aedea) to head (f98170c).

Files with missing lines Patch % Lines
rust/dl/src/commands.rs 0.00% 77 Missing ⚠️
rust/devlaunch-core/src/flows/claude_profiles.rs 97.60% 6 Missing ⚠️
rust/devlaunch-core/src/clients/claude.rs 88.09% 5 Missing ⚠️
Additional details and impacted files
Flag Coverage Δ
python 42.98% <ø> (ø)
rust 94.87% <76.84%> (-0.17%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
shipped code (rust) 94.87% <76.84%> (-0.17%) ⬇️
harness and tooling (python) 42.98% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@blooop blooop left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@blooop
blooop force-pushed the feat/claude-profiles-list branch from b60c6da to c4f93db Compare September 4, 2026 11:36
@blooop
blooop force-pushed the feat/claude-profiles-list branch from c4f93db to 0b73c63 Compare September 4, 2026 11:48
Base automatically changed from feat/claude-profile to main September 4, 2026 11:52
JSmithRobotics and others added 5 commits September 4, 2026 12:52
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`.
@blooop
blooop force-pushed the feat/claude-profiles-list branch from 0b73c63 to f98170c Compare September 4, 2026 11:52
@blooop
blooop merged commit 26e6369 into main Sep 4, 2026
15 checks passed
@blooop
blooop deleted the feat/claude-profiles-list branch September 4, 2026 11:56
@blooop blooop mentioned this pull request Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants