Skip to content

feat: --claude-profiles --json says what a machine actually has - #650

Open
JSmithRobotics wants to merge 3 commits into
mainfrom
feat/claude-profiles-json
Open

JSmithRobotics wants to merge 3 commits into
mainfrom
feat/claude-profiles-json

Conversation

@JSmithRobotics

@JSmithRobotics JSmithRobotics commented Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator

What this adds

--claude-profiles gained an account column in #572. This adds --json to 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-profiles returning a table is the wrong shape for that.

Field notes

  • state is "authed" or "not-logged-in".
  • account is 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 keeps state for that reason.
  • default is a flag rather than a name comparison, so the default row is identifiable without the caller reimplementing the resolution rules.
  • usageSnapshot carries the raw text of the profile's usage-snapshot.json, or null. It is filled only for an authed profile, mirroring the existing rule for account: a logged-out profile must not surface a snapshot belonging to the account that used to be signed in.
  • usageSnapshot is always serialised, with no skip_serializing_if. A missing key means "this dl is 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 an is_file() check before opening so a FIFO cannot hang the read, and a 64 KiB cap. It never writes, and never opens projects/ or the credential file's contents.

One behaviour change beyond the new field

In --json mode, 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.txt is 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:

  • Add machine-readable JSON output for --claude-profiles, including profile state, default status, account details, shared-account relationships, and usage snapshots.

Bug Fixes:

  • Return a non-zero exit status in JSON mode when the Claude profiles directory exists but cannot be read, distinguishing read failures from an empty listing.

Enhancements:

  • Expose bounded, read-only usage snapshot data while preserving the distinction between unsupported fields and missing snapshots.
  • Keep table and JSON profile listings based on the same profile summaries and preserve existing table behavior.

Documentation:

  • Document the new machine-readable Claude profile listing and usage snapshot field.

Tests:

  • Add coverage for usage snapshot handling, JSON serialization, profile state and account consistency, default profile marking, empty listings, and unreadable-directory exit behavior.

@sourcery-ai

sourcery-ai Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

The 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 listing

sequenceDiagram
    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
Loading

Flow diagram for safe usage snapshot forwarding

flowchart 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]
Loading

File-Level Changes

Change Details Files
Add machine-readable Claude profile inventory output with account, authentication, default, sharing, and usage data.
  • Introduce a serialized JSON row schema with camelCase fields and explicit authentication states.
  • Expose account metadata and shared-account relationships without exposing account UUIDs.
  • Read the exact usage-snapshot.json file only for authenticated profiles, with regular-file validation, a 64 KiB cap, lossy UTF-8 forwarding, and no writes.
  • Always emit usageSnapshot on supported JSON responses so null remains distinct from an omitted field.
  • Build JSON from the same profile summaries used by table rendering.
rust/devlaunch-core/src/flows/claude_profiles.rs
rust/dl/src/render.rs
README.md
rust/devlaunch-core/public-api.rest.txt
Wire --json into --claude-profiles while preserving existing command validation and table behavior.
  • Add an output mode to the ClaudeProfiles command and mark it as JSON-capable in CLI parsing.
  • Render a JSON array for Claude profiles and retain the existing human-readable table path.
  • Represent an empty JSON listing as [] rather than a stderr sentinel.
  • Add coverage for accepted and rejected flag combinations and JSON/table state agreement.
rust/dl/src/cli.rs
rust/dl/src/commands.rs
rust/dl/src/render.rs
Make JSON callers distinguish an unreadable profiles directory from an empty or absent directory via the process exit status.
  • Detect profiles-root read errors separately from normal nonexistence.
  • Keep the diagnostic and zero exit status for table mode.
  • Return a non-zero status for JSON mode when the existing root cannot be read.
  • Test missing, readable, and invalid-root cases plus both output modes.
rust/dl/src/commands.rs

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment thread rust/dl/src/commands.rs Outdated
@codecov

codecov Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.28125% with 30 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.42%. Comparing base (11d160d) to head (4724f98).

Files with missing lines Patch % Lines
rust/dl/src/commands.rs 57.35% 29 Missing ⚠️
rust/dl/src/render.rs 98.73% 1 Missing ⚠️
Additional details and impacted files
Flag Coverage Δ
python 42.98% <ø> (ø)
rust 94.64% <88.28%> (-0.02%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
shipped code (rust) 94.64% <88.28%> (-0.02%) ⬇️
harness and tooling (python) 42.98% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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
@JSmithRobotics
JSmithRobotics force-pushed the feat/claude-profiles-json branch from e64c666 to b4e9db9 Compare October 2, 2026 10:32
`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
@JSmithRobotics

Copy link
Copy Markdown
Collaborator Author

Fixed in 4724f98, and the finding was correct.

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 exactly the outcome it exists to catch — the "a host with five profiles being told it has none" case named in warn_if_the_profiles_root_could_not_be_read, arrived at one entry at a time instead of all at once.

It now walks the iterator and returns the first entry error, which both prints the warning and gives --json its non-zero exit.

Two notes on the shape, since the suggestion offered a choice:

Walking the directory twice — here and again in summarise — is deliberate rather than an oversight. summarise is pure and returns a list, so it 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, so two walks is not a cost worth shaping an API around.

The arm has no test and the doc says so rather than implying coverage. A per-entry readdir failure is not something a portable unit test can provoke: removing entries mid-walk does not error, nor does a name that is not UTF-8, and the kernel paths that do fail (a stale NFS handle, a disappearing mount) cannot be arranged from inside the suite. The three existing tests pin what can be pinned — absent, readable, not a directory.

Also rebased onto 0.59.2.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant