Skip to content

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

Closed
JSmithRobotics wants to merge 4 commits into
blooop:feat/claude-profilefrom
JSmithRobotics:feat/claude-profiles-list
Closed

feat: dl --claude-profiles names the account behind each profile#567
JSmithRobotics wants to merge 4 commits into
blooop:feat/claude-profilefrom
JSmithRobotics:feat/claude-profiles-list

Conversation

@JSmithRobotics

@JSmithRobotics JSmithRobotics commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #566, which is stacked on #565. Their commits are in this diff
because a cross-fork PR's base must be a branch in this repository. Merge them in
order and this collapses to its own two commits. Review order: #565, #566, then
here.

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.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AdSFnBdxie6TosHVmjLY28

Summary by Sourcery

Add an account-aware Claude profile listing command while aligning profile validation and completion behavior.

New Features:

  • Add dl --claude-profiles to list available Claude profiles with authentication state and the account identity associated with each profile.
  • Identify profiles that share the same Claude account so redundant profile names are visible.

Bug Fixes:

  • Prevent profiles without credentials from being presented as signed-in accounts or grouped with active profiles.
  • Reject hidden profile names so profile resolution, listing, and shell completion remain consistent.

Enhancements:

  • Read account labels without reading credential contents and distinguish missing account metadata from profiles that are not logged in.
  • Expose the account type through the Claude profiles flow without making token-management internals public.

Documentation:

  • Document the new Claude profile listing command, output, account metadata, and profile naming behavior.
  • Add the new command to the CLI reference.

Tests:

  • Add coverage for account display, authentication states, duplicate-account grouping, profile-name validation, default-profile handling, sorting, and shell completion consistency.

@sourcery-ai

sourcery-ai Bot commented Sep 4, 2026

Copy link
Copy Markdown

Reviewer's Guide

Adds read-only, account-aware Claude profile selection: users can list external profiles with identity metadata, choose one per launch without workspace persistence, and rely on strict validation and no-fallback behavior while preserving credential secrecy and existing token precedence.

Sequence diagram for selecting and forwarding a Claude profile

sequenceDiagram
    participant User
    participant CLI as dl CLI
    participant Host as Host resolution
    participant Profiles as Profile directory
    participant Session as Workspace session

    User->>CLI: --claude-profile work
    CLI->>Host: with_claude_profile(work)
    Host->>Host: forwarding_disabled()
    Host->>Host: ProfileName::parse(work)
    Host->>Profiles: read .credentials.json
    Profiles-->>Host: accessToken
    Host-->>Session: CLAUDE_CODE_OAUTH_TOKEN
    Session-->>User: Launch with selected login
Loading

Sequence diagram for account-aware Claude profile listing

sequenceDiagram
    participant User
    participant CLI as dl --claude-profiles
    participant Listing as claude_profiles::from_process
    participant Profiles as Profile directories
    participant State as .claude.json

    User->>CLI: Request profile listing
    CLI->>Listing: from_process()
    Listing->>Profiles: read_dir()
    loop each offerable profile
        Listing->>Profiles: has_credential()
        Listing->>State: account_at()
        State-->>Listing: email, organization, seat_tier, accountUuid
    end
    Listing->>Listing: group by accountUuid
    Listing-->>CLI: ProfileSummary rows
    CLI-->>User: Print state, account, and shared-account footnotes
Loading

Flow diagram for Claude credential precedence and profile refusal

flowchart TD
    A[Start Claude token resolution] --> B{DEVLAUNCH_NO_CLAUDE_TOKEN?}
    B -- yes --> C[Forward nothing]
    B -- no --> D{Named profile other than default?}
    D -- yes --> E{ProfileName::parse succeeds?}
    E -- no --> F[ProfileNotAName refusal]
    E -- yes --> G{Profile .credentials.json readable?}
    G -- no --> H[ProfileUnreadable refusal]
    G -- yes --> I[Use profile token]
    D -- no --> J{CLAUDE_CODE_OAUTH_TOKEN present?}
    J -- yes --> K[Use exported token]
    J -- no --> L{CLAUDE_CONFIG_DIR set and non-empty?}
    L -- yes --> M[Read config directory credential]
    L -- no --> N[Read $HOME/.claude credential]
    M --> O{Credential available?}
    N --> O
    O -- yes --> P[Use host token]
    O -- no --> Q[NotLoggedIn]
Loading

File-Level Changes

Change Details Files
Adds named Claude profile resolution and strict credential-selection behavior to launches.
  • Introduces --claude-profile, including default, per-launch propagation, aid passthrough, and command applicability rules.
  • Resolves profiles from configurable external directories without writing or storing profile state.
  • Validates profile names, prevents traversal and hidden-profile mismatches, and refuses missing or unreadable named credentials instead of falling back.
  • Honors CLAUDE_CONFIG_DIR with explicit precedence and preserves opt-out and inherited-token behavior.
rust/devlaunch-core/src/clients/claude.rs
rust/devlaunch-core/src/domain/xdg.rs
rust/devlaunch-core/src/flows/launch.rs
rust/dl/src/cli.rs
rust/dl/src/commands.rs
rust/dl/src/launch.rs
rust/dl/src/lib.rs
rust/aid/src/rewrite.rs
Implements dl --claude-profiles as a read-only account-aware listing.
  • Lists default and valid profile directories with credential existence state.
  • Reads only selected identity fields from .claude.json; distinguishes missing credentials from unavailable account metadata without reading token contents.
  • Groups duplicate profiles by accountUuid, excludes unidentified accounts, and emits one spare-profile footnote per group.
  • Exposes the public Account type through the flow module and adds focused unit coverage.
rust/devlaunch-core/src/flows/claude_profiles.rs
rust/devlaunch-core/src/clients/claude.rs
rust/devlaunch-core/src/flows/mod.rs
rust/dl/src/cli.rs
rust/dl/src/commands.rs
rust/devlaunch-core/public-api.api.txt
rust/devlaunch-core/public-api.rest.txt
Extends completion, documentation, and release-facing descriptions for Claude profiles and host configuration.
  • Offers profile directories and default dynamically in bash completion, using the same precedence as runtime resolution.
  • Documents profile layout, environment-variable precedence, security boundaries, command behavior, and distinction from Remote Control authentication.
  • Adds changelog and README coverage for profile forwarding, account listing, and CLAUDE_CONFIG_DIR support.
rust/devlaunch-core/completions/dl.bash
docs/workspace-tools.md
README.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

@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.

Hey - I've found 2 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="rust/devlaunch-core/src/flows/claude_profiles.rs" line_range="177-186" />
<code_context>
+}
+
+fn row(name: String, path: PathBuf) -> ProfileSummary {
+    let state = if claude::has_credential(&path) {
+        ProfileState::Authed
+    } else {
+        ProfileState::NoCredential
+    };
+    ProfileSummary {
+        name,
+        path: path.clone(),
+        state,
+        account: claude::account_at(&path),
+        // Filled in by `note_shared_accounts` once the whole listing exists: it is a
+        // fact about a row's neighbours, so no row can answer it alone.
</code_context>
<issue_to_address>
**issue (bug_risk):** A profile with no `.credentials.json` still reads and displays any stale `.claude.json` account, so its `not logged in` row can show an email/account and it can be grouped as sharing an account. This contradicts the listing contract that a no-credential profile has nobody to name and renders `-`, and can label an unauthenticated profile as a usable identity.

**Triggers:** When a profile's credential file was removed or never existed but its `.claude.json` state file remains.

**Suggested fix:** Only read and attach the account when `has_credential` is true, or make `account_of` and grouping ignore account data for `ProfileState::NoCredential`.

```suggestion
    let has_credential = claude::has_credential(&path);
    let state = if has_credential {
        ProfileState::Authed
    } else {
        ProfileState::NoCredential
    };
    ProfileSummary {
        name,
        path: path.clone(),
        state,
        account: if has_credential {
            claude::account_at(&path)
        } else {
            None
        },
```
</issue_to_address>

### Comment 2
<location path="rust/devlaunch-core/completions/dl.bash" line_range="92-105" />
<code_context>
+        # The same three sources `domain::xdg::claude_profiles_root` reads, in the same
</code_context>
<issue_to_address>
**issue:** Profile completion enumerates every non-hidden directory without applying `ProfileName::parse` or excluding invalid names. Directories such as `has space`, `-flag`, or `a/b` are offered even though launch refuses them, so completion and the resolver disagree and selecting a listed completion can produce a profile-name error.

**Triggers:** When the externally managed profiles directory contains a directory whose name violates the resolver's profile grammar.

**Suggested fix:** Filter each directory basename with the same profile-name predicate used by the Rust listing/resolver before appending it to `profiles`.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +177 to +186
let state = if claude::has_credential(&path) {
ProfileState::Authed
} else {
ProfileState::NoCredential
};
ProfileSummary {
name,
path: path.clone(),
state,
account: claude::account_at(&path),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue (bug_risk): A profile with no .credentials.json still reads and displays any stale .claude.json account, so its not logged in row can show an email/account and it can be grouped as sharing an account. This contradicts the listing contract that a no-credential profile has nobody to name and renders -, and can label an unauthenticated profile as a usable identity.

Triggers: When a profile's credential file was removed or never existed but its .claude.json state file remains.

Suggested fix: Only read and attach the account when has_credential is true, or make account_of and grouping ignore account data for ProfileState::NoCredential.

Suggested change
let state = if claude::has_credential(&path) {
ProfileState::Authed
} else {
ProfileState::NoCredential
};
ProfileSummary {
name,
path: path.clone(),
state,
account: claude::account_at(&path),
let has_credential = claude::has_credential(&path);
let state = if has_credential {
ProfileState::Authed
} else {
ProfileState::NoCredential
};
ProfileSummary {
name,
path: path.clone(),
state,
account: if has_credential {
claude::account_at(&path)
} else {
None
},

Comment on lines +92 to +105
# The same three sources `domain::xdg::claude_profiles_root` reads, in the same
# order: devlaunch's own scratch override, then claude-as's own variable, then
# its default directory. `default` is offered because it is a name the resolver
# answers for without any directory existing.
local profiles_root="${DEVLAUNCH_CLAUDE_PROFILES_DIR:-${CLAUDE_PROFILES_DIR:-$HOME/.claude-profiles}}"
local profiles="default" pdir
if [[ -d "${profiles_root}" ]]; then
for pdir in "${profiles_root}"/*/; do
[[ -d "$pdir" ]] || continue
pdir="${pdir%/}"
profiles+=" ${pdir##*/}"
done
fi
COMPREPLY=( $(compgen -W "${profiles}" -- ${cur}) )

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue: Profile completion enumerates every non-hidden directory without applying ProfileName::parse or excluding invalid names. Directories such as has space, -flag, or a/b are offered even though launch refuses them, so completion and the resolver disagree and selecting a listed completion can produce a profile-name error.

Triggers: When the externally managed profiles directory contains a directory whose name violates the resolver's profile grammar.

Suggested fix: Filter each directory basename with the same profile-name predicate used by the Rust listing/resolver before appending it to profiles.

@JSmithRobotics
JSmithRobotics force-pushed the feat/claude-profiles-list branch from 2844c5b to db4d3e3 Compare September 4, 2026 10:19
@gitguardian

gitguardian Bot commented Sep 4, 2026

Copy link
Copy Markdown

️✅ There are no secrets present in this pull request anymore.

If these secrets were true positive and are still valid, we highly recommend you to revoke them.
While these secrets were previously flagged, we no longer have a reference to the
specific commits where they were detected. Once a secret has been leaked into a git
repository, you should consider it compromised, even if it was deleted immediately.
Find here more information about risks.


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

@JSmithRobotics
JSmithRobotics changed the base branch from main to feat/claude-profile September 4, 2026 10:19
@JSmithRobotics
JSmithRobotics force-pushed the feat/claude-profiles-list branch from db4d3e3 to c920883 Compare September 4, 2026 10:20
@codecov

codecov Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 77.24551% with 76 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.62%. Comparing base (e0c614e) to head (c920883).

Files with missing lines Patch % Lines
rust/dl/src/commands.rs 0.00% 65 Missing ⚠️
rust/devlaunch-core/src/flows/claude_profiles.rs 97.23% 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.89% <77.24%> (-0.15%) ⬇️

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

Components Coverage Δ
shipped code (rust) 94.89% <77.24%> (-0.15%) ⬇️
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.

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.
@JSmithRobotics

Copy link
Copy Markdown
Collaborator Author

Superseded by #572, which is the same branch opened from this repository rather than from a fork.

Moved because a cross-fork pull request's base has to be a branch in the base repository, so the stack needed its parents pushed here anyway -- which left three branch names living on two remotes, two of them serving as one PR's head and another's base at the same time. Every update then had to reach both remotes or a diff would quietly misrepresent itself. Same commits, one ref each, and #570 to #573 are now a native GitHub stack.

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.

1 participant