Skip to content
Open
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
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ instead. [docs/cli.md](docs/cli.md) has the full `--rm` contract, including whic
| `--herdr-workspace ID` | Target a workspace when using `--herdr-env` |
| `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 --claude-profiles --json` | The same, machine-readable, for a caller (such as corral) asking this host what it actually has rather than trusting its own copy of the list |
| `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 Expand Up @@ -361,7 +362,14 @@ holding the `.credentials.json` that a `claude` login writes. Each is a `CLAUDE_
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_CONFIG_DIR=~/.claude-profiles/work claude`, then log in. `dl --claude-profiles --json`
answers the same question machine-readably, so a tool that names a profile can check this host
actually has it before asking for it. Each authed row also carries `usageSnapshot`: the exact
bytes of a `usage-snapshot.json` sitting beside the credential, or `null` when there is none, so a
gateway can read a profile's usage over the same round trip instead of a second one. The key is
always present, even when its value is `null`, because a missing key and a `null` value mean
different things: missing says this `dl` predates the field, `null` says it looked and found
nothing.

`--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".
Expand Down
2 changes: 2 additions & 0 deletions rust/devlaunch-core/public-api.rest.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1314,6 +1314,7 @@ pub devlaunch_core::flows::claude_profiles::ProfileSummary::name: alloc::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
pub devlaunch_core::flows::claude_profiles::ProfileSummary::usage_snapshot: core::option::Option<alloc::string::String>
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
Expand All @@ -1324,6 +1325,7 @@ pub fn devlaunch_core::flows::claude_profiles::ProfileSummary::fmt(&self, &mut c
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::json_document(&[devlaunch_core::flows::claude_profiles::ProfileSummary]) -> serde_json::value::Value
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
Expand Down
253 changes: 253 additions & 0 deletions rust/devlaunch-core/src/flows/claude_profiles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@

use std::path::{Path, PathBuf};

use serde::Serialize;

use crate::clients::claude;

/// The account behind a profile, at a path a caller outside this crate can name.
Expand All @@ -48,6 +50,23 @@ pub use crate::clients::claude::Account;
/// leaving one to drift.
pub const DEFAULT_PROFILE: &str = "default";

/// The exact filename a usage snapshot is read from, beside the credential.
///
/// Named, not globbed: Claude Code's own writers use temp-then-rename (a file named
/// `.usage-snapshot.json.<pid>` briefly exists beside the real one while a write is in
/// flight), and a glob over the directory would pick up that half-written temp file as
/// though it were the snapshot. Joining this exact name never can.
const USAGE_SNAPSHOT_NAME: &str = "usage-snapshot.json";

/// The most a usage snapshot read will ever return, regardless of the file's real
/// size.
///
/// A cap, not a validation: this crate does not parse the file, so it cannot tell an
/// oversize snapshot from a truncated one and does not try to. It exists so that a
/// gateway relying on one ssh round trip cannot be handed an unbounded amount of data
/// by a file it does not own.
const USAGE_SNAPSHOT_MAX_BYTES: u64 = 64 * 1024;

/// Whether a profile can be launched with.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ProfileState {
Expand Down Expand Up @@ -83,6 +102,19 @@ pub struct ProfileSummary {
/// proves nothing. Decided on `accountUuid`, never on a display field, for that
/// reason.
pub shares_account_with: Vec<String>,
/// The bytes of `usage-snapshot.json` beside the credential, when there is a
/// credential and a file to read.
///
/// `None` covers two different absences a caller does not need told apart: no
/// credential (nobody signed in to have a snapshot) and a credential with no such
/// file yet (nothing has written one). What a caller *does* need told apart is
/// this crate's own absence from an older `dl` that never read the file at all --
/// [`crate::flows::claude_profiles::json_document`] carries that distinction by
/// always emitting the wire key, `null` or not, rather than omitting it.
///
/// Read whole and undecoded: this crate does not parse whatever a usage snapshot
/// holds, only forwards the bytes a gateway asked for over one round trip.
pub usage_snapshot: Option<String>,
}

/// Every profile this host offers, `default` first and the rest by name.
Expand Down Expand Up @@ -234,9 +266,137 @@ fn row(name: String, path: PathBuf) -> ProfileSummary {
// 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.
shares_account_with: Vec::new(),
// Same rule as `account`, and the same reason: a usage snapshot belongs to the
// account that wrote it, and a profile with no credential has no account to
// have written one.
usage_snapshot: authed.then(|| read_usage_snapshot(&path)).flatten(),
}
}

/// The bytes of `usage-snapshot.json` in `dir`, if there is one to read.
///
/// `None` for "no such file" and for "could not be read" alike -- this is a listing,
/// not a diagnostic, and, like [`claude::account_at`] beside it, has no stderr of its
/// own to put a reason on.
///
/// **Never writes, creates or renames anything**, and checks [`Path::is_file`] before
/// opening so that a FIFO or other special file left in the directory cannot hang this
/// read. Reads at most [`USAGE_SNAPSHOT_MAX_BYTES`] and decodes what it got lossily,
/// because this crate does not parse the contents and a snapshot writer using bytes
/// this is not prepared for is not this function's failure to report.
fn read_usage_snapshot(dir: &Path) -> Option<String> {
let path = dir.join(USAGE_SNAPSHOT_NAME);
if !path.is_file() {
return None;
}
let file = std::fs::File::open(&path).ok()?;
let mut capped = std::io::Read::take(file, USAGE_SNAPSHOT_MAX_BYTES);
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut capped, &mut buf).ok()?;
Some(String::from_utf8_lossy(&buf).into_owned())
}

// ---------------------------------------------------------------------------
// the JSON document -- what corral parses instead of trusting its own list
// ---------------------------------------------------------------------------

/// `dl --claude-profiles --json`.
///
/// One row per [`ProfileSummary`] the human listing prints, over the same `rows` --
/// this reads no filesystem of its own, so the table and the document cannot
/// disagree about what a profile is, and there is nothing here for a second
/// listing's worth of drift to hide in.
///
/// Written for corral's gateway (see the module doc): it kept an allowlist of
/// profile names while the directories live here, the two drifted, and a launch
/// naming a profile the gateway offered but this host no longer had fell back to
/// forwarding the host login instead of refusing. Reading this document is the
/// fix, so its three fields are the three things that answer that:
///
/// - `name` is the row at all. A profile the gateway remembers and this host does
/// not is not a row here to begin with -- `summarise` only lists directories
/// [`std::fs::read_dir`] actually found -- so a name missing from this array
/// answers "gone" without a state to interpret.
/// - `state` and `account` together are the three readings the human columns
/// already carry, spelled for a parser instead of a person: `"authed"` with an
/// `account` object is signed in and nameably so; `"authed"` with `account: null`
/// is signed in as somebody this could not name (Claude Code's state file was
/// absent or has moved on from the shape this reads); `"not-logged-in"` (always
/// paired with `account: null`) is a directory nobody has logged in to yet. The
/// pairing is [`ProfileSummary::state`] and [`ProfileSummary::account`]
/// unmodified, not a fourth value invented for the wire.
/// - `default` is true for exactly the row named [`DEFAULT_PROFILE`], which
/// `summarise` pushes whether or not its directory exists at all
/// (`$CLAUDE_CONFIG_DIR`, else `~/.claude`) -- the one row here that is not
/// backed by an entry `read_dir` found. A caller that treated every row as "a
/// directory dl walked" would treat `default`'s absence of one as a fact about
/// the login instead of a fact about how the name resolves; this field says so
/// rather than leaving it to be inferred from the string `"default"`.
///
/// `sharesAccountWith` carries [`ProfileSummary::shares_account_with`] unmodified,
/// for the same reason it is a field there: which names are spare copies of one
/// login is a fact about the whole listing, not about a name alone.
///
/// `usageSnapshot` carries [`ProfileSummary::usage_snapshot`] unmodified, and is
/// **always present as a key**, never dropped for `null` -- unlike every other
/// optional field here. That is deliberate and the opposite default from
/// `account`'s: a caller reading this over one ssh round trip has to be able to tell
/// "this `dl` is old enough that it never read for a snapshot at all" (the key is
/// missing) from "this `dl` looked and there was nothing to find" (the key is
/// present and `null`). Collapsing the two would let an un-upgraded host report zero
/// usage with the same shape as a host that genuinely has none.
pub fn json_document(rows: &[ProfileSummary]) -> serde_json::Value {
serde_json::Value::Array(rows.iter().map(json_row).collect())
}

/// The four fields every row carries, in the order the wire carries them.
#[derive(Debug, Serialize)]
struct RowWire {
name: String,
default: bool,
/// `"authed"` or `"not-logged-in"` -- [`ProfileState`], spelled for the wire
/// rather than for `--help`.
state: &'static str,
account: Option<AccountWire>,
#[serde(rename = "sharesAccountWith")]
shares_account_with: Vec<String>,
/// Always serialised, `null` or not -- see [`json_document`]'s doc comment for
/// why a missing key and a `null` value must stay distinguishable.
#[serde(rename = "usageSnapshot")]
usage_snapshot: Option<String>,
}

/// [`Account`], on the wire. No `accountUuid`: it exists to tell two profiles of
/// one login apart from two that merely look alike, which [`ProfileSummary`]
/// already did on this listing's behalf (`shares_account_with`), so repeating the
/// id here would hand a caller a second, weaker way to reach the same fact.
#[derive(Debug, Serialize)]
struct AccountWire {
email: Option<String>,
organization: Option<String>,
#[serde(rename = "seatTier")]
seat_tier: Option<String>,
}

fn json_row(row: &ProfileSummary) -> serde_json::Value {
let wire = RowWire {
name: row.name.clone(),
default: row.name == DEFAULT_PROFILE,
state: match row.state {
ProfileState::Authed => "authed",
ProfileState::NoCredential => "not-logged-in",
},
account: row.account.as_ref().map(|account| AccountWire {
email: account.email.clone(),
organization: account.organization.clone(),
seat_tier: account.seat_tier.clone(),
}),
shares_account_with: row.shares_account_with.clone(),
usage_snapshot: row.usage_snapshot.clone(),
};
serde_json::to_value(wire).expect("RowWire holds only strings, bools and options of them")
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -529,4 +689,97 @@ mod tests {
let offered: Vec<&str> = rows.iter().map(|row| row.name.as_str()).collect();
assert_eq!(offered, ["alpha", "mid", "zeta"]);
}

#[test]
fn an_authed_profile_with_a_usage_snapshot_returns_its_exact_bytes() {
let root = tempfile::tempdir().expect("a scratch root");
let dir = profile(root.path(), "work", true, None);
std::fs::write(dir.join(USAGE_SNAPSHOT_NAME), r#"{"tokens":123}"#).expect("a snapshot");

let rows = summarise(Some(root.path()), None);
assert_eq!(rows[0].usage_snapshot.as_deref(), Some(r#"{"tokens":123}"#));
}

#[test]
fn an_authed_profile_with_no_usage_snapshot_returns_none() {
let root = tempfile::tempdir().expect("a scratch root");
profile(root.path(), "work", true, None);

let rows = summarise(Some(root.path()), None);
assert_eq!(rows[0].usage_snapshot, None);
}

#[test]
fn a_profile_with_no_credential_names_no_usage_snapshot_even_with_one_on_disk() {
// Same rule as `account`: a snapshot belongs to the account that wrote it, and
// a profile with no credential has no account to have written one.
let root = tempfile::tempdir().expect("a scratch root");
let dir = profile(root.path(), "stale", false, None);
std::fs::write(dir.join(USAGE_SNAPSHOT_NAME), r#"{"tokens":123}"#).expect("a snapshot");

let rows = summarise(Some(root.path()), None);
assert_eq!(rows[0].usage_snapshot, None);
}

#[test]
fn a_temp_written_snapshot_is_not_picked_up() {
// The writer's own temp-then-rename artifact, left beside the real name --
// never globbed for, so it is never mistaken for one.
let root = tempfile::tempdir().expect("a scratch root");
let dir = profile(root.path(), "work", true, None);
std::fs::write(dir.join(".usage-snapshot.json.123"), r#"{"tokens":123}"#)
.expect("a temp file");

let rows = summarise(Some(root.path()), None);
assert_eq!(rows[0].usage_snapshot, None);
}

#[test]
fn an_oversize_usage_snapshot_is_truncated_at_the_cap() {
let root = tempfile::tempdir().expect("a scratch root");
let dir = profile(root.path(), "work", true, None);
let oversize = "a".repeat(USAGE_SNAPSHOT_MAX_BYTES as usize + 1024);
std::fs::write(dir.join(USAGE_SNAPSHOT_NAME), &oversize).expect("a snapshot");

let rows = summarise(Some(root.path()), None);
let read = rows[0].usage_snapshot.as_ref().expect("a truncated read");
assert_eq!(read.len(), USAGE_SNAPSHOT_MAX_BYTES as usize);
}

#[test]
fn the_wire_always_carries_the_usage_snapshot_key() {
let root = tempfile::tempdir().expect("a scratch root");
let dir = profile(root.path(), "work", true, None);
std::fs::write(dir.join(USAGE_SNAPSHOT_NAME), r#"{"tokens":123}"#).expect("a snapshot");
profile(root.path(), "fresh", false, None);

let rows = summarise(Some(root.path()), None);
let document = json_document(&rows);
let by_name = |name: &str| -> &serde_json::Value {
document
.as_array()
.expect("an array")
.iter()
.find(|row| row["name"] == name)
.unwrap_or_else(|| panic!("{name} missing from {document:?}"))
};
// Present and non-null for the profile that has one.
assert_eq!(by_name("work")["usageSnapshot"], r#"{"tokens":123}"#);
// Present, but null, for a profile with nothing to report -- the key must
// never simply be absent, which is what would make an un-upgraded `dl`
// indistinguishable from a host with no usage at all.
assert!(
by_name("work")
.as_object()
.unwrap()
.contains_key("usageSnapshot")
);
assert!(
by_name("fresh")
.as_object()
.unwrap()
.contains_key("usageSnapshot")
);
assert!(by_name("fresh")["usageSnapshot"].is_null());
}
}
Loading
Loading