feat: dl --claude-profiles names the account behind each profile - #567
feat: dl --claude-profiles names the account behind each profile#567JSmithRobotics wants to merge 4 commits into
Conversation
Reviewer's GuideAdds 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 profilesequenceDiagram
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
Sequence diagram for account-aware Claude profile listingsequenceDiagram
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
Flow diagram for Claude credential precedence and profile refusalflowchart 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]
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
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>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| let state = if claude::has_credential(&path) { | ||
| ProfileState::Authed | ||
| } else { | ||
| ProfileState::NoCredential | ||
| }; | ||
| ProfileSummary { | ||
| name, | ||
| path: path.clone(), | ||
| state, | ||
| account: claude::account_at(&path), |
There was a problem hiding this comment.
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.
| 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 | |
| }, |
| # 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}) ) |
There was a problem hiding this comment.
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.
2844c5b to
db4d3e3
Compare
️✅ 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. 🦉 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. |
db4d3e3 to
c920883
Compare
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:
|
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.
c920883 to
b60c6da
Compare
e0c614e to
da3ec2c
Compare
|
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. |
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.
🤖 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:
dl --claude-profilesto list available Claude profiles with authentication state and the account identity associated with each profile.Bug Fixes:
Enhancements:
Documentation:
Tests: