Skip to content
Closed
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
47 changes: 47 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **`dl --claude-profile <name>` forwards a named Claude login instead of the
default one**, for the case one credential cannot serve: two accounts on one
machine, and a workspace that wants the one your host is not signed in to. Profiles
are directories under `~/.claude-profiles/`, or wherever `CLAUDE_PROFILES_DIR` points,
each holding the `.credentials.json` a `claude` login writes and each a
`CLAUDE_CONFIG_DIR` of its own, which is what makes the logins independent.

**That layout and that variable belong to the tool managing the profiles, and `dl`
only reads them.** The first version of this invented a devlaunch-shaped root under
the config directory, which was a third location for one concept and would have asked
anyone with working profiles to log every account in again somewhere new. There is no
writer here: creating, seeding and deleting a profile stay with whatever made the
directory. `DEVLAUNCH_CLAUDE_PROFILES_DIR` still wins over `CLAUDE_PROFILES_DIR`, so a
scratch run reads its own profiles rather than the real credentials.

`--claude-profile default` resolves the login you would get anyway and never consults
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".

**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
than a launch that fails: the launch you see, and the wrong account you find out about
later and somewhere else. The name is checked at the boundary as a single directory
component, so `--claude-profile ../../etc` is refused by the rule rather than becoming
a traversal that fails later on a read.

Read above an exported `CLAUDE_CODE_OAUTH_TOKEN`, unlike `$CLAUDE_CONFIG_DIR`, because
a profile was typed on this command line for this launch and nothing ambient should
beat an explicit argument. `DEVLAUNCH_NO_CLAUDE_TOKEN` still comes first: a machine
that has opted out has no account to choose.

**Not stored with the workspace**, unlike `--devcontainer`, so no workspace can
quietly forward an account chosen weeks ago. Profiles live outside everything devlaunch owns
so `--purge` and `--prune`, which walk devlaunch's cache, were never in reach of a
login. Completion offers the profiles that exist plus `default`, read off the disk
rather than the completion cache, because a profile made a minute ago has to complete
now.

It is not the claude.ai account a container's `claude` is paired to for Remote
Control, and it does not weaken the check that leaves a repo's own mounted Claude
config alone: `Foreign` forwards nothing, profile or no profile. `aid` passes the flag
through. A verb that forwards no login says it is ignoring it, as `--devcontainer`
does; a global command refuses it.

### Fixed

- **`$CLAUDE_CONFIG_DIR` is now honoured on the host, so a host that has moved its
Expand Down
32 changes: 29 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,29 @@ dl 0.29.0
Projects with several variants, compose sidecars, or a host-side `initializeCommand` are covered
in [docs/devcontainer-projects.md](docs/devcontainer-projects.md).

`--claude-profile <name>` forwards a named Claude login instead of the default one, for
workspaces where you want a different account than the one `claude` on your host is signed in to:

```bash
dl blooop/devlaunch --claude-profile work
```

Profiles live in `~/.claude-profiles/<name>/`, or wherever `CLAUDE_PROFILES_DIR` points, each
holding the `.credentials.json` that a `claude` login writes. Each is a `CLAUDE_CONFIG_DIR` of its
own, which is what makes the logins independent. `dl` reads that layout rather than inventing one,
so profiles you already have work with no re-login, and it never writes there: creating and
deleting them stays with whatever made the directory. By hand it is
`CLAUDE_CONFIG_DIR=~/.claude-profiles/work claude`, then log in.

`--claude-profile default` means the login you would get anyway, so a recalled line has a way to
say "not the profile I used last time".

Unlike `--devcontainer` it is **not** stored with the workspace, so it applies to the launch you
typed it on and no workspace ever forwards an account chosen weeks ago. A name that holds no
credential stops the launch and says so rather than falling back to your default login, which is
the whole point of naming one. [docs/workspace-tools.md](docs/workspace-tools.md) has the
precedence order and what a profile does not change.

`dl --help` is the complete reference and is kept in step with the binary by a test.

## aid: an agent instead of a shell
Expand Down Expand Up @@ -318,6 +341,7 @@ the question and launches one-shot, so scripts behave as they always have.
| `--no-remote-control`, `--no-remote` | Start a plain local session. Remote Control is on by default for `claude`: the session is named after the workspace and can be read and steered from claude.ai/code or the Claude app. It needs a claude.ai login in the container |
| `--remote-control`, `--remote` | Ask for Remote Control by name. `claude` has it already; beside `--codex` or `--gemini` this says they have not got it and stops |
| `--devcontainer <variant\|path>` | Passed through to `dl` |
| `--claude-profile <name>` | Passed through to `dl`: which host Claude login to forward. Not the claude.ai account the container's `claude` is paired to for Remote Control |

**The trade, stated plainly.** `claude` starts with `--dangerously-skip-permissions`, because the
agent is already inside a disposable container holding only this repo, and the per-tool prompts
Expand Down Expand Up @@ -418,11 +442,13 @@ Images are yours: `docker system df` is what shows those.
| `DEVLAUNCH_TIMING=1\|json` | Write a timing summary to stderr. See [docs/performance.md](docs/performance.md) |
| `DEVPOD_SSH_CONFIG=<path>` | devpod's own, honoured rather than set: it is where `devpod up` publishes host aliases, so it is where `dl` looks for them. See [docs/cli.md](docs/cli.md) |
| `CLAUDE_CONFIG_DIR=<path>` | Claude Code's own, honoured rather than set: it is where the host keeps its Claude configuration, so it is where `dl` reads the login to forward. It replaces `~/.claude` rather than being tried before it, exactly as Claude Code treats it. See [docs/workspace-tools.md](docs/workspace-tools.md) |
| `DEVLAUNCH_CLAUDE_PROFILES_DIR=<path>` | Where `--claude-profile` looks, and it wins over the row below. For scoping a scratch run away from real credentials |
| `CLAUDE_PROFILES_DIR=<path>` | Honoured rather than set: the profile directory `claude-as` manages. Defaults to `~/.claude-profiles`. Nothing `dl` deletes reaches it |

Every switch here reads the same values: anything but empty, `0`, `false` or `no` counts as
set. On a "no" variable that means turn it off; on an opt-in one it means turn it on. Six
rows are not switches and do not follow it: `DEVLAUNCH_AID_AGENT`, `DEVPOD_SSH_CONFIG` and
`CLAUDE_CONFIG_DIR` take a value, `DEVLAUNCH_TIMING` counts only empty and `0` as off, so `false` and `no`
set. On a "no" variable that means turn it off; on an opt-in one it means turn it on. Eight
rows are not switches and do not follow it: `DEVLAUNCH_AID_AGENT`, `DEVPOD_SSH_CONFIG`,
`CLAUDE_CONFIG_DIR`, `DEVLAUNCH_CLAUDE_PROFILES_DIR` and `CLAUDE_PROFILES_DIR` take a value, `DEVLAUNCH_TIMING` counts only empty and `0` as off, so `false` and `no`
turn it on, `DEVLAUNCH_AID_REMOTE_CONTROL` takes `1`/`true`/`on`/`yes` or
`0`/`false`/`off`/`no` and refuses anything else rather than guessing, and `HERDR_AGENT` is
the one written rather than read, so a value of your own survives only a line that starts
Expand Down
68 changes: 68 additions & 0 deletions docs/workspace-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,74 @@ set it and Claude Code honours it there too. The host side read `~/.claude`
regardless, so a host that had moved its configuration reported itself as not
logged in while holding a perfectly good login.

### Naming a profile

`--claude-profile <name>` forwards a named login instead of the default one, for the
case the order above cannot serve: two accounts on one machine, and a workspace
that wants the one your host is not signed in to.

```bash
dl owner/repo --claude-profile work
```

The name is one directory under `~/.claude-profiles/`, holding the
`.credentials.json` a `claude` login writes. Each such directory is a
`CLAUDE_CONFIG_DIR` of its own, which is what makes the logins independent.

**That is somebody else's directory and `dl` only reads it.** The layout and the
`CLAUDE_PROFILES_DIR` variable belong to the tool that manages them, honoured here
rather than set, the same arrangement `dl` has with devpod's own
`DEVPOD_SSH_CONFIG`. An earlier version of this feature invented a devlaunch-shaped
root under the config directory, and that was wrong: it made a third location for one
concept and would have asked anyone with working profiles to log every account in
again somewhere new.

So there is no writer. Creating a profile, seeding the config it shares with your main
login, and deleting it belong to whatever made the directory; `dl` reads one file out
of it. By hand that is `CLAUDE_CONFIG_DIR=~/.claude-profiles/work claude`, then a
login.

Nothing `dl` deletes can reach them. `dl --purge` removes devlaunch's cache entire and
`dl --prune` walks the clones inside it, and a login was never in either path.

Two variables, in this order: `DEVLAUNCH_CLAUDE_PROFILES_DIR` is devlaunch's own and
wins, which is what lets a scratch run read and complete its own profiles rather than
the real credentials; `CLAUDE_PROFILES_DIR` is the managing tool's and is honoured
next.

`--claude-profile default` resolves the login you would get anyway and never consults
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".

**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
the other one would be worse than a launch that fails: the launch you see, and the
wrong account you find out about later and somewhere else. The opt-out still comes
first, because a machine that has opted out has no account to choose.

It is read **above** an exported `CLAUDE_CODE_OAUTH_TOKEN`, unlike
`$CLAUDE_CONFIG_DIR`, and the difference is where the two come from. A profile was
typed on this command line for this launch, so nothing ambient should beat it, and a
nested `dl` naming a profile is overriding exactly the token it inherited.

**Not stored with the workspace**, unlike `--devcontainer`. A profile describes this
session and not the container, so storing it would mean a workspace quietly
forwarding an account chosen weeks ago. Pass it per launch, or export
`CLAUDE_CODE_OAUTH_TOKEN` in a shell profile if that is the shape you want.

Two things it does not change. It is not the claude.ai account the container's own
`claude` is paired to for **Remote Control**, which lives in the container with the
rest of the agent's state, so `aid --claude-profile work` still lists its session
under whichever account the container is signed in to. And it does not weaken the
check below: a repo whose devcontainer owns its Claude config forwards nothing,
profile or no profile.

A verb that forwards no login at all (`stop`, `kill`, `rm`, `rme`) says it is
ignoring the flag rather than failing, the way `--devcontainer` does there. A global
command such as `--ls` refuses it outright, because there is no workspace for it to
be about.

### A variable, not the credential file

Claude Code authenticates from `CLAUDE_CODE_OAUTH_TOKEN` alone, with an otherwise
Expand Down
19 changes: 16 additions & 3 deletions rust/aid/src/rewrite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,9 +161,22 @@ const REMOTE_CONTROL_NO: &[&str] = &["0", "false", "off", "no"];
///
/// aid splits its own command line before handing it to dl and has to tell such a
/// value from the workspace spec. Python keeps the list in `dl.py`
/// (`DL_VALUE_OPTIONS`) next to the parsing it describes, and it is one entry long;
/// here it is the one thing aid knows about dl's grammar.
const DL_VALUE_OPTIONS: &[&str] = &["--devcontainer"];
/// (`DL_VALUE_OPTIONS`) next to the parsing it describes; here it is the one thing
/// aid knows about dl's grammar.
///
/// A flag missing from this list is not passed through wrongly, it is read as *prompt
/// text*, because everything after the workspace is the prompt. `aid --claude-profile
/// work owner/repo fix it` would send `dl` a prompt beginning "work". That is why the
/// list is here rather than inferred.
///
/// **`--claude-profile` chooses which host credential `dl` forwards, and nothing
/// else.** It is not the claude.ai login the container's own `claude` is paired to
/// for Remote Control, which lives in the container with the rest of the agent's
/// state (docs/cli.md, "It needs a claude.ai login inside the workspace"). So
/// `aid --claude-profile work` still lists its session under whichever account the
/// container is signed in to. Two credentials, and this one is the token forwarded
/// into the session.
const DL_VALUE_OPTIONS: &[&str] = &["--devcontainer", "--claude-profile"];

/// The modifier the suffix options take, peeled only in their company.
///
Expand Down
34 changes: 30 additions & 4 deletions rust/devlaunch-core/completions/dl.bash
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,9 @@ _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 --help -h --version"
local global_opts="--ls --install --refresh --prune --reconcile --purge --herdr-shell --rm --devcontainer --claude-profile --help -h --version"
if [[ "$cmd" == aid ]]; then
global_opts="--claude --codex --gemini --devcontainer --help -h --version"
global_opts="--claude --codex --gemini --devcontainer --claude-profile --help -h --version"
fi

# Workspace subcommands
Expand All @@ -77,8 +77,34 @@ _dl_completion() {
# two different requests, docker's `rm` and `run --rm`.
local ws_cmds="up stop kill rm rme code restart recreate reset dotfiles --rm --"

# Options that take a value; a variant name or a path follows them.
local value_opts="--devcontainer"
# Options that take a value; a variant name, a profile name or a path follows.
local value_opts="--devcontainer --claude-profile"

# After --claude-profile, offer the profile directories that exist. Read off the
# disk rather than out of the completion cache, deliberately: profiles are
# created by hand and rarely, the cache is rebuilt by commands that change
# *workspaces*, and a profile you made a minute ago has to complete now. It is one
# readdir of a directory holding a handful of entries.
#
# A mistyped name is a hard refusal at launch rather than a fallback to the default
# login, which is what makes completing these worth the readdir.
if [[ "${prev}" == "--claude-profile" ]]; then
# 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}) )
return 0
fi

# After --devcontainer, offer the repo's variant directories (and paths).
if [[ " ${value_opts} " == *" ${prev} "* ]]; then
Expand Down
2 changes: 2 additions & 0 deletions rust/devlaunch-core/public-api.api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ impl core::marker::StructuralPartialEq for devlaunch_core::clients::devpod_home:
pub struct devlaunch_core::api::Host
impl devlaunch_core::flows::launch::Host
pub fn devlaunch_core::flows::launch::Host::from_process(impl core::convert::Into<std::path::PathBuf>) -> Self
pub fn devlaunch_core::flows::launch::Host::with_claude_profile(self, core::option::Option<alloc::string::String>) -> Self
impl core::clone::Clone for devlaunch_core::flows::launch::Host
pub fn devlaunch_core::flows::launch::Host::clone(&self) -> devlaunch_core::flows::launch::Host
impl core::cmp::Eq for devlaunch_core::flows::launch::Host
Expand Down Expand Up @@ -598,6 +599,7 @@ pub fn devlaunch_core::flows::launch::ColdPath<'r, '_>::recorded(&mut self) -> c
pub struct devlaunch_core::flows::launch::Host
impl devlaunch_core::flows::launch::Host
pub fn devlaunch_core::flows::launch::Host::from_process(impl core::convert::Into<std::path::PathBuf>) -> Self
pub fn devlaunch_core::flows::launch::Host::with_claude_profile(self, core::option::Option<alloc::string::String>) -> Self
impl core::clone::Clone for devlaunch_core::flows::launch::Host
pub fn devlaunch_core::flows::launch::Host::clone(&self) -> devlaunch_core::flows::launch::Host
impl core::cmp::Eq for devlaunch_core::flows::launch::Host
Expand Down
Loading
Loading