1use serde_json::{Value, json};
2
3pub fn generate() -> Value {
4 let mut schema = json!({
5 "clispec": "0.3",
6 "name": "yuki",
7 "version": env!("CARGO_PKG_VERSION"),
8 "description": "CLI client for the Yuki bookkeeping API",
9 "global_args": [
10 {
11 "name": "--output",
12 "type": "string",
13 "description": "Output format: auto, text, or json.",
14 "enum": ["auto", "text", "json"],
15 "default": "auto"
16 },
17 {
18 "name": "--admin",
19 "type": "string",
20 "description": "Override the active administration by name."
21 },
22 {
23 "name": "--quiet",
24 "type": "boolean",
25 "description": "Suppress all output except errors.",
26 "default": false
27 },
28 {
29 "name": "--yes",
30 "type": "boolean",
31 "description": "Skip confirmation prompts (for use in scripts and pipelines).",
32 "default": false
33 }
34 ],
35 "commands": [
36 {
37 "name": "admin list",
38 "description": "List all available administrations.",
39 "mutating": false,
40 "args": [
41 {"name": "--limit", "type": "integer", "required": false, "description": "Maximum number of results to return."},
42 {"name": "--offset", "type": "integer", "required": false, "description": "Number of results to skip (for pagination)."},
43 {"name": "--fields", "type": "string", "required": false, "description": "Comma-separated list of fields to include in output."}
44 ],
45 "output_fields": [
46 {"name": "name", "type": "string"},
47 {"name": "domain_id", "type": "string"},
48 {"name": "admin_id", "type": "string"},
49 {"name": "default", "type": "boolean"}
50 ]
51 },
52 {
53 "name": "admin switch",
54 "description": "Switch the active administration.",
55 "mutating": true,
56 "args": [
57 {"name": "name", "type": "string", "required": true, "description": "Name of the administration to activate."}
58 ]
59 },
60 {
61 "name": "vat returns",
62 "description": "List VAT returns for a given year.",
63 "mutating": false,
64 "args": [
65 {"name": "year", "type": "string", "required": false, "description": "Fiscal year (e.g. 2025)."}
66 ],
67 "output_fields": [
68 {"name": "period", "type": "string"},
69 {"name": "status", "type": "string"},
70 {"name": "amount", "type": "string"}
71 ]
72 },
73 {
74 "name": "vat codes",
75 "description": "List active VAT codes.",
76 "mutating": false,
77 "args": [
78 {"name": "--limit", "type": "integer", "required": false, "description": "Maximum number of results to return."},
79 {"name": "--offset", "type": "integer", "required": false, "description": "Number of results to skip (for pagination)."},
80 {"name": "--fields", "type": "string", "required": false, "description": "Comma-separated list of fields to include in output."}
81 ],
82 "output_fields": [
83 {"name": "code", "type": "string"},
84 {"name": "description", "type": "string"},
85 {"name": "percentage", "type": "string"}
86 ]
87 },
88 {
89 "name": "contacts search",
90 "description": "Search contacts by name or other criteria.",
91 "mutating": false,
92 "args": [
93 {"name": "query", "type": "string", "required": true, "description": "Search query."}
94 ],
95 "output_fields": [
96 {"name": "id", "type": "string"},
97 {"name": "name", "type": "string"},
98 {"name": "type", "type": "string"}
99 ]
100 },
101 {
102 "name": "contacts list",
103 "description": "List contacts filtered by type.",
104 "mutating": false,
105 "args": [
106 {"name": "--contact-type", "type": "string", "required": false, "description": "Contact type (e.g. customer, supplier)."},
107 {"name": "--limit", "type": "integer", "required": false, "description": "Maximum number of results to return."},
108 {"name": "--offset", "type": "integer", "required": false, "description": "Number of results to skip (for pagination)."},
109 {"name": "--fields", "type": "string", "required": false, "description": "Comma-separated list of fields to include in output."}
110 ],
111 "output_fields": [
112 {"name": "id", "type": "string"},
113 {"name": "name", "type": "string"},
114 {"name": "type", "type": "string"},
115 {"name": "email", "type": "string"}
116 ]
117 },
118 {
119 "name": "accounts balance",
120 "description": "Show the balance of a general ledger account for a period.",
121 "mutating": false,
122 "args": [
123 {"name": "--account", "type": "string", "required": false, "description": "GL account code."},
124 {"name": "--period", "type": "string", "required": false, "description": "Accounting period (e.g. 2025-01)."}
125 ],
126 "output_fields": [
127 {"name": "account", "type": "string"},
128 {"name": "description", "type": "string"},
129 {"name": "balance", "type": "string"}
130 ]
131 },
132 {
133 "name": "accounts transactions",
134 "description": "List transactions for a general ledger account.",
135 "mutating": false,
136 "args": [
137 {"name": "--account", "type": "string", "required": false, "description": "GL account code."},
138 {"name": "--period", "type": "string", "required": false, "description": "Accounting period (e.g. 2025-01)."},
139 {"name": "--limit", "type": "integer", "required": false, "description": "Maximum number of results to return."},
140 {"name": "--offset", "type": "integer", "required": false, "description": "Number of results to skip (for pagination)."},
141 {"name": "--fields", "type": "string", "required": false, "description": "Comma-separated list of fields to include in output."}
142 ],
143 "output_fields": [
144 {"name": "date", "type": "string"},
145 {"name": "description", "type": "string"},
146 {"name": "amount", "type": "string"},
147 {"name": "reference", "type": "string"}
148 ]
149 },
150 {
151 "name": "accounts scheme",
152 "description": "Show the chart of accounts (GL account scheme).",
153 "mutating": false,
154 "args": [
155 {"name": "--limit", "type": "integer", "required": false, "description": "Maximum number of results to return."},
156 {"name": "--offset", "type": "integer", "required": false, "description": "Number of results to skip (for pagination)."},
157 {"name": "--fields", "type": "string", "required": false, "description": "Comma-separated list of fields to include in output."}
158 ],
159 "output_fields": [
160 {"name": "code", "type": "string"},
161 {"name": "description", "type": "string"},
162 {"name": "type", "type": "string"}
163 ]
164 },
165 {
166 "name": "accounts revenue",
167 "description": "Show net revenue for a period.",
168 "mutating": false,
169 "args": [
170 {"name": "--period", "type": "string", "required": false, "description": "Accounting period (e.g. 2025, 2025-Q1, 2025-01)."}
171 ],
172 "output_fields": [
173 {"name": "period", "type": "string"},
174 {"name": "revenue", "type": "string"}
175 ]
176 },
177 {
178 "name": "accounts start-balance",
179 "description": "Show opening balances per GL account for a book year.",
180 "mutating": false,
181 "args": [
182 {"name": "--year", "type": "string", "required": false, "description": "Book year (e.g. 2025)."},
183 {"name": "--limit", "type": "integer", "required": false, "description": "Maximum number of results to return."},
184 {"name": "--offset", "type": "integer", "required": false, "description": "Number of results to skip (for pagination)."},
185 {"name": "--fields", "type": "string", "required": false, "description": "Comma-separated list of fields to include in output."}
186 ],
187 "output_fields": [
188 {"name": "account", "type": "string"},
189 {"name": "description", "type": "string"},
190 {"name": "balance", "type": "string"}
191 ]
192 },
193 {
194 "name": "projects list",
195 "description": "List all projects.",
196 "mutating": false,
197 "args": [
198 {"name": "--limit", "type": "integer", "required": false, "description": "Maximum number of results to return."},
199 {"name": "--offset", "type": "integer", "required": false, "description": "Number of results to skip (for pagination)."},
200 {"name": "--fields", "type": "string", "required": false, "description": "Comma-separated list of fields to include in output."}
201 ],
202 "output_fields": [
203 {"name": "code", "type": "string"},
204 {"name": "name", "type": "string"},
205 {"name": "status", "type": "string"}
206 ]
207 },
208 {
209 "name": "projects balance",
210 "description": "Show balance for a project.",
211 "mutating": false,
212 "args": [
213 {"name": "project", "type": "string", "required": true, "description": "Project code."},
214 {"name": "--account", "type": "string", "required": false, "description": "GL account code filter."},
215 {"name": "--period", "type": "string", "required": false, "description": "Accounting period (e.g. 2025, 2025-Q1)."}
216 ],
217 "output_fields": [
218 {"name": "account", "type": "string"},
219 {"name": "description", "type": "string"},
220 {"name": "balance", "type": "string"}
221 ]
222 },
223 {
224 "name": "invoices list",
225 "description": "List invoices, optionally filtered by period and type.",
226 "mutating": false,
227 "args": [
228 {"name": "--period", "type": "string", "required": false, "description": "Accounting period (e.g. 2025-01)."},
229 {"name": "--invoice-type", "type": "string", "required": false, "description": "Invoice type filter (e.g. sales, purchase)."},
230 {"name": "--limit", "type": "integer", "required": false, "description": "Maximum number of results to return."},
231 {"name": "--offset", "type": "integer", "required": false, "description": "Number of results to skip (for pagination)."},
232 {"name": "--fields", "type": "string", "required": false, "description": "Comma-separated list of fields to include in output."}
233 ],
234 "output_fields": [
235 {"name": "id", "type": "string"},
236 {"name": "date", "type": "string"},
237 {"name": "contact", "type": "string"},
238 {"name": "amount", "type": "string"},
239 {"name": "status", "type": "string"}
240 ]
241 },
242 {
243 "name": "invoices show",
244 "description": "Show details for a single invoice.",
245 "mutating": false,
246 "args": [
247 {"name": "id", "type": "string", "required": true, "description": "Invoice ID."}
248 ],
249 "output_fields": [
250 {"name": "id", "type": "string"},
251 {"name": "date", "type": "string"},
252 {"name": "contact", "type": "string"},
253 {"name": "amount", "type": "string"},
254 {"name": "status", "type": "string"}
255 ]
256 },
257 {
258 "name": "invoices document",
259 "description": "Show the document linked to a transaction.",
260 "mutating": false,
261 "args": [
262 {"name": "id", "type": "string", "required": true, "description": "Transaction ID."}
263 ],
264 "output_fields": [
265 {"name": "id", "type": "string"},
266 {"name": "filename", "type": "string"},
267 {"name": "url", "type": "string"}
268 ]
269 },
270 {
271 "name": "documents list",
272 "description": "List documents in a folder or of a given type.",
273 "mutating": false,
274 "args": [
275 {"name": "--folder", "type": "string", "required": false, "description": "Archive folder name."},
276 {"name": "--doc-type", "type": "string", "required": false, "description": "Document type filter."},
277 {"name": "--limit", "type": "integer", "required": false, "description": "Maximum number of results to return."},
278 {"name": "--offset", "type": "integer", "required": false, "description": "Number of results to skip (for pagination)."},
279 {"name": "--fields", "type": "string", "required": false, "description": "Comma-separated list of fields to include in output."}
280 ],
281 "output_fields": [
282 {"name": "id", "type": "string"},
283 {"name": "filename", "type": "string"},
284 {"name": "folder", "type": "string"},
285 {"name": "date", "type": "string"}
286 ]
287 },
288 {
289 "name": "documents search",
290 "description": "Search documents by a query string.",
291 "mutating": false,
292 "args": [
293 {"name": "query", "type": "string", "required": true, "description": "Search query."}
294 ],
295 "output_fields": [
296 {"name": "id", "type": "string"},
297 {"name": "filename", "type": "string"},
298 {"name": "folder", "type": "string"},
299 {"name": "date", "type": "string"}
300 ]
301 },
302 {
303 "name": "documents exists",
304 "description": "Check if an invoice exists in the archive (by amount, date, and optional contact).",
305 "mutating": false,
306 "args": [
307 {"name": "--amount", "type": "number", "required": true, "description": "Invoice amount to search for."},
308 {"name": "--date", "type": "string", "required": true, "description": "Invoice date (YYYY-MM-DD). Matches within +/-7 days."},
309 {"name": "--contact", "type": "string", "required": false, "description": "Contact/supplier name to narrow the search."}
310 ],
311 "output_fields": [
312 {"name": "exists", "type": "boolean"},
313 {"name": "id", "type": "string"},
314 {"name": "filename", "type": "string"}
315 ]
316 },
317 {
318 "name": "check btw",
319 "description": "Check outstanding BTW (VAT) items for a period.",
320 "mutating": false,
321 "args": [
322 {"name": "period", "type": "string", "required": false, "description": "Accounting period (e.g. 2025-01)."}
323 ],
324 "output_fields": [
325 {"name": "account", "type": "string"},
326 {"name": "description", "type": "string"},
327 {"name": "amount", "type": "string"}
328 ]
329 },
330 {
331 "name": "check unmatched",
332 "description": "Find bank transactions without matching booked invoices.",
333 "mutating": false,
334 "args": [
335 {"name": "--period", "type": "string", "required": false, "description": "Accounting period (e.g. 2025-Q1)."},
336 {"name": "--bank-account", "type": "string", "required": false, "description": "GL account code for the bank account.", "default": "11001"}
337 ],
338 "output_fields": [
339 {"name": "date", "type": "string"},
340 {"name": "description", "type": "string"},
341 {"name": "amount", "type": "string"}
342 ]
343 },
344 {
345 "name": "check outstanding",
346 "description": "Check if a specific invoice reference is still outstanding.",
347 "mutating": false,
348 "args": [
349 {"name": "reference", "type": "string", "required": true, "description": "Invoice reference to check."}
350 ],
351 "output_fields": [
352 {"name": "reference", "type": "string"},
353 {"name": "outstanding", "type": "boolean"},
354 {"name": "amount", "type": "string"}
355 ]
356 },
357 {
358 "name": "upload file",
359 "description": "Upload a document with optional invoice metadata.",
360 "mutating": true,
361 "args": [
362 {"name": "file", "type": "path", "required": true, "description": "Path to the file to upload."},
363 {"name": "--folder", "type": "string", "required": false, "description": "Target folder.", "default": "uitzoeken"},
364 {"name": "--amount", "type": "number", "required": false, "description": "Invoice amount (e.g. 114.27)."},
365 {"name": "--category", "type": "string", "required": false, "description": "Cost category ID (e.g. 45100)."},
366 {"name": "--payment-method", "type": "string", "required": false, "description": "Payment method ID."},
367 {"name": "--project", "type": "string", "required": false, "description": "Project ID."},
368 {"name": "--remarks", "type": "string", "required": false, "description": "Remarks or notes."},
369 {"name": "--currency", "type": "string", "required": false, "description": "Currency code.", "default": "EUR"}
370 ],
371 "output_fields": [
372 {"name": "id", "type": "string"},
373 {"name": "filename", "type": "string"},
374 {"name": "folder", "type": "string"}
375 ]
376 },
377 {
378 "name": "upload categories",
379 "description": "List available cost categories.",
380 "mutating": false,
381 "output_fields": [
382 {"name": "id", "type": "string"},
383 {"name": "description", "type": "string"}
384 ]
385 },
386 {
387 "name": "upload payment-methods",
388 "description": "List available payment methods.",
389 "mutating": false,
390 "output_fields": [
391 {"name": "id", "type": "string"},
392 {"name": "description", "type": "string"}
393 ]
394 },
395 {
396 "name": "init",
397 "description": "Initialize yuki configuration for this machine.",
398 "mutating": true,
399 "args": [
400 {"name": "--api-key", "type": "string", "required": false, "description": "API key (skips interactive prompt if provided)."},
401 {"name": "--default-admin", "type": "string", "required": false, "description": "Default administration name (auto-selects if only one available)."}
402 ]
403 },
404 {
405 "name": "schema",
406 "description": "Output JSON schema for agent integration.",
407 "mutating": false
408 },
409 {
410 "name": "capabilities",
411 "description": "Describe supported API areas and safety behavior without loading configuration.",
412 "mutating": false,
413 "output_fields": [
414 {"name":"areas","type":"array","items":{"type":"string"}},
415 {"name":"structured_output","type":"boolean"},
416 {"name":"daily_api_limit","type":"integer"}
417 ]
418 },
419 {
420 "name": "completions",
421 "description": "Generate shell completions.",
422 "mutating": false,
423 "args": [
424 {"name": "shell", "type": "string", "required": true, "description": "Shell to generate completions for.", "enum": ["bash", "fish", "zsh", "powershell", "elvish"]}
425 ]
426 }
427 ],
428 "errors": [
429 {
430 "kind": "auth_failed",
431 "exit_code": 2,
432 "retryable": false,
433 "description": "Authentication failed: invalid or expired API key."
434 },
435 {
436 "kind": "not_found",
437 "exit_code": 3,
438 "retryable": false,
439 "description": "The requested resource was not found."
440 },
441 {
442 "kind": "rate_limited",
443 "exit_code": 4,
444 "retryable": true,
445 "description": "API rate limit exceeded (1000 calls/day)."
446 },
447 {
448 "kind": "config_error",
449 "exit_code": 1,
450 "retryable": false,
451 "description": "Configuration error: missing or invalid config file."
452 },
453 {
454 "kind": "confirmation_required",
455 "exit_code": 1,
456 "retryable": false,
457 "description": "A mutating command was invoked non-interactively without --yes."
458 },
459 {
460 "kind": "error",
461 "exit_code": 1,
462 "retryable": false,
463 "description": "An unexpected error occurred."
464 }
465 ]
466 });
467 enrich_v0_3(&mut schema);
468 schema
469}
470
471fn enrich_v0_3(schema: &mut Value) {
472 schema["output"] = json!({"tty":"text","piped":"json"});
473 let Some(commands) = schema["commands"].as_array_mut() else {
474 return;
475 };
476 for command in commands {
477 let Some(object) = command.as_object_mut() else {
478 continue;
479 };
480 let name = object["name"].as_str().unwrap_or_default().to_string();
481 let mutating = object["mutating"].as_bool().unwrap_or(false);
482 object.insert(
483 "effects".into(),
484 json!(if !mutating {
485 "read_only"
486 } else if name == "upload file" {
487 "non_idempotent"
488 } else {
489 "idempotent"
490 }),
491 );
492 if name == "completions" {
493 object.insert("output_kind".into(), json!("opaque"));
494 object.insert("media_type".into(), json!("text/plain"));
495 continue;
496 }
497 let unbounded = object
498 .get("args")
499 .and_then(Value::as_array)
500 .is_some_and(|args| {
501 args.iter().any(|arg| arg["name"] == "--limit")
502 && args.iter().any(|arg| arg["name"] == "--offset")
503 && args.iter().any(|arg| arg["name"] == "--fields")
504 });
505 object.insert(
506 "cardinality".into(),
507 json!(if unbounded { "unbounded" } else { "bounded" }),
508 );
509 if unbounded {
510 object.insert(
511 "pagination".into(),
512 json!({"style":"offset","limit_arg":"--limit","offset_arg":"--offset"}),
513 );
514 object.insert("fields_arg".into(), json!("--fields"));
515 }
516 if mutating {
517 object.insert("confirmation_bypass_arg".into(), json!("--yes"));
518 }
519 if name == "capabilities" {
520 object.insert("example".into(), json!({"args":["capabilities"]}));
521 }
522 if name == "schema" {
523 object.insert("cardinality".into(), json!("single"));
524 object.insert(
525 "stdout_schema".into(),
526 json!({"$ref":"https://clispec.dev/schema/v0.3.json"}),
527 );
528 }
529 if !object.contains_key("output_fields") && !object.contains_key("stdout_schema") {
530 object.insert("stdout_schema".into(), json!({}));
531 }
532 }
533}
534
535pub fn print_schema() {
536 let schema = generate();
537 println!(
538 "{}",
539 serde_json::to_string_pretty(&schema).expect("serialize schema")
540 );
541}
542
543#[cfg(test)]
544mod tests {
545 use super::*;
546 use jsonschema::Validator;
547 use serde_json::Value;
548
549 #[test]
550 fn schema_is_valid_json() {
551 let schema = generate();
552 let serialized = serde_json::to_string_pretty(&schema).unwrap();
553 let _: Value = serde_json::from_str(&serialized).unwrap();
554 }
555
556 #[test]
557 fn schema_has_required_top_level_keys() {
558 let schema = generate();
559 assert!(schema.get("clispec").is_some(), "missing clispec field");
560 assert!(schema.get("name").is_some(), "missing name field");
561 assert!(schema.get("version").is_some(), "missing version field");
562 assert!(schema.get("commands").is_some(), "missing commands field");
563 assert!(schema.get("errors").is_some(), "missing errors field");
564 assert!(
565 schema.get("global_args").is_some(),
566 "missing global_args field"
567 );
568 }
569
570 #[test]
571 fn schema_clispec_version() {
572 let schema = generate();
573 assert_eq!(schema["clispec"], "0.3");
574 }
575
576 #[test]
577 fn schema_commands_is_array() {
578 let schema = generate();
579 assert!(schema["commands"].is_array(), "commands must be an array");
580 }
581
582 #[test]
583 fn schema_all_commands_have_mutating_field() {
584 let schema = generate();
585 let commands = schema["commands"].as_array().unwrap();
586 for cmd in commands {
587 let name = cmd["name"].as_str().unwrap_or("unknown");
588 assert!(
589 cmd.get("mutating").is_some(),
590 "command '{name}' is missing 'mutating' field"
591 );
592 }
593 }
594
595 #[test]
596 fn schema_errors_have_exit_codes() {
597 let schema = generate();
598 let errors = schema["errors"].as_array().unwrap();
599 for err in errors {
600 let kind = err["kind"].as_str().unwrap_or("unknown");
601 assert!(
602 err.get("exit_code").is_some(),
603 "error '{kind}' is missing 'exit_code'"
604 );
605 }
606 }
607
608 #[test]
609 fn schema_global_args_has_output_flag() {
610 let schema = generate();
611 let global_args = schema["global_args"].as_array().unwrap();
612 let has_output = global_args.iter().any(|a| a["name"] == "--output");
613 assert!(has_output, "global_args must include --output flag");
614 }
615
616 #[test]
617 fn schema_global_args_has_yes_flag() {
618 let schema = generate();
619 let global_args = schema["global_args"].as_array().unwrap();
620 let has_yes = global_args.iter().any(|a| a["name"] == "--yes");
621 assert!(has_yes, "global_args must include --yes flag");
622 }
623
624 #[test]
625 fn schema_includes_leaf_commands() {
626 let schema = generate();
627 let commands = schema["commands"].as_array().unwrap();
628 let names: Vec<&str> = commands
629 .iter()
630 .map(|c| c["name"].as_str().unwrap_or(""))
631 .collect();
632 assert!(names.contains(&"admin list"), "missing 'admin list'");
633 assert!(names.contains(&"vat returns"), "missing 'vat returns'");
634 assert!(names.contains(&"invoices list"), "missing 'invoices list'");
635 }
636
637 #[test]
638 fn schema_validates_against_clispec_v03() {
639 let schema_json: Value =
640 serde_json::from_str(include_str!("../tests/fixtures/schema-v0.3.json"))
641 .expect("parse clispec schema fixture");
642
643 let validator = Validator::new(&schema_json).expect("compile clispec schema");
644 let output = generate();
645 if let Err(e) = validator.validate(&output) {
646 panic!("Schema does not validate against clispec v0.3:\n{e}");
647 }
648 }
649
650 #[test]
651 fn schema_works_without_config() {
652 let schema = generate();
654 assert!(schema.get("name").is_some());
655 }
656
657 #[test]
658 fn list_commands_have_limit_flag() {
659 let schema = generate();
660 let commands = schema["commands"].as_array().unwrap();
661 let list_cmd = commands
662 .iter()
663 .find(|c| c["name"] == "contacts list")
664 .unwrap();
665 let args = list_cmd["args"].as_array().unwrap();
666 let has_limit = args.iter().any(|a| a["name"] == "--limit");
667 assert!(has_limit, "contacts list is missing --limit arg");
668 }
669
670 #[test]
671 fn list_commands_have_offset_flag() {
672 let schema = generate();
673 let commands = schema["commands"].as_array().unwrap();
674 let list_cmd = commands
675 .iter()
676 .find(|c| c["name"] == "contacts list")
677 .unwrap();
678 let args = list_cmd["args"].as_array().unwrap();
679 let has_offset = args.iter().any(|a| a["name"] == "--offset");
680 assert!(has_offset, "contacts list is missing --offset arg");
681 }
682
683 #[test]
684 fn list_commands_have_fields_flag() {
685 let schema = generate();
686 let commands = schema["commands"].as_array().unwrap();
687 let list_cmd = commands
688 .iter()
689 .find(|c| c["name"] == "contacts list")
690 .unwrap();
691 let args = list_cmd["args"].as_array().unwrap();
692 let has_fields = args.iter().any(|a| a["name"] == "--fields");
693 assert!(has_fields, "contacts list is missing --fields arg");
694 }
695
696 #[test]
697 fn output_fields_declared_on_commands() {
698 let schema = generate();
699 let commands = schema["commands"].as_array().unwrap();
700 let with_output_fields = commands
701 .iter()
702 .filter(|c| {
703 c.get("output_fields")
704 .and_then(|f| f.as_array())
705 .map(|a| !a.is_empty())
706 .unwrap_or(false)
707 })
708 .count();
709 assert!(
710 with_output_fields > 0,
711 "at least some commands must have output_fields"
712 );
713 }
714}