feat: --claude-profiles --json says what a machine actually has - #650
JSmithRobotics wants to merge 3 commits into
Conversation
Reviewer's GuideThe PR adds --claude-profiles --json as a machine-readable representation built from the existing profile summaries, including safe bounded usage-snapshot forwarding and explicit account/state metadata, while preserving table behavior and making JSON fail non-zero when an existing profiles directory is unreadable. Sequence diagram for machine-readable Claude profile listingsequenceDiagram
participant Caller
participant CLI
participant Profiles as ClaudeProfiles
participant FS as ProfileFilesystem
Caller->>CLI: --claude-profiles --json
CLI->>Profiles: from_process()
Profiles->>FS: Read profile directories and credentials
Profiles->>FS: Read usage-snapshot.json for authed profiles
FS-->>Profiles: Bounded snapshot bytes or null
Profiles-->>CLI: ProfileSummary rows
CLI->>Profiles: json_document(rows)
Profiles-->>CLI: JSON array
CLI-->>Caller: JSON rows with state, account, sharing, usageSnapshot
Flow diagram for safe usage snapshot forwardingflowchart TD
A[Authed profile] --> B[Join exact filename usage-snapshot.json]
B --> C{Path is a regular file?}
C -- No --> D[usageSnapshot: null]
C -- Yes --> E[Open read-only]
E --> F[Read at most 64 KiB]
F --> G[Lossy UTF-8 decode]
G --> H[Forward exact snapshot text]
I[No credential] --> D
J[Temp file .usage-snapshot.json.pid] --> K[Ignored]
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 1 issue
Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments
### Comment 1
<location path="rust/dl/src/commands.rs" line_range="499-502" />
<code_context>
+/// should be) instead of shaping a process environment every other test in the binary
+/// shares.
+fn profiles_root_read_error(root: &Path) -> Option<std::io::Error> {
+ match std::fs::read_dir(root) {
+ Ok(_) => None,
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
+ Err(error) => Some(error),
+ }
}
</code_context>
<issue_to_address>
**issue (bug_risk):** `profiles_root_read_error` only verifies that `read_dir` can open the directory; `summarise` then consumes the iterator with `entries.flatten()`, silently discarding any later directory-entry read error. JSON mode therefore prints a partial listing or `[]` and exits 0 even though reading the existing profiles directory failed.
**Triggers:** When opening the profiles directory succeeds but iterating its entries later returns an I/O error.
**Suggested fix:** Consume the directory iterator in the error-checking path and propagate an error if any entry read fails, using that result both for the listing and the JSON exit status.
</issue_to_address>
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:
|
corral keeps an allowlist of Claude profile names on the gateway while the
directories they name live on the target. Those are two copies of one fact and
they drifted: the gateway went on offering profiles the target no longer had,
and a launch naming one fell back to forwarding the host's own login instead of
refusing. The operator saw a successful launch using the wrong credential.
Reading the machine is the fix, and this is what makes reading it reliable.
`--claude-profiles` already knew the answer; it could only say it in a table.
The shape distinguishes the three readings the table's account column already
has, without inventing a fourth:
- `state` is `authed` or `not-logged-in`, mirroring `ProfileState`;
- `account` is null both when there is no credential and when there is one
that names nobody, so `authed` with a null account is the table's
"unknown";
- `default` is a flag rather than a name comparison, because that row is
pushed into the listing whether or not its directory exists and a consumer
must not infer that from the string.
The state strings are spelled independently of the table's, so a test diffs
the two renderings over every state the columns distinguish -- this repo's rule
about a second hand-maintained copy of a fact.
`--json` is now required to accompany `--ls` or `--claude-profiles`, expressed
as a clap group rather than a hand-rolled check, so asking for it anywhere else
is refused by the parser with the alternatives named.
Claude-Session: https://claude.ai/code/session_01AdSFnBdxie6TosHVmjLY28
A gateway forwarding a Claude login already asks this host what it has over one ssh round trip; usage data was a second round trip away. Reading usage-snapshot.json beside the credential (never globbed, capped at 64KiB, only when the profile is authed, same rule as the account field) and adding it to the wire as an always-present `usageSnapshot` key lets a caller get both in the one call. The key is never omitted, because a missing key and a null value answer different questions: missing means this dl predates the field, null means it looked and found nothing -- collapsing the two would let an un-upgraded host report zero usage with the same shape as a host that genuinely has none. Also makes `--claude-profiles --json` exit non-zero when the profiles directory exists but can't be read, so "couldn't look" stops being indistinguishable from "nothing there" for a script that isn't reading stderr. Claude-Session: https://claude.ai/code/session_01AdSFnBdxie6TosHVmjLY28
e64c666 to
b4e9db9
Compare
`profiles_root_read_error` asked only whether the directory could be OPENED. Each entry is a second fallible read, and `claude_profiles::summarise` consumes them with `entries.flatten()`, which drops a failing one in silence. So the check reported "fine" for the exact outcome it exists to catch: a listing short a profile, no warning, exit 0 -- "a host with five profiles being told it has none", arrived at one entry at a time instead of all at once. It now walks the iterator and returns the first entry error. Walking it twice, here and in `summarise`, is deliberate: `summarise` is pure and has no channel to report this, and widening its return type for a case only the binary can print would charge every caller for it. The directory holds one entry per Claude login. The arm has no test and the doc says so. A per-entry readdir failure is not something a portable unit test can provoke -- removing entries mid-walk does not error, nor does a non-UTF-8 name, and a stale NFS handle cannot be arranged from inside the suite. Reported by review on #650. Claude-Session: https://claude.ai/code/session_01AdSFnBdxie6TosHVmjLY28
|
Fixed in 4724f98, and the finding was correct.
It now walks the iterator and returns the first entry error, which both prints the warning and gives Two notes on the shape, since the suggestion offered a choice: Walking the directory twice — here and again in The arm has no test and the doc says so rather than implying coverage. A per-entry Also rebased onto 0.59.2. |
What this adds
--claude-profilesgained an account column in #572. This adds--jsonto it, so a tool can read what a machine has rather than parsing a table meant for a person:[{"name":"bear","default":false,"state":"authed", "account":{"email":"...","organization":"...","seatTier":"max"}, "sharesAccountWith":["kinisi"], "usageSnapshot":"{\"captured_at\":...}"}]The motivating case is a machine you are not sitting at. Deciding which profile to launch a workspace with means knowing which profiles that host actually has, which of them are logged in, and which account each one is, and
ssh host dl --claude-profilesreturning a table is the wrong shape for that.Field notes
stateis"authed"or"not-logged-in".accountis null both when there is no credential and when the credential names nobody. Those are different situations and the table already distinguished them; the JSON keepsstatefor that reason.defaultis a flag rather than a name comparison, so the default row is identifiable without the caller reimplementing the resolution rules.usageSnapshotcarries the raw text of the profile'susage-snapshot.json, or null. It is filled only for an authed profile, mirroring the existing rule foraccount: a logged-out profile must not surface a snapshot belonging to the account that used to be signed in.usageSnapshotis always serialised, with noskip_serializing_if. A missing key means "thisdlis too old to report usage" and a null means "no snapshot file exists". Collapsing those would make an un-upgraded machine confidently report zero usage instead of saying it cannot tell.The snapshot is read by exact filename, never globbed (the writer uses temp-then-rename, so
.usage-snapshot.json.<pid>files sit alongside it), with anis_file()check before opening so a FIFO cannot hang the read, and a 64 KiB cap. It never writes, and never opensprojects/or the credential file's contents.One behaviour change beyond the new field
In
--jsonmode, an existing-but-unreadable profiles directory now exits non-zero. It previously warned on stderr and exited 0, which made "I could not look" indistinguishable from "there is nothing there" to any caller reading the JSON. The table path is unchanged: it keeps its stderr notice for a person and its exit code.Testing
Six tests on the snapshot read (exact bytes returned, absent file, no-credential profile with a snapshot on disk, the temp-written file ignored, oversize truncation, and the key always present on the wire) and six on the exit-code split.
public-api.rest.txtis regenerated for the one new public field.cargo fmt --check,cargo clippy --locked --all-targets -- -D warnings, and the README/citation/prose guards all pass.Summary by Sourcery
Add machine-readable Claude profile discovery with account and usage information for reliable remote profile selection.
New Features:
--claude-profiles, including profile state, default status, account details, shared-account relationships, and usage snapshots.Bug Fixes:
Enhancements:
Documentation:
Tests: