add --json output to the query commands

Scripts and health checks had to scrape formatted text, which changes whenever
the display does.

--json is global and applies to status, ls, pins, sync list, devices list,
transfers, activity, and cache inspect. Action commands keep their human
output; a script that needs to know whether one worked has the exit code.

Two decisions worth recording:

The daemon's own reply is serialized rather than a hand-built parallel
structure, so new fields reach the JSON without anyone remembering to add them
-- but the variant tag is stripped first. Serializing CtlResponse directly
yields {"SyncFolders": {"items": [...]}}, which would make every caller write
jq '.SyncFolders.items[]' and couple it to the name of an internal enum
variant. Callers get {"items": [...]}.

A daemon-side error prints its payload and then fails the command. The first
cut exited 0 after printing the error body, which was caught by running
`pdfs --json ls NoSuchFolder`: a script would have read a failed lookup as
success. The body is still emitted so `kind` is available, but the exit code
is what `set -e` and `if pdfs ...` actually test.

status composes its object by hand instead, being the one query that merges
the local keyring with the daemon -- including the logged-in-but-no-daemon
case, which has no daemon reply to pass through at all.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JFzNQyb4TWrCRWcnegqWxY
This commit is contained in:
nvrlandClaude Opus 4.8 committed 2026-07-20 20:38:48 +02:00
1 parent e259016a00
commit a6a925667a
2 files changed
+170 -23

No files matched your search

+24
View File
@@ -92,6 +92,30 @@ For the local metadata database and content cache:
the database while it runs, so it is a deliberate operation rather than
something the daemon does on a timer.
## Scripting (`--json`)
Query commands accept a global `--json` flag and emit machine-readable output:
```console
$ pdfs --json sync list | jq '.items[] | select(.state != "idle") | .local_path'
$ pdfs --json cache inspect | jq '.db_reclaimable_bytes'
$ pdfs --json status | jq -r '.mount.mountpoint'
```
Supported on `status`, `ls`, `pins`, `sync list`, `devices list`, `transfers`,
`activity`, and `cache inspect`. Commands that perform an action keep their
human output — a script that needs to know whether one worked has the exit code.
Two things worth relying on:
- **The payload is unwrapped.** Output is `{"items": […]}`, not the daemon's
internal `{"SyncFolders": {"items": […]}}`, so scripts never name an internal
variant.
- **Errors still fail.** A daemon-side error prints its JSON body (with a
machine-readable `kind`) on stdout *and* exits non-zero, so `set -e` and
`if pdfs …` behave as expected rather than treating a failed lookup as
success.
## Performance & Caching
The client includes several optimizations designed for high efficiency, a low memory footprint, and a responsive user experience:
+146 -23
View File
@@ -23,10 +23,74 @@ use pdfs_core::db::Db;
about = "Proton Drive for Linux (Files On-Demand)"
)]
struct Cli {
/// Emit machine-readable JSON instead of formatted text.
///
/// Applies to the query commands (`status`, `ls`, `pins`, `sync list`,
/// `devices list`, `transfers`, `activity`, `cache inspect`). Commands that
/// perform an action keep their human output — a script that needs to know
/// whether one succeeded has the exit code.
#[arg(long, global = true)]
json: bool,
#[command(subcommand)]
command: Command,
}
/// Whether `--json` was passed. A global rather than a threaded parameter
/// because it is read at the leaves — one line per query command — and
/// threading a display flag through every command signature would cost more
/// than it explains.
static JSON_OUTPUT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
fn json_enabled() -> bool {
JSON_OUTPUT.load(std::sync::atomic::Ordering::Relaxed)
}
/// The daemon's reply as JSON, with the response-variant tag stripped.
///
/// Serializing [`CtlResponse`] directly yields serde's externally tagged form,
/// `{"SyncFolders": {"items": […]}}`, which makes every script write
/// `jq '.SyncFolders.items[]'` and couples it to the name of an internal enum
/// variant. The payload is what the caller asked for, so that is what is
/// emitted: `{"items": […]}`.
///
/// The reply is still serialized from the daemon's own type rather than
/// rebuilt by hand, so new fields appear in the JSON without anyone
/// remembering to add them here.
fn response_payload(response: &CtlResponse) -> Result<serde_json::Value> {
let value = serde_json::to_value(response)?;
// Every variant serializes as a one-key object keyed by the variant name;
// anything else is left alone rather than guessed at.
if let serde_json::Value::Object(map) = &value
&& map.len() == 1
&& let Some((_, payload)) = map.iter().next()
{
return Ok(payload.clone());
}
Ok(value)
}
/// Print the daemon's reply as JSON if `--json` is in effect, reporting whether
/// it did so — a `true` means the caller should skip its human formatting.
///
/// A daemon-side error still prints its payload, so a script can read the
/// `kind`, but then fails the command. Emitting the error and exiting `0` would
/// make every `--json` caller inspect the body to find out whether it worked,
/// when the exit code is the thing they already check.
fn emit_json(response: &CtlResponse) -> Result<bool> {
if !json_enabled() {
return Ok(false);
}
println!(
"{}",
serde_json::to_string_pretty(&response_payload(response)?)?
);
if let CtlResponse::Error { message, kind } = response {
bail!("{}", cli_error(*kind, message));
}
Ok(true)
}
#[derive(Subcommand)]
enum Command {
/// Log in to a Proton account (SRP + optional 2FA) and store the session.
@@ -407,6 +471,7 @@ fn main() -> Result<()> {
.init();
let cli = Cli::parse();
JSON_OUTPUT.store(cli.json, std::sync::atomic::Ordering::Relaxed);
match cli.command {
Command::Login { username } => cmd_login(username),
Command::Logout => cmd_logout(),
@@ -461,23 +526,29 @@ fn main() -> Result<()> {
fn cmd_devices(action: DeviceCmd) -> Result<()> {
match action {
DeviceCmd::List => match control_request(CtlRequest::ListDevices)? {
CtlResponse::Devices { items } if items.is_empty() => println!("No devices."),
CtlResponse::Devices { items } => {
for d in items {
let sync = d
.last_sync
.map(|t| t.to_string())
.unwrap_or_else(|| "never".to_string());
println!(
"{} {} (synced: {sync}) [{}]",
d.device_type, d.name, d.uid
);
}
DeviceCmd::List => {
let response = control_request(CtlRequest::ListDevices)?;
if emit_json(&response)? {
return Ok(());
}
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
},
match response {
CtlResponse::Devices { items } if items.is_empty() => println!("No devices."),
CtlResponse::Devices { items } => {
for d in items {
let sync = d
.last_sync
.map(|t| t.to_string())
.unwrap_or_else(|| "never".to_string());
println!(
"{} {} (synced: {sync}) [{}]",
d.device_type, d.name, d.uid
);
}
}
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
}
DeviceCmd::Rename { uid, name } => {
ok_or_bail(control_request(CtlRequest::RenameDevice { uid, name })?)?
}
@@ -497,7 +568,12 @@ fn cmd_sync(action: SyncCmd) -> Result<()> {
.to_string();
ok_or_bail(control_request(CtlRequest::AddSyncFolder { local_path })?)?
}
SyncCmd::List => match control_request(CtlRequest::ListSyncFolders)? {
SyncCmd::List => {
let response = control_request(CtlRequest::ListSyncFolders)?;
if emit_json(&response)? {
return Ok(());
}
match response {
CtlResponse::SyncFolders { items } if items.is_empty() => {
println!("No synced folders.")
}
@@ -537,7 +613,8 @@ fn cmd_sync(action: SyncCmd) -> Result<()> {
}
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
},
}
}
SyncCmd::Rm { id, delete_remote } => {
ok_or_bail(control_request(CtlRequest::RemoveSyncFolder {
id,
@@ -696,7 +773,11 @@ fn cmd_shared_with_me() -> Result<()> {
}
fn cmd_activity(limit: usize) -> Result<()> {
match control_request(CtlRequest::ListActivity { limit })? {
let response = control_request(CtlRequest::ListActivity { limit })?;
if emit_json(&response)? {
return Ok(());
}
match response {
CtlResponse::Activity { items } if items.is_empty() => println!("No recent activity."),
CtlResponse::Activity { items } => {
for a in items {
@@ -872,6 +953,32 @@ fn cmd_logout() -> Result<()> {
}
fn cmd_status() -> Result<()> {
// `status` is the one query that merges two sources — the local keyring and
// the daemon — so its JSON is composed here rather than being a daemon reply
// passed through. A script asking for status wants both halves in one
// object, including the "logged in but no daemon" case that has no reply at
// all.
if json_enabled() {
let session = match auth::load() {
Ok(s) => Some(s),
Err(pdfs_core::Error::NotLoggedIn) => None,
Err(e) => return Err(e.into()),
};
let mount = match control_request(CtlRequest::Status) {
Ok(r @ CtlResponse::Status { .. }) => response_payload(&r)?,
_ => serde_json::Value::Null,
};
let out = serde_json::json!({
"logged_in": session.is_some(),
"username": session.as_ref().map(|s| s.username.clone()),
"user_id": session.as_ref().map(|s| s.user_id.clone()),
"scopes": session.as_ref().map(|s| s.scopes.clone()),
"mount": mount,
});
println!("{}", serde_json::to_string_pretty(&out)?);
return Ok(());
}
match auth::load() {
Ok(s) => {
println!("Logged in as {} (user {})", s.username, s.user_id);
@@ -1032,7 +1139,11 @@ fn cmd_unpin(path: PathBuf) -> Result<()> {
fn cmd_transfers() -> Result<()> {
use pdfs_core::control::TransferDirection;
match control_request(CtlRequest::GetQueueStatus)? {
let response = control_request(CtlRequest::GetQueueStatus)?;
if emit_json(&response)? {
return Ok(());
}
match response {
CtlResponse::Transfers { items, jobs } if items.is_empty() && jobs.is_empty() => {
println!("Nothing in progress.")
}
@@ -1079,7 +1190,11 @@ fn cmd_transfers() -> Result<()> {
}
fn cmd_pins() -> Result<()> {
match control_request(CtlRequest::ListPins)? {
let response = control_request(CtlRequest::ListPins)?;
if emit_json(&response)? {
return Ok(());
}
match response {
CtlResponse::Pins { pins } if pins.is_empty() => println!("No pinned files."),
CtlResponse::Pins { pins } => {
for p in pins {
@@ -1097,7 +1212,11 @@ fn cmd_ls(path: Option<PathBuf>) -> Result<()> {
Some(p) => path_arg(&p)?,
None => String::new(),
};
match control_request(CtlRequest::ListDir { path })? {
let response = control_request(CtlRequest::ListDir { path })?;
if emit_json(&response)? {
return Ok(());
}
match response {
CtlResponse::Entries { entries } if entries.is_empty() => println!("(empty)"),
CtlResponse::Entries { entries } => {
for e in entries {
@@ -1380,7 +1499,11 @@ fn cmd_cache(action: CacheCmd) -> Result<()> {
}
fn cmd_cache_inspect(deep: bool) -> Result<()> {
match control_request(CtlRequest::CacheInspect { deep })? {
let response = control_request(CtlRequest::CacheInspect { deep })?;
if emit_json(&response)? {
return Ok(());
}
match response {
CtlResponse::CacheReport {
schema_version,
db_bytes,