Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
a `default/` directory, so a picker has something to select and a recalled line has a
way to say "not the profile I used last time".

- **`dl --claude-profiles` lists those logins 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 failure profiles exist to prevent; the name is what you type and
the account column is what you get. Read from the three fields of `.claude.json` worth
showing (email, organisation, seat tier), and it distinguishes a profile with no
credential from one whose state file says nothing.

**Two names for one directory are one login, not a spare copy of one.** The
redundancy footnote says "all but one are spare", so a group has to mean separate
directories you could delete one of. `CLAUDE_CONFIG_DIR=~/.claude-profiles/work`
makes the `default` row and the `work` row the same directory, the same state file
and the same account id, and grouping on the id alone named the only login on the
host; a symlinked profile directory got there the same way, since the walk follows
symlinks. A directory is now counted once, so both rows are still listed with the
same account against each and no deletion is advised. `path` is not in the listing,
which is why nothing on screen let a reader catch this.

**A profiles directory that cannot be read says so** instead of listing as a host
with no profiles. Only `NotFound` is silent, because a root nothing has created yet
is the ordinary state of most hosts; a root that is unreadable, or a plain file where
the directory should be, told a host with five profiles that it had none.

**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. It is also
the way to find a profile created and never logged in to, since a launch naming one of
those refuses.

It also names the profiles that are **two names for one account**, which is 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. Grouped on the account's own id and never on a display
field, since a shared organisation is two people; a profile naming no account joins no
group, because two blanks are not the same account. Said once per group as a footnote
rather than per row as a column, because it is a fact about a pair.

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.

**A named profile that holds no credential stops the launch, and that refusal is the
feature.** It does not fall back to the default login. Two accounts on one machine is
what profiles are for, so a typo that silently forwarded the other one would be worse
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ instead. [docs/cli.md](docs/cli.md) has the full `--rm` contract, including whic
| `dl --purge` | Remove devlaunch's own workspaces and caches |
| `dl --install` | Install shell completions, and the `dl-herdr-shell` name a herdr pane opens through |
| `dl --refresh` | Rebuild the completion cache now |
| `dl --claude-profiles` | List the Claude logins `--claude-profile` can name, and the account each is signed in as |
| `dl --version` | Print the version |
| `dl --herdr-shell` | The shell a new [herdr](https://herdr.dev) pane opens: inside the workspace its tab holds, or on this host |
| `dl --help`, `-h` | Print help |
Expand Down
41 changes: 41 additions & 0 deletions docs/workspace-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,47 @@ next.
a `default/` directory. It exists as a word because a picker needs something to
select, and a recalled line needs a way to say "not the profile I used last time".

### Seeing which account a profile actually holds

```
$ dl --claude-profiles
NAME STATE ACCOUNT
default authed someone@example.com · Someorg · team_tier_1
work authed someone@work.example · Workorg · team_tier_1
spare authed someone@example.com · Someorg · team_tier_1
fresh not logged in -

'default', 'spare' are the same account, so all but one are spare.
```

That last line is 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. It is decided
on the account's own id and never on a display field, because a shared organisation is
two people and a shared name is nothing at all. A profile whose state file names no
account joins no group, so no claim is made about a blank.

**A profile's name is chosen by you and verified by nothing**, which is the reason
this listing exists. A profile called `work` holding a personal login reads as correct
right up until the work is pushed from the wrong identity, and that is the failure
profiles are meant to prevent. The name is what you type; the account column is what
you get.

The account is read from `.claude.json`, the state file Claude Code keeps inside each
config directory, and only three of its fields: the email address, the organisation
and the seat tier. Nothing else is read and nothing is written. A `-` is a profile
with no credential and so nobody to name; `unknown` is one that is logged in whose
state file is absent or has moved on from the shape this reads, which stops nothing.

**No token is read to build this.** The `authed` column is the credential file's
existence, never its contents, so a listing has not touched a secret.
`--claude-profiles` is also the honest way to find a profile created and never logged
in to, since a launch naming one of those refuses.

A name beginning with a dot is neither offered nor accepted. A `<root>/*/` glob does
not match a dot-directory, so neither this listing nor the shell completion would show
one, and a profile you can launch but never see is a trap rather than a feature.

**A named profile that holds no credential stops the launch.** It does not fall back
to your default login, and that refusal is the feature rather than a rough edge. Two
accounts on one machine is what profiles are for, so a typo that silently forwarded
Expand Down
26 changes: 23 additions & 3 deletions rust/devlaunch-core/completions/dl.bash
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ _dl_completion() {
# The retired spellings (--stop, --autorm) are absent by rule rather than by
# hand: the grammar marks them `hide = true`, and the test drops every hidden
# flag, so a spelling this build only still answers for is never offered.
local global_opts="--ls --install --refresh --prune --reconcile --purge --herdr-shell --rm --devcontainer --claude-profile --help -h --version"
local global_opts="--ls --install --refresh --prune --reconcile --purge --herdr-shell --rm --devcontainer --claude-profile --claude-profiles --help -h --version"
if [[ "$cmd" == aid ]]; then
global_opts="--claude --codex --gemini --devcontainer --claude-profile --help -h --version"
fi
Expand Down Expand Up @@ -94,12 +94,32 @@ _dl_completion() {
# 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
local profiles="default" pdir pname
if [[ -d "${profiles_root}" ]]; then
for pdir in "${profiles_root}"/*/; do
[[ -d "$pdir" ]] || continue
pdir="${pdir%/}"
profiles+=" ${pdir##*/}"
pname="${pdir##*/}"
# `ProfileName::parse`'s grammar, a second time: one directory
# component of ASCII letters, digits, '.', '_' and '-', not starting
# with '.' or '-'. Offering more than that is offering a completion the
# launch refuses -- press tab, get `-flag` or `my profile`, and the
# refusal is about a name you did not type by hand.
#
# The glob already hides the leading dot (it matches no dot-directory),
# so the visible half of this is the leading '-' and the character set.
# Both are checked anyway rather than relying on the glob, because the
# rule is what has to agree and not the accident that enforces part of
# it. `test_the_completion_offers_only_names_a_launch_accepts` in
# test_bash_completion.py is the diff that keeps the two in step.
# The character set is spelled out rather than written as ranges,
# and that is not fussiness: `[[ =~ ]]` honours LC_COLLATE, so
# `[A-Za-z]` matches `é` in a UTF-8 locale and this offered
# `unicode-é` while `ProfileName::parse` -- which asks
# `is_ascii_alphanumeric` -- refuses it. The test below caught it.
[[ "$pname" =~ ^[abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-]+$ ]] || continue
[[ "$pname" == [-.]* ]] && continue
profiles+=" ${pname}"
done
fi
COMPREPLY=( $(compgen -W "${profiles}" -- ${cur}) )
Expand Down
47 changes: 47 additions & 0 deletions rust/devlaunch-core/public-api.rest.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1093,6 +1093,53 @@ pub fn devlaunch_core::flows::branch_manager::BranchError::eq(&self, &devlaunch_
impl core::fmt::Debug for devlaunch_core::flows::branch_manager::BranchError
pub fn devlaunch_core::flows::branch_manager::BranchError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
impl core::marker::StructuralPartialEq for devlaunch_core::flows::branch_manager::BranchError
pub mod devlaunch_core::flows::claude_profiles
pub enum devlaunch_core::flows::claude_profiles::ProfileState
pub devlaunch_core::flows::claude_profiles::ProfileState::Authed
pub devlaunch_core::flows::claude_profiles::ProfileState::NoCredential
impl core::clone::Clone for devlaunch_core::flows::claude_profiles::ProfileState
pub fn devlaunch_core::flows::claude_profiles::ProfileState::clone(&self) -> devlaunch_core::flows::claude_profiles::ProfileState
impl core::cmp::Eq for devlaunch_core::flows::claude_profiles::ProfileState
impl core::cmp::PartialEq for devlaunch_core::flows::claude_profiles::ProfileState
pub fn devlaunch_core::flows::claude_profiles::ProfileState::eq(&self, &devlaunch_core::flows::claude_profiles::ProfileState) -> bool
impl core::fmt::Debug for devlaunch_core::flows::claude_profiles::ProfileState
pub fn devlaunch_core::flows::claude_profiles::ProfileState::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
impl core::marker::Copy for devlaunch_core::flows::claude_profiles::ProfileState
impl core::marker::StructuralPartialEq for devlaunch_core::flows::claude_profiles::ProfileState
pub struct devlaunch_core::flows::claude_profiles::Account
pub devlaunch_core::flows::claude_profiles::Account::account_uuid: core::option::Option<alloc::string::String>
pub devlaunch_core::flows::claude_profiles::Account::email: core::option::Option<alloc::string::String>
pub devlaunch_core::flows::claude_profiles::Account::organization: core::option::Option<alloc::string::String>
pub devlaunch_core::flows::claude_profiles::Account::seat_tier: core::option::Option<alloc::string::String>
impl devlaunch_core::flows::claude_profiles::Account
pub fn devlaunch_core::flows::claude_profiles::Account::is_empty(&self) -> bool
impl core::clone::Clone for devlaunch_core::flows::claude_profiles::Account
pub fn devlaunch_core::flows::claude_profiles::Account::clone(&self) -> devlaunch_core::flows::claude_profiles::Account
impl core::cmp::Eq for devlaunch_core::flows::claude_profiles::Account
impl core::cmp::PartialEq for devlaunch_core::flows::claude_profiles::Account
pub fn devlaunch_core::flows::claude_profiles::Account::eq(&self, &devlaunch_core::flows::claude_profiles::Account) -> bool
impl core::default::Default for devlaunch_core::flows::claude_profiles::Account
pub fn devlaunch_core::flows::claude_profiles::Account::default() -> devlaunch_core::flows::claude_profiles::Account
impl core::fmt::Debug for devlaunch_core::flows::claude_profiles::Account
pub fn devlaunch_core::flows::claude_profiles::Account::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
impl core::marker::StructuralPartialEq for devlaunch_core::flows::claude_profiles::Account
pub struct devlaunch_core::flows::claude_profiles::ProfileSummary
pub devlaunch_core::flows::claude_profiles::ProfileSummary::account: core::option::Option<devlaunch_core::flows::claude_profiles::Account>
pub devlaunch_core::flows::claude_profiles::ProfileSummary::name: alloc::string::String
pub devlaunch_core::flows::claude_profiles::ProfileSummary::path: std::path::PathBuf
pub devlaunch_core::flows::claude_profiles::ProfileSummary::shares_account_with: alloc::vec::Vec<alloc::string::String>
pub devlaunch_core::flows::claude_profiles::ProfileSummary::state: devlaunch_core::flows::claude_profiles::ProfileState
impl core::clone::Clone for devlaunch_core::flows::claude_profiles::ProfileSummary
pub fn devlaunch_core::flows::claude_profiles::ProfileSummary::clone(&self) -> devlaunch_core::flows::claude_profiles::ProfileSummary
impl core::cmp::Eq for devlaunch_core::flows::claude_profiles::ProfileSummary
impl core::cmp::PartialEq for devlaunch_core::flows::claude_profiles::ProfileSummary
pub fn devlaunch_core::flows::claude_profiles::ProfileSummary::eq(&self, &devlaunch_core::flows::claude_profiles::ProfileSummary) -> bool
impl core::fmt::Debug for devlaunch_core::flows::claude_profiles::ProfileSummary
pub fn devlaunch_core::flows::claude_profiles::ProfileSummary::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
impl core::marker::StructuralPartialEq for devlaunch_core::flows::claude_profiles::ProfileSummary
pub const devlaunch_core::flows::claude_profiles::DEFAULT_PROFILE: &str
pub fn devlaunch_core::flows::claude_profiles::from_process() -> alloc::vec::Vec<devlaunch_core::flows::claude_profiles::ProfileSummary>
pub fn devlaunch_core::flows::claude_profiles::summarise(core::option::Option<&std::path::Path>, core::option::Option<&std::path::Path>) -> alloc::vec::Vec<devlaunch_core::flows::claude_profiles::ProfileSummary>
pub mod devlaunch_core::flows::completion
pub enum devlaunch_core::flows::completion::FileState
pub devlaunch_core::flows::completion::FileState::AlreadyCurrent
Expand Down
Loading
Loading