proxctl 0.2.4__tar.gz → 0.2.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. {proxctl-0.2.4 → proxctl-0.2.6}/CHANGELOG.md +14 -0
  2. {proxctl-0.2.4 → proxctl-0.2.6}/Cargo.lock +1 -1
  3. {proxctl-0.2.4 → proxctl-0.2.6}/Cargo.toml +1 -1
  4. {proxctl-0.2.4 → proxctl-0.2.6}/PKG-INFO +1 -1
  5. {proxctl-0.2.4 → proxctl-0.2.6}/src/api/error.rs +32 -0
  6. {proxctl-0.2.4 → proxctl-0.2.6}/src/commands/backup.rs +16 -4
  7. {proxctl-0.2.4 → proxctl-0.2.6}/src/commands/container/lifecycle.rs +14 -5
  8. {proxctl-0.2.4 → proxctl-0.2.6}/src/commands/container/mod.rs +10 -1
  9. proxctl-0.2.6/src/commands/list_args.rs +67 -0
  10. {proxctl-0.2.4 → proxctl-0.2.6}/src/commands/mod.rs +1 -0
  11. {proxctl-0.2.4 → proxctl-0.2.6}/src/commands/node.rs +24 -6
  12. {proxctl-0.2.4 → proxctl-0.2.6}/src/commands/pool.rs +20 -6
  13. {proxctl-0.2.4 → proxctl-0.2.6}/src/commands/storage.rs +20 -4
  14. {proxctl-0.2.4 → proxctl-0.2.6}/src/commands/task.rs +18 -5
  15. {proxctl-0.2.4 → proxctl-0.2.6}/src/commands/vm/lifecycle.rs +14 -5
  16. {proxctl-0.2.4 → proxctl-0.2.6}/src/commands/vm/mod.rs +10 -1
  17. {proxctl-0.2.4 → proxctl-0.2.6}/src/main.rs +32 -11
  18. {proxctl-0.2.4 → proxctl-0.2.6}/src/schema.rs +34 -0
  19. {proxctl-0.2.4 → proxctl-0.2.6}/.github/workflows/ci.yml +0 -0
  20. {proxctl-0.2.4 → proxctl-0.2.6}/.github/workflows/release.yml +0 -0
  21. {proxctl-0.2.4 → proxctl-0.2.6}/.gitignore +0 -0
  22. {proxctl-0.2.4 → proxctl-0.2.6}/LICENSE +0 -0
  23. {proxctl-0.2.4 → proxctl-0.2.6}/Makefile +0 -0
  24. {proxctl-0.2.4 → proxctl-0.2.6}/README.md +0 -0
  25. {proxctl-0.2.4 → proxctl-0.2.6}/prek.toml +0 -0
  26. {proxctl-0.2.4 → proxctl-0.2.6}/proxctl_py/__init__.py +0 -0
  27. {proxctl-0.2.4 → proxctl-0.2.6}/proxctl_py/__main__.py +0 -0
  28. {proxctl-0.2.4 → proxctl-0.2.6}/pyproject.toml +0 -0
  29. {proxctl-0.2.4 → proxctl-0.2.6}/src/api/client.rs +0 -0
  30. {proxctl-0.2.4 → proxctl-0.2.6}/src/api/mod.rs +0 -0
  31. {proxctl-0.2.4 → proxctl-0.2.6}/src/api/token.rs +0 -0
  32. {proxctl-0.2.4 → proxctl-0.2.6}/src/api/types.rs +0 -0
  33. {proxctl-0.2.4 → proxctl-0.2.6}/src/commands/access.rs +0 -0
  34. {proxctl-0.2.4 → proxctl-0.2.6}/src/commands/api.rs +0 -0
  35. {proxctl-0.2.4 → proxctl-0.2.6}/src/commands/apply/container.rs +0 -0
  36. {proxctl-0.2.4 → proxctl-0.2.6}/src/commands/apply/diff.rs +0 -0
  37. {proxctl-0.2.4 → proxctl-0.2.6}/src/commands/apply/firewall.rs +0 -0
  38. {proxctl-0.2.4 → proxctl-0.2.6}/src/commands/apply/manifest.rs +0 -0
  39. {proxctl-0.2.4 → proxctl-0.2.6}/src/commands/apply/mod.rs +0 -0
  40. {proxctl-0.2.4 → proxctl-0.2.6}/src/commands/apply/reconciler.rs +0 -0
  41. {proxctl-0.2.4 → proxctl-0.2.6}/src/commands/apply/vm.rs +0 -0
  42. {proxctl-0.2.4 → proxctl-0.2.6}/src/commands/ceph.rs +0 -0
  43. {proxctl-0.2.4 → proxctl-0.2.6}/src/commands/cluster.rs +0 -0
  44. {proxctl-0.2.4 → proxctl-0.2.6}/src/commands/container/config.rs +0 -0
  45. {proxctl-0.2.4 → proxctl-0.2.6}/src/commands/container/firewall.rs +0 -0
  46. {proxctl-0.2.4 → proxctl-0.2.6}/src/commands/container/migrate.rs +0 -0
  47. {proxctl-0.2.4 → proxctl-0.2.6}/src/commands/container/snapshot.rs +0 -0
  48. {proxctl-0.2.4 → proxctl-0.2.6}/src/commands/export.rs +0 -0
  49. {proxctl-0.2.4 → proxctl-0.2.6}/src/commands/firewall.rs +0 -0
  50. {proxctl-0.2.4 → proxctl-0.2.6}/src/commands/vm/agent.rs +0 -0
  51. {proxctl-0.2.4 → proxctl-0.2.6}/src/commands/vm/cloudinit.rs +0 -0
  52. {proxctl-0.2.4 → proxctl-0.2.6}/src/commands/vm/config.rs +0 -0
  53. {proxctl-0.2.4 → proxctl-0.2.6}/src/commands/vm/firewall.rs +0 -0
  54. {proxctl-0.2.4 → proxctl-0.2.6}/src/commands/vm/migrate.rs +0 -0
  55. {proxctl-0.2.4 → proxctl-0.2.6}/src/commands/vm/snapshot.rs +0 -0
  56. {proxctl-0.2.4 → proxctl-0.2.6}/src/lib.rs +0 -0
  57. {proxctl-0.2.4 → proxctl-0.2.6}/src/output.rs +0 -0
@@ -6,6 +6,20 @@ All notable changes to this project will be documented in this file.
6
6
 
7
7
 
8
8
 
9
+
10
+
11
+ ## [0.2.6](https://github.com/rvben/proxctl/compare/v0.2.5...v0.2.6) - 2026-04-03
12
+
13
+ ### Added
14
+
15
+ - add --limit, --offset, --fields to all list commands ([0ecd684](https://github.com/rvben/proxctl/commit/0ecd6844273d697dcc1f7247f26743d6ac8252af))
16
+
17
+ ## [0.2.5](https://github.com/rvben/proxctl/compare/v0.2.4...v0.2.5) - 2026-04-03
18
+
19
+ ### Added
20
+
21
+ - add error kinds to schema and structured JSON errors on stderr ([b46b7d6](https://github.com/rvben/proxctl/commit/b46b7d67d0a40412ffb90771e221e5dbf9b08f73))
22
+
9
23
  ## [0.2.4](https://github.com/rvben/proxctl/compare/v0.2.3...v0.2.4) - 2026-04-03
10
24
 
11
25
  ### Added
@@ -955,7 +955,7 @@ dependencies = [
955
955
 
956
956
  [[package]]
957
957
  name = "proxctl"
958
- version = "0.2.4"
958
+ version = "0.2.6"
959
959
  dependencies = [
960
960
  "clap",
961
961
  "clap_complete",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "proxctl"
3
- version = "0.2.4"
3
+ version = "0.2.6"
4
4
  edition = "2024"
5
5
  rust-version = "1.90"
6
6
  description = "CLI for Proxmox VE"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxctl
3
- Version: 0.2.4
3
+ Version: 0.2.6
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Intended Audience :: System Administrators
@@ -40,6 +40,19 @@ pub enum Error {
40
40
  }
41
41
 
42
42
  impl Error {
43
+ /// Returns a machine-readable error kind string for structured error output.
44
+ pub fn kind(&self) -> &'static str {
45
+ match self {
46
+ Error::Config(_) => "config",
47
+ Error::Auth(_) => "auth",
48
+ Error::NotFound(_) => "not_found",
49
+ Error::Api { .. } | Error::TaskFailed(_) => "api",
50
+ Error::Conflict(_) => "conflict",
51
+ Error::Timeout(_) => "timeout",
52
+ Error::Http(_) | Error::Other(_) => "other",
53
+ }
54
+ }
55
+
43
56
  pub fn exit_code(&self) -> i32 {
44
57
  match self {
45
58
  Error::Config(_) => exit_codes::CONFIG_ERROR,
@@ -174,6 +187,25 @@ mod tests {
174
187
  assert_eq!(exit_codes::TIMEOUT, 7);
175
188
  }
176
189
 
190
+ #[test]
191
+ fn test_kind_maps_correctly() {
192
+ assert_eq!(Error::Config("x".into()).kind(), "config");
193
+ assert_eq!(Error::Auth("x".into()).kind(), "auth");
194
+ assert_eq!(Error::NotFound("x".into()).kind(), "not_found");
195
+ assert_eq!(
196
+ Error::Api {
197
+ status: 500,
198
+ message: "x".into()
199
+ }
200
+ .kind(),
201
+ "api"
202
+ );
203
+ assert_eq!(Error::TaskFailed("x".into()).kind(), "api");
204
+ assert_eq!(Error::Conflict("x".into()).kind(), "conflict");
205
+ assert_eq!(Error::Timeout("x".into()).kind(), "timeout");
206
+ assert_eq!(Error::Other("x".into()).kind(), "other");
207
+ }
208
+
177
209
  #[test]
178
210
  fn test_error_display_config() {
179
211
  let err = Error::Config("missing host".to_string());
@@ -4,6 +4,7 @@ use serde_json::json;
4
4
 
5
5
  use crate::api::Error;
6
6
  use crate::api::client::ProxmoxClient;
7
+ use crate::commands::list_args::ListArgs;
7
8
  use crate::output::{OutputConfig, use_color};
8
9
 
9
10
  fn require_node<'a>(node: Option<&'a str>, global_node: Option<&'a str>) -> Result<&'a str, Error> {
@@ -69,6 +70,8 @@ pub enum BackupCommand {
69
70
  /// Node name
70
71
  #[arg(long)]
71
72
  node: Option<String>,
73
+ #[command(flatten)]
74
+ list: ListArgs,
72
75
  },
73
76
  /// Create a backup
74
77
  Create {
@@ -128,9 +131,10 @@ pub async fn run(
128
131
  vmid,
129
132
  storage,
130
133
  node,
134
+ list: list_args,
131
135
  } => {
132
136
  let n = require_node(node.as_deref(), global_node)?;
133
- list(client, out, n, vmid, storage.as_deref()).await
137
+ list(client, out, n, vmid, storage.as_deref(), &list_args).await
134
138
  }
135
139
  BackupCommand::Create {
136
140
  vmid,
@@ -206,6 +210,7 @@ async fn list(
206
210
  node: &str,
207
211
  vmid_filter: Option<u32>,
208
212
  storage_filter: Option<&str>,
213
+ list_args: &ListArgs,
209
214
  ) -> Result<(), Error> {
210
215
  // Find backup-capable storages
211
216
  let storages: Vec<serde_json::Value> = client.get(&format!("/nodes/{node}/storage")).await?;
@@ -253,12 +258,19 @@ async fn list(
253
258
  });
254
259
  }
255
260
 
261
+ let total = all_backups.len();
262
+
256
263
  if out.json {
257
- out.print_data(&serde_json::to_string_pretty(&all_backups).expect("serialize"));
264
+ let paginated: Vec<serde_json::Value> = list_args.paginate(&all_backups).to_vec();
265
+ let paginated = list_args.filter_fields(paginated);
266
+ let envelope = list_args.paginated_json(&paginated, total);
267
+ out.print_data(&serde_json::to_string_pretty(&envelope).expect("serialize"));
258
268
  return Ok(());
259
269
  }
260
270
 
261
- if all_backups.is_empty() {
271
+ let page = list_args.paginate(&all_backups);
272
+
273
+ if page.is_empty() {
262
274
  out.print_message("No backups found.");
263
275
  return Ok(());
264
276
  }
@@ -273,7 +285,7 @@ async fn list(
273
285
  println!("{header}");
274
286
  println!("{}", "-".repeat(total_w));
275
287
  }
276
- for b in &all_backups {
288
+ for b in page {
277
289
  let volid = b.get("volid").and_then(|v| v.as_str()).unwrap_or("-");
278
290
  let vmid = b
279
291
  .get("vmid")
@@ -3,6 +3,7 @@ use serde_json::json;
3
3
 
4
4
  use crate::api::Error;
5
5
  use crate::api::client::ProxmoxClient;
6
+ use crate::commands::list_args::ListArgs;
6
7
  use crate::output::{OutputConfig, use_color};
7
8
 
8
9
  /// Format bytes as a human-readable string (e.g. "2.00 GiB").
@@ -47,6 +48,7 @@ pub async fn list(
47
48
  node: Option<&str>,
48
49
  status_filter: Option<&str>,
49
50
  pool_filter: Option<&str>,
51
+ list_args: &ListArgs,
50
52
  ) -> Result<(), Error> {
51
53
  let containers: Vec<serde_json::Value> = if let Some(n) = node {
52
54
  let items: Vec<serde_json::Value> = client.get(&format!("/nodes/{n}/lxc")).await?;
@@ -69,8 +71,8 @@ pub async fn list(
69
71
  .collect()
70
72
  };
71
73
 
72
- let containers: Vec<&serde_json::Value> = containers
73
- .iter()
74
+ let containers: Vec<serde_json::Value> = containers
75
+ .into_iter()
74
76
  .filter(|v| {
75
77
  if let Some(sf) = status_filter {
76
78
  let s = v.get("status").and_then(|x| x.as_str()).unwrap_or("");
@@ -88,12 +90,19 @@ pub async fn list(
88
90
  })
89
91
  .collect();
90
92
 
93
+ let total = containers.len();
94
+
91
95
  if out.json {
92
- out.print_data(&serde_json::to_string_pretty(&containers).expect("serialize"));
96
+ let paginated: Vec<serde_json::Value> = list_args.paginate(&containers).to_vec();
97
+ let paginated = list_args.filter_fields(paginated);
98
+ let envelope = list_args.paginated_json(&paginated, total);
99
+ out.print_data(&serde_json::to_string_pretty(&envelope).expect("serialize"));
93
100
  return Ok(());
94
101
  }
95
102
 
96
- if containers.is_empty() {
103
+ let page = list_args.paginate(&containers);
104
+
105
+ if page.is_empty() {
97
106
  out.print_message("No containers found.");
98
107
  return Ok(());
99
108
  }
@@ -112,7 +121,7 @@ pub async fn list(
112
121
  println!("{}", "-".repeat(total_w));
113
122
  }
114
123
 
115
- for ct in &containers {
124
+ for ct in page {
116
125
  let vmid = ct.get("vmid").and_then(|v| v.as_u64()).unwrap_or(0);
117
126
  let name = ct
118
127
  .get("name")
@@ -8,6 +8,7 @@ use clap::Subcommand;
8
8
 
9
9
  use crate::api::Error;
10
10
  use crate::api::client::ProxmoxClient;
11
+ use crate::commands::list_args::ListArgs;
11
12
  use crate::output::OutputConfig;
12
13
 
13
14
  #[derive(Subcommand)]
@@ -105,6 +106,8 @@ pub enum ContainerCommand {
105
106
  /// Filter by pool
106
107
  #[arg(long)]
107
108
  pool: Option<String>,
109
+ #[command(flatten)]
110
+ list: ListArgs,
108
111
  },
109
112
  /// Show container status
110
113
  Status {
@@ -314,7 +317,12 @@ pub async fn run(
314
317
  global_node: Option<&str>,
315
318
  ) -> Result<(), Error> {
316
319
  match cmd {
317
- ContainerCommand::List { node, status, pool } => {
320
+ ContainerCommand::List {
321
+ node,
322
+ status,
323
+ pool,
324
+ list,
325
+ } => {
318
326
  let effective_node = node.as_deref().or(global_node);
319
327
  lifecycle::list(
320
328
  client,
@@ -322,6 +330,7 @@ pub async fn run(
322
330
  effective_node,
323
331
  status.as_deref(),
324
332
  pool.as_deref(),
333
+ &list,
325
334
  )
326
335
  .await
327
336
  }
@@ -0,0 +1,67 @@
1
+ use std::collections::HashSet;
2
+
3
+ use serde_json::json;
4
+
5
+ /// Shared arguments for list commands providing bounded output support.
6
+ #[derive(clap::Args, Clone, Debug)]
7
+ pub struct ListArgs {
8
+ /// Maximum number of items to return
9
+ #[arg(long)]
10
+ pub limit: Option<usize>,
11
+
12
+ /// Number of items to skip
13
+ #[arg(long, default_value = "0")]
14
+ pub offset: usize,
15
+
16
+ /// Comma-separated list of fields to include in JSON output
17
+ #[arg(long)]
18
+ pub fields: Option<String>,
19
+ }
20
+
21
+ impl ListArgs {
22
+ /// Apply offset and limit to a slice, returning a paginated subset.
23
+ pub fn paginate<'a, T>(&self, items: &'a [T]) -> &'a [T] {
24
+ let start = self.offset.min(items.len());
25
+ let remaining = &items[start..];
26
+ match self.limit {
27
+ Some(limit) => &remaining[..limit.min(remaining.len())],
28
+ None => remaining,
29
+ }
30
+ }
31
+
32
+ /// Filter JSON values to only include specified fields.
33
+ /// Returns items unchanged if no fields filter is set.
34
+ pub fn filter_fields(&self, items: Vec<serde_json::Value>) -> Vec<serde_json::Value> {
35
+ let fields = match &self.fields {
36
+ Some(f) => f,
37
+ None => return items,
38
+ };
39
+
40
+ let field_set: HashSet<&str> = fields.split(',').map(|f| f.trim()).collect();
41
+ items
42
+ .into_iter()
43
+ .map(|item| {
44
+ if let Some(obj) = item.as_object() {
45
+ let filtered: serde_json::Map<String, serde_json::Value> = obj
46
+ .iter()
47
+ .filter(|(k, _)| field_set.contains(k.as_str()))
48
+ .map(|(k, v)| (k.clone(), v.clone()))
49
+ .collect();
50
+ serde_json::Value::Object(filtered)
51
+ } else {
52
+ item
53
+ }
54
+ })
55
+ .collect()
56
+ }
57
+
58
+ /// Wrap items in a pagination envelope for JSON output.
59
+ pub fn paginated_json(&self, items: &[serde_json::Value], total: usize) -> serde_json::Value {
60
+ json!({
61
+ "items": items,
62
+ "total": total,
63
+ "offset": self.offset,
64
+ "limit": self.limit,
65
+ })
66
+ }
67
+ }
@@ -7,6 +7,7 @@ pub mod cluster;
7
7
  pub mod container;
8
8
  pub mod export;
9
9
  pub mod firewall;
10
+ pub mod list_args;
10
11
  pub mod node;
11
12
  pub mod pool;
12
13
  pub mod storage;
@@ -4,6 +4,7 @@ use serde_json::json;
4
4
 
5
5
  use crate::api::Error;
6
6
  use crate::api::client::ProxmoxClient;
7
+ use crate::commands::list_args::ListArgs;
7
8
  use crate::output::{OutputConfig, use_color};
8
9
 
9
10
  #[derive(Subcommand)]
@@ -89,7 +90,10 @@ pub enum CertificateCommand {
89
90
  #[derive(Subcommand)]
90
91
  pub enum NodeCommand {
91
92
  /// List all nodes
92
- List,
93
+ List {
94
+ #[command(flatten)]
95
+ list: ListArgs,
96
+ },
93
97
  /// Show node status
94
98
  Status {
95
99
  /// Node name (uses default node if not specified)
@@ -221,7 +225,7 @@ pub async fn run(
221
225
  global_node: Option<&str>,
222
226
  ) -> Result<(), Error> {
223
227
  match cmd {
224
- NodeCommand::List => list(client, out).await,
228
+ NodeCommand::List { list: list_args } => list(client, out, &list_args).await,
225
229
  NodeCommand::Status { node } => {
226
230
  let n = require_node(node.as_deref(), global_node)?;
227
231
  status(client, out, n).await
@@ -270,15 +274,29 @@ pub async fn run(
270
274
  }
271
275
  }
272
276
 
273
- async fn list(client: &ProxmoxClient, out: OutputConfig) -> Result<(), Error> {
277
+ async fn list(
278
+ client: &ProxmoxClient,
279
+ out: OutputConfig,
280
+ list_args: &ListArgs,
281
+ ) -> Result<(), Error> {
274
282
  let nodes = client.list_nodes().await?;
283
+ let total = nodes.len();
275
284
 
276
285
  if out.json {
277
- out.print_data(&serde_json::to_string_pretty(&nodes).expect("serialize"));
286
+ let values: Vec<serde_json::Value> = nodes
287
+ .iter()
288
+ .map(|n| serde_json::to_value(n).expect("serialize node"))
289
+ .collect();
290
+ let paginated: Vec<serde_json::Value> = list_args.paginate(&values).to_vec();
291
+ let paginated = list_args.filter_fields(paginated);
292
+ let envelope = list_args.paginated_json(&paginated, total);
293
+ out.print_data(&serde_json::to_string_pretty(&envelope).expect("serialize"));
278
294
  return Ok(());
279
295
  }
280
296
 
281
- if nodes.is_empty() {
297
+ let page = list_args.paginate(&nodes);
298
+
299
+ if page.is_empty() {
282
300
  out.print_message("No nodes found.");
283
301
  return Ok(());
284
302
  }
@@ -297,7 +315,7 @@ async fn list(client: &ProxmoxClient, out: OutputConfig) -> Result<(), Error> {
297
315
  println!("{}", "-".repeat(total_w));
298
316
  }
299
317
 
300
- for node in &nodes {
318
+ for node in page {
301
319
  let hours = node.uptime / 3600;
302
320
  let days = hours / 24;
303
321
  let uptime_str = if days > 0 {
@@ -4,6 +4,7 @@ use serde_json::json;
4
4
 
5
5
  use crate::api::Error;
6
6
  use crate::api::client::ProxmoxClient;
7
+ use crate::commands::list_args::ListArgs;
7
8
  use crate::output::{OutputConfig, use_color};
8
9
 
9
10
  fn confirm_action(action: &str, yes: bool) -> Result<(), Error> {
@@ -25,7 +26,10 @@ fn confirm_action(action: &str, yes: bool) -> Result<(), Error> {
25
26
  #[derive(Subcommand)]
26
27
  pub enum PoolCommand {
27
28
  /// List resource pools
28
- List,
29
+ List {
30
+ #[command(flatten)]
31
+ list: ListArgs,
32
+ },
29
33
  /// Show pool details
30
34
  Show {
31
35
  /// Pool ID
@@ -70,7 +74,7 @@ pub async fn run(
70
74
  _global_node: Option<&str>,
71
75
  ) -> Result<(), Error> {
72
76
  match cmd {
73
- PoolCommand::List => list(client, out).await,
77
+ PoolCommand::List { list: list_args } => list(client, out, &list_args).await,
74
78
  PoolCommand::Show { poolid } => show(client, out, &poolid).await,
75
79
  PoolCommand::Create { poolid, comment } => {
76
80
  create(client, out, &poolid, comment.as_deref()).await
@@ -95,15 +99,25 @@ pub async fn run(
95
99
  }
96
100
  }
97
101
 
98
- async fn list(client: &ProxmoxClient, out: OutputConfig) -> Result<(), Error> {
102
+ async fn list(
103
+ client: &ProxmoxClient,
104
+ out: OutputConfig,
105
+ list_args: &ListArgs,
106
+ ) -> Result<(), Error> {
99
107
  let data: Vec<serde_json::Value> = client.get("/pools").await?;
108
+ let total = data.len();
100
109
 
101
110
  if out.json {
102
- out.print_data(&serde_json::to_string_pretty(&data).expect("serialize"));
111
+ let paginated: Vec<serde_json::Value> = list_args.paginate(&data).to_vec();
112
+ let paginated = list_args.filter_fields(paginated);
113
+ let envelope = list_args.paginated_json(&paginated, total);
114
+ out.print_data(&serde_json::to_string_pretty(&envelope).expect("serialize"));
103
115
  return Ok(());
104
116
  }
105
117
 
106
- if data.is_empty() {
118
+ let page = list_args.paginate(&data);
119
+
120
+ if page.is_empty() {
107
121
  out.print_message("No resource pools found.");
108
122
  return Ok(());
109
123
  }
@@ -118,7 +132,7 @@ async fn list(client: &ProxmoxClient, out: OutputConfig) -> Result<(), Error> {
118
132
  println!("{header}");
119
133
  println!("{}", "-".repeat(total_w));
120
134
  }
121
- for pool in &data {
135
+ for pool in page {
122
136
  let poolid = pool.get("poolid").and_then(|v| v.as_str()).unwrap_or("-");
123
137
  let comment = pool.get("comment").and_then(|v| v.as_str()).unwrap_or("");
124
138
  if color {
@@ -4,6 +4,7 @@ use serde_json::json;
4
4
 
5
5
  use crate::api::Error;
6
6
  use crate::api::client::ProxmoxClient;
7
+ use crate::commands::list_args::ListArgs;
7
8
  use crate::output::{OutputConfig, use_color};
8
9
 
9
10
  fn require_node<'a>(node: Option<&'a str>, global_node: Option<&'a str>) -> Result<&'a str, Error> {
@@ -37,6 +38,8 @@ pub enum StorageCommand {
37
38
  /// Filter by storage type
38
39
  #[arg(long, rename_all = "kebab-case")]
39
40
  r#type: Option<String>,
41
+ #[command(flatten)]
42
+ list: ListArgs,
40
43
  },
41
44
  /// Show storage status
42
45
  Status {
@@ -142,12 +145,17 @@ pub async fn run(
142
145
  global_node: Option<&str>,
143
146
  ) -> Result<(), Error> {
144
147
  match cmd {
145
- StorageCommand::List { node, r#type } => {
148
+ StorageCommand::List {
149
+ node,
150
+ r#type,
151
+ list: list_args,
152
+ } => {
146
153
  list(
147
154
  client,
148
155
  out,
149
156
  node.as_deref().or(global_node),
150
157
  r#type.as_deref(),
158
+ &list_args,
151
159
  )
152
160
  .await
153
161
  }
@@ -231,6 +239,7 @@ async fn list(
231
239
  out: OutputConfig,
232
240
  node: Option<&str>,
233
241
  type_filter: Option<&str>,
242
+ list_args: &ListArgs,
234
243
  ) -> Result<(), Error> {
235
244
  let mut data: Vec<serde_json::Value> = client.get("/storage").await?;
236
245
 
@@ -250,12 +259,19 @@ async fn list(
250
259
  });
251
260
  }
252
261
 
262
+ let total = data.len();
263
+
253
264
  if out.json {
254
- out.print_data(&serde_json::to_string_pretty(&data).expect("serialize"));
265
+ let paginated: Vec<serde_json::Value> = list_args.paginate(&data).to_vec();
266
+ let paginated = list_args.filter_fields(paginated);
267
+ let envelope = list_args.paginated_json(&paginated, total);
268
+ out.print_data(&serde_json::to_string_pretty(&envelope).expect("serialize"));
255
269
  return Ok(());
256
270
  }
257
271
 
258
- if data.is_empty() {
272
+ let page = list_args.paginate(&data);
273
+
274
+ if page.is_empty() {
259
275
  out.print_message("No storage pools found.");
260
276
  return Ok(());
261
277
  }
@@ -273,7 +289,7 @@ async fn list(
273
289
  println!("{header}");
274
290
  println!("{}", "-".repeat(total_w));
275
291
  }
276
- for s in &data {
292
+ for s in page {
277
293
  let name = s.get("storage").and_then(|v| v.as_str()).unwrap_or("-");
278
294
  let stype = s.get("type").and_then(|v| v.as_str()).unwrap_or("-");
279
295
  let content = s.get("content").and_then(|v| v.as_str()).unwrap_or("-");
@@ -4,6 +4,7 @@ use serde_json::json;
4
4
 
5
5
  use crate::api::Error;
6
6
  use crate::api::client::{ProxmoxClient, parse_upid_node};
7
+ use crate::commands::list_args::ListArgs;
7
8
  use crate::output::{OutputConfig, use_color};
8
9
 
9
10
  #[derive(Subcommand)]
@@ -19,6 +20,8 @@ pub enum TaskCommand {
19
20
  /// Filter by status (running, error, ok)
20
21
  #[arg(long)]
21
22
  status: Option<String>,
23
+ #[command(flatten)]
24
+ list: ListArgs,
22
25
  },
23
26
  /// Show task status
24
27
  Status {
@@ -56,6 +59,7 @@ pub async fn run(
56
59
  node,
57
60
  source,
58
61
  status,
62
+ list: list_args,
59
63
  } => {
60
64
  list(
61
65
  client,
@@ -63,6 +67,7 @@ pub async fn run(
63
67
  node.as_deref().or(global_node),
64
68
  source.as_deref(),
65
69
  status.as_deref(),
70
+ &list_args,
66
71
  )
67
72
  .await
68
73
  }
@@ -79,6 +84,7 @@ async fn list(
79
84
  node: Option<&str>,
80
85
  source: Option<&str>,
81
86
  status_filter: Option<&str>,
87
+ list_args: &ListArgs,
82
88
  ) -> Result<(), Error> {
83
89
  let data: Vec<serde_json::Value> = if let Some(n) = node {
84
90
  let mut path = format!("/nodes/{n}/tasks");
@@ -101,8 +107,8 @@ async fn list(
101
107
  };
102
108
 
103
109
  // Apply client-side status filter for ok/error
104
- let data: Vec<&serde_json::Value> = data
105
- .iter()
110
+ let data: Vec<serde_json::Value> = data
111
+ .into_iter()
106
112
  .filter(|t| {
107
113
  if let Some(sf) = status_filter {
108
114
  match sf {
@@ -128,12 +134,19 @@ async fn list(
128
134
  })
129
135
  .collect();
130
136
 
137
+ let total = data.len();
138
+
131
139
  if out.json {
132
- out.print_data(&serde_json::to_string_pretty(&data).expect("serialize"));
140
+ let paginated: Vec<serde_json::Value> = list_args.paginate(&data).to_vec();
141
+ let paginated = list_args.filter_fields(paginated);
142
+ let envelope = list_args.paginated_json(&paginated, total);
143
+ out.print_data(&serde_json::to_string_pretty(&envelope).expect("serialize"));
133
144
  return Ok(());
134
145
  }
135
146
 
136
- if data.is_empty() {
147
+ let page = list_args.paginate(&data);
148
+
149
+ if page.is_empty() {
137
150
  out.print_message("No tasks found.");
138
151
  return Ok(());
139
152
  }
@@ -151,7 +164,7 @@ async fn list(
151
164
  println!("{header}");
152
165
  println!("{}", "-".repeat(total_w));
153
166
  }
154
- for task in &data {
167
+ for task in page {
155
168
  let node_name = task.get("node").and_then(|v| v.as_str()).unwrap_or("-");
156
169
  let task_type = task.get("type").and_then(|v| v.as_str()).unwrap_or("-");
157
170
  let id = task.get("id").and_then(|v| v.as_str()).unwrap_or("-");
@@ -3,6 +3,7 @@ use serde_json::json;
3
3
 
4
4
  use crate::api::Error;
5
5
  use crate::api::client::ProxmoxClient;
6
+ use crate::commands::list_args::ListArgs;
6
7
  use crate::output::{OutputConfig, use_color};
7
8
 
8
9
  /// Format bytes as a human-readable string (e.g. "2.00 GiB").
@@ -47,6 +48,7 @@ pub async fn list(
47
48
  node: Option<&str>,
48
49
  status_filter: Option<&str>,
49
50
  pool_filter: Option<&str>,
51
+ list_args: &ListArgs,
50
52
  ) -> Result<(), Error> {
51
53
  let vms: Vec<serde_json::Value> = if let Some(n) = node {
52
54
  let items: Vec<serde_json::Value> = client.get(&format!("/nodes/{n}/qemu")).await?;
@@ -71,8 +73,8 @@ pub async fn list(
71
73
  };
72
74
 
73
75
  // Apply filters
74
- let vms: Vec<&serde_json::Value> = vms
75
- .iter()
76
+ let vms: Vec<serde_json::Value> = vms
77
+ .into_iter()
76
78
  .filter(|v| {
77
79
  if let Some(sf) = status_filter {
78
80
  let s = v.get("status").and_then(|x| x.as_str()).unwrap_or("");
@@ -90,12 +92,19 @@ pub async fn list(
90
92
  })
91
93
  .collect();
92
94
 
95
+ let total = vms.len();
96
+
93
97
  if out.json {
94
- out.print_data(&serde_json::to_string_pretty(&vms).expect("serialize"));
98
+ let paginated: Vec<serde_json::Value> = list_args.paginate(&vms).to_vec();
99
+ let paginated = list_args.filter_fields(paginated);
100
+ let envelope = list_args.paginated_json(&paginated, total);
101
+ out.print_data(&serde_json::to_string_pretty(&envelope).expect("serialize"));
95
102
  return Ok(());
96
103
  }
97
104
 
98
- if vms.is_empty() {
105
+ let page = list_args.paginate(&vms);
106
+
107
+ if page.is_empty() {
99
108
  out.print_message("No virtual machines found.");
100
109
  return Ok(());
101
110
  }
@@ -114,7 +123,7 @@ pub async fn list(
114
123
  println!("{}", "-".repeat(total_w));
115
124
  }
116
125
 
117
- for vm in &vms {
126
+ for vm in page {
118
127
  let vmid = vm.get("vmid").and_then(|v| v.as_u64()).unwrap_or(0);
119
128
  let name = vm.get("name").and_then(|v| v.as_str()).unwrap_or("-");
120
129
  let status = vm
@@ -10,6 +10,7 @@ use clap::Subcommand;
10
10
 
11
11
  use crate::api::Error;
12
12
  use crate::api::client::ProxmoxClient;
13
+ use crate::commands::list_args::ListArgs;
13
14
  use crate::output::OutputConfig;
14
15
 
15
16
  #[derive(Subcommand)]
@@ -173,6 +174,8 @@ pub enum VmCommand {
173
174
  /// Filter by pool
174
175
  #[arg(long)]
175
176
  pool: Option<String>,
177
+ #[command(flatten)]
178
+ list: ListArgs,
176
179
  },
177
180
  /// Show VM status
178
181
  Status {
@@ -393,7 +396,12 @@ pub async fn run(
393
396
  global_node: Option<&str>,
394
397
  ) -> Result<(), Error> {
395
398
  match cmd {
396
- VmCommand::List { node, status, pool } => {
399
+ VmCommand::List {
400
+ node,
401
+ status,
402
+ pool,
403
+ list,
404
+ } => {
397
405
  let effective_node = node.as_deref().or(global_node);
398
406
  lifecycle::list(
399
407
  client,
@@ -401,6 +409,7 @@ pub async fn run(
401
409
  effective_node,
402
410
  status.as_deref(),
403
411
  pool.as_deref(),
412
+ &list,
404
413
  )
405
414
  .await
406
415
  }
@@ -1,3 +1,4 @@
1
+ use std::io::IsTerminal;
1
2
  use std::path::PathBuf;
2
3
  use std::process;
3
4
 
@@ -6,7 +7,6 @@ use clap_complete::Shell;
6
7
  use serde_json::json;
7
8
 
8
9
  use proxctl::api::client::ProxmoxClient;
9
- use proxctl::api::error::exit_codes;
10
10
  use proxctl::api::token::ApiToken;
11
11
  use proxctl::commands::access::AccessCommand;
12
12
  use proxctl::commands::apply::ApplyCommand;
@@ -760,6 +760,22 @@ async fn run_config_init() -> Result<(), Error> {
760
760
  Ok(())
761
761
  }
762
762
 
763
+ /// Print an error to stderr. When stdout is not a TTY (piped), output
764
+ /// structured JSON so consuming agents can parse error details.
765
+ fn print_error(e: &Error) {
766
+ if std::io::stdout().is_terminal() {
767
+ eprintln!("Error: {e}");
768
+ } else {
769
+ let json = json!({
770
+ "error": {
771
+ "kind": e.kind(),
772
+ "message": e.to_string(),
773
+ }
774
+ });
775
+ eprintln!("{}", serde_json::to_string(&json).expect("serialize error"));
776
+ }
777
+ }
778
+
763
779
  // ── Main ────────────────────────────────────────────────────────────
764
780
 
765
781
  #[tokio::main]
@@ -780,7 +796,7 @@ async fn main() {
780
796
  }
781
797
  Command::Config(ConfigCommand::Init) => {
782
798
  if let Err(e) = run_config_init().await {
783
- eprintln!("Error: {e}");
799
+ print_error(&e);
784
800
  process::exit(e.exit_code());
785
801
  }
786
802
  return;
@@ -796,15 +812,19 @@ async fn main() {
796
812
  let (cfg_host, cfg_token, cfg_insecure) = load_config(cli.profile.as_deref());
797
813
 
798
814
  let host = cli.host.or(cfg_host).unwrap_or_else(|| {
799
- eprintln!("Error: no host configured. Set --host, PROXMOX_HOST, or configure a profile.");
800
- process::exit(exit_codes::CONFIG_ERROR);
815
+ let e = Error::Config(
816
+ "no host configured. Set --host, PROXMOX_HOST, or configure a profile.".to_string(),
817
+ );
818
+ print_error(&e);
819
+ process::exit(e.exit_code());
801
820
  });
802
821
 
803
822
  let token_str = cli.token.or(cfg_token).unwrap_or_else(|| {
804
- eprintln!(
805
- "Error: no token configured. Set --token, PROXMOX_TOKEN, or configure a profile."
823
+ let e = Error::Config(
824
+ "no token configured. Set --token, PROXMOX_TOKEN, or configure a profile.".to_string(),
806
825
  );
807
- process::exit(exit_codes::CONFIG_ERROR);
826
+ print_error(&e);
827
+ process::exit(e.exit_code());
808
828
  });
809
829
 
810
830
  let insecure = cli.insecure || cfg_insecure.unwrap_or(false);
@@ -812,15 +832,16 @@ async fn main() {
812
832
  let token: ApiToken = match token_str.parse() {
813
833
  Ok(t) => t,
814
834
  Err(e) => {
815
- eprintln!("Error: {e}");
816
- process::exit(exit_codes::CONFIG_ERROR);
835
+ let err = Error::Config(e.to_string());
836
+ print_error(&err);
837
+ process::exit(err.exit_code());
817
838
  }
818
839
  };
819
840
 
820
841
  let client = match ProxmoxClient::new(&host, token, insecure) {
821
842
  Ok(c) => c,
822
843
  Err(e) => {
823
- eprintln!("Error: {e}");
844
+ print_error(&e);
824
845
  process::exit(e.exit_code());
825
846
  }
826
847
  };
@@ -882,7 +903,7 @@ async fn main() {
882
903
  };
883
904
 
884
905
  if let Err(e) = result {
885
- eprintln!("Error: {e}");
906
+ print_error(&e);
886
907
  process::exit(e.exit_code());
887
908
  }
888
909
  }
@@ -184,6 +184,15 @@ pub fn generate(cmd: &clap::Command) -> Value {
184
184
  "version": env!("CARGO_PKG_VERSION"),
185
185
  "description": "CLI for Proxmox VE — manage VMs, containers, nodes, storage, and more",
186
186
  "usage": "proxctl [OPTIONS] <COMMAND> [SUBCOMMAND] [ARGS]",
187
+ "errors": [
188
+ {"kind": "config", "retryable": false, "description": "Configuration error"},
189
+ {"kind": "auth", "retryable": false, "description": "Authentication failed"},
190
+ {"kind": "not_found", "retryable": false, "description": "Resource not found"},
191
+ {"kind": "api", "retryable": true, "description": "API error"},
192
+ {"kind": "conflict", "retryable": false, "description": "Resource conflict"},
193
+ {"kind": "timeout", "retryable": true, "description": "Operation timed out"},
194
+ {"kind": "other", "retryable": false, "description": "General error"}
195
+ ],
187
196
  "global_flags": {
188
197
  "--host": {"type": "string", "env": "PROXMOX_HOST", "description": "Proxmox host (e.g., 192.168.1.25:8006)"},
189
198
  "--token": {"type": "string", "env": "PROXMOX_TOKEN", "description": "API token (user@realm!tokenid=secret)"},
@@ -443,6 +452,7 @@ mod tests {
443
452
  assert!(schema.get("version").is_some());
444
453
  assert!(schema.get("global_flags").is_some());
445
454
  assert!(schema.get("exit_codes").is_some());
455
+ assert!(schema.get("errors").is_some());
446
456
  assert!(schema.get("commands").is_some());
447
457
  assert!(schema.get("notes").is_some());
448
458
  }
@@ -481,6 +491,30 @@ mod tests {
481
491
  assert_eq!(enums, &[json!("fast"), json!("slow")]);
482
492
  }
483
493
 
494
+ #[test]
495
+ fn schema_errors_array_has_all_kinds() {
496
+ let schema = generate(&test_cmd());
497
+ let errors = schema["errors"].as_array().unwrap();
498
+ assert_eq!(errors.len(), 7);
499
+
500
+ let kinds: Vec<&str> = errors.iter().map(|e| e["kind"].as_str().unwrap()).collect();
501
+ assert!(kinds.contains(&"config"));
502
+ assert!(kinds.contains(&"auth"));
503
+ assert!(kinds.contains(&"not_found"));
504
+ assert!(kinds.contains(&"api"));
505
+ assert!(kinds.contains(&"conflict"));
506
+ assert!(kinds.contains(&"timeout"));
507
+ assert!(kinds.contains(&"other"));
508
+
509
+ // Verify retryable fields
510
+ let api = errors.iter().find(|e| e["kind"] == "api").unwrap();
511
+ assert_eq!(api["retryable"], true);
512
+ let timeout = errors.iter().find(|e| e["kind"] == "timeout").unwrap();
513
+ assert_eq!(timeout["retryable"], true);
514
+ let config = errors.iter().find(|e| e["kind"] == "config").unwrap();
515
+ assert_eq!(config["retryable"], false);
516
+ }
517
+
484
518
  #[test]
485
519
  fn schema_handles_nested_subcommands() {
486
520
  let schema = generate(&test_cmd());
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes