upscaler-cli 0.2.3.dev6696__py3-none-any.whl → 0.2.3.dev6697__py3-none-any.whl

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.
src/cli/asset.py CHANGED
@@ -79,14 +79,29 @@ def asset_find(ctx, title, description, asset_type, limit, offset):
79
79
  returned = metadata.get("returned_count", len(data))
80
80
  if total > returned:
81
81
  click.echo(f"Showing {returned} of {total} results (use --offset to paginate)\n")
82
- click.echo(format_table(
83
- data,
84
- columns=["asset_id", "title", "asset_type", "description"],
85
- ))
82
+ click.echo(
83
+ format_table(
84
+ data,
85
+ columns=["asset_id", "title", "asset_type", "description"],
86
+ )
87
+ )
86
88
 
87
89
 
88
90
  @asset_group.command("create")
89
- @click.option("--type", "asset_type", required=True, help="Asset type to create.")
91
+ @click.option(
92
+ "--type",
93
+ "asset_type",
94
+ required=True,
95
+ type=click.Choice(
96
+ [
97
+ "document_definition",
98
+ "register_definition",
99
+ "record_definition",
100
+ "course_definition",
101
+ ]
102
+ ),
103
+ help="Asset type to create.",
104
+ )
90
105
  @click.option("--data", "data_input", required=True, help="JSON data (value, - for stdin, @file).")
91
106
  @click.option(
92
107
  "--values-type",
@@ -179,7 +194,13 @@ def asset_update(ctx, asset_id, data_input, dry_run):
179
194
  @click.option("--dry-run", is_flag=True, help="Preview without updating.")
180
195
  @pass_context
181
196
  def asset_update_content(
182
- ctx, asset_id, data_input, values_type, file_pairs, content_types, dry_run,
197
+ ctx,
198
+ asset_id,
199
+ data_input,
200
+ values_type,
201
+ file_pairs,
202
+ content_types,
203
+ dry_run,
183
204
  ):
184
205
  """Update asset content (Slate JSON or markdown), optionally uploading files.
185
206
 
@@ -228,7 +249,9 @@ def asset_update_content(
228
249
  @asset_group.command("upload-file")
229
250
  @click.option("--asset-id", required=True, help="Document ID to upload into.")
230
251
  @click.option(
231
- "--field", "field_name", required=True,
252
+ "--field",
253
+ "field_name",
254
+ required=True,
232
255
  help="File-block name (options.name) on the Slate tree.",
233
256
  )
234
257
  @click.option(
@@ -361,7 +384,11 @@ def asset_add_task(ctx, asset_id, title, description, dry_run):
361
384
  data["description"] = description
362
385
 
363
386
  _execute_asset(
364
- ctx, "add_task_definition", asset_id=asset_id, data=data, dry_run=dry_run,
387
+ ctx,
388
+ "add_task_definition",
389
+ asset_id=asset_id,
390
+ data=data,
391
+ dry_run=dry_run,
365
392
  )
366
393
 
367
394
 
@@ -379,7 +406,8 @@ def asset_add_task(ctx, asset_id, title, description, dry_run):
379
406
  "values_file",
380
407
  default=None,
381
408
  type=click.Path(exists=True, dir_okay=False),
382
- help="Path to a markdown (or Slate JSON) file. Packed as the 'values' field.",
409
+ help="Path to the body file. With --values-type markdown the file is sent "
410
+ "as a raw string; otherwise it is parsed as a Slate JSON array.",
383
411
  )
384
412
  @click.option(
385
413
  "--values-type",
@@ -390,7 +418,13 @@ def asset_add_task(ctx, asset_id, title, description, dry_run):
390
418
  @click.option("--dry-run", is_flag=True, help="Preview without updating.")
391
419
  @pass_context
392
420
  def asset_set_task_values(
393
- ctx, asset_id, task_definition_id, data_input, values_file, values_type, dry_run,
421
+ ctx,
422
+ asset_id,
423
+ task_definition_id,
424
+ data_input,
425
+ values_file,
426
+ values_type,
427
+ dry_run,
394
428
  ):
395
429
  """Set the body of a single task definition (markdown or SlateJS).
396
430
 
@@ -403,30 +437,9 @@ def asset_set_task_values(
403
437
  upscaler asset set-task-values --asset-id rd_abc --task-id td_xyz \\
404
438
  --data '{"values": "## Header\\n..."}' --values-type markdown
405
439
  """
406
- from src.cli.helpers import parse_data
440
+ from src.cli.helpers import build_values_payload
407
441
 
408
- if not data_input and not values_file:
409
- click.echo("Provide either --data or --values-file.", err=True)
410
- sys.exit(1)
411
- return
412
- if data_input and values_file:
413
- click.echo("--data and --values-file are mutually exclusive.", err=True)
414
- sys.exit(1)
415
- return
416
-
417
- if values_file:
418
- data = {"values": Path(values_file).read_text()}
419
- else:
420
- try:
421
- data = parse_data(data_input)
422
- except Exception as e:
423
- click.echo(str(e), err=True)
424
- sys.exit(1)
425
- return
426
- if "values" not in data:
427
- click.echo("--data must include a 'values' key.", err=True)
428
- sys.exit(1)
429
- return
442
+ data = build_values_payload(data_input, values_file, values_type)
430
443
 
431
444
  data["taskDefinitionId"] = task_definition_id
432
445
  if values_type:
@@ -488,10 +501,164 @@ def asset_set_task_condition(ctx, asset_id, task_definition_id, data_input, dry_
488
501
  )
489
502
 
490
503
 
504
+ @asset_group.command("add-lesson")
505
+ @click.option("--asset-id", required=True, help="Course definition ID (cd_...).")
506
+ @click.option("--title", required=True, help="Lesson title.")
507
+ @click.option("--description", default=None, help="Lesson description (optional).")
508
+ @click.option("--dry-run", is_flag=True, help="Preview without creating.")
509
+ @pass_context
510
+ def asset_add_lesson(ctx, asset_id, title, description, dry_run):
511
+ """Append a new lesson definition to a course_definition.
512
+
513
+ Returns the assigned lesson definition ID (cl_...) which is needed to set
514
+ the lesson's body via `set-lesson-values`, rename it, or reorder it.
515
+
516
+ Example:
517
+ upscaler asset add-lesson --asset-id cd_abc --title "Phishing Basics"
518
+ """
519
+ data = {"title": title}
520
+ if description:
521
+ data["description"] = description
522
+
523
+ _execute_asset(
524
+ ctx,
525
+ "add_lesson_definition",
526
+ asset_id=asset_id,
527
+ data=data,
528
+ dry_run=dry_run,
529
+ )
530
+
531
+
532
+ @asset_group.command("set-lesson-title")
533
+ @click.option("--asset-id", required=True, help="Course definition ID (cd_...).")
534
+ @click.option("--lesson-id", "lesson_definition_id", required=True, help="Lesson ID (cl_...).")
535
+ @click.option("--title", required=True, help="New lesson title.")
536
+ @click.option("--dry-run", is_flag=True, help="Preview without updating.")
537
+ @pass_context
538
+ def asset_set_lesson_title(ctx, asset_id, lesson_definition_id, title, dry_run):
539
+ """Rename a single lesson definition."""
540
+ _execute_asset(
541
+ ctx,
542
+ "set_lesson_definition_title",
543
+ asset_id=asset_id,
544
+ data={"lessonDefinitionId": lesson_definition_id, "title": title},
545
+ dry_run=dry_run,
546
+ )
547
+
548
+
549
+ @asset_group.command("set-lesson-description")
550
+ @click.option("--asset-id", required=True, help="Course definition ID (cd_...).")
551
+ @click.option("--lesson-id", "lesson_definition_id", required=True, help="Lesson ID (cl_...).")
552
+ @click.option("--description", required=True, help="New lesson description.")
553
+ @click.option("--dry-run", is_flag=True, help="Preview without updating.")
554
+ @pass_context
555
+ def asset_set_lesson_description(ctx, asset_id, lesson_definition_id, description, dry_run):
556
+ """Set the description of a single lesson definition."""
557
+ _execute_asset(
558
+ ctx,
559
+ "set_lesson_definition_description",
560
+ asset_id=asset_id,
561
+ data={"lessonDefinitionId": lesson_definition_id, "description": description},
562
+ dry_run=dry_run,
563
+ )
564
+
565
+
566
+ @asset_group.command("set-lesson-values")
567
+ @click.option("--asset-id", required=True, help="Course definition ID (cd_...).")
568
+ @click.option("--lesson-id", "lesson_definition_id", required=True, help="Lesson ID (cl_...).")
569
+ @click.option(
570
+ "--data",
571
+ "data_input",
572
+ default=None,
573
+ help="JSON object with 'values' key. Mutually exclusive with --values-file.",
574
+ )
575
+ @click.option(
576
+ "--values-file",
577
+ "values_file",
578
+ default=None,
579
+ type=click.Path(exists=True, dir_okay=False),
580
+ help="Path to the body file. With --values-type markdown the file is sent "
581
+ "as a raw string; otherwise it is parsed as a Slate JSON array.",
582
+ )
583
+ @click.option(
584
+ "--values-type",
585
+ type=click.Choice(["markdown", "slateJson"]),
586
+ default=None,
587
+ help="Content format: 'markdown' or 'slateJson' (default: slateJson).",
588
+ )
589
+ @click.option("--dry-run", is_flag=True, help="Preview without updating.")
590
+ @pass_context
591
+ def asset_set_lesson_values(
592
+ ctx,
593
+ asset_id,
594
+ lesson_definition_id,
595
+ data_input,
596
+ values_file,
597
+ values_type,
598
+ dry_run,
599
+ ):
600
+ """Set the body of a single lesson definition (markdown or SlateJS).
601
+
602
+ Provide content via either --data (JSON object with 'values') or --values-file
603
+ (raw markdown or Slate JSON file). Pass --values-type markdown for markdown bodies.
604
+
605
+ Examples:
606
+ upscaler asset set-lesson-values --asset-id cd_abc --lesson-id cl_xyz \\
607
+ --values-file lesson1-body.md --values-type markdown
608
+ """
609
+ from src.cli.helpers import build_values_payload
610
+
611
+ data = build_values_payload(data_input, values_file, values_type)
612
+
613
+ data["lessonDefinitionId"] = lesson_definition_id
614
+ if values_type:
615
+ data["valuesType"] = values_type
616
+
617
+ _execute_asset(
618
+ ctx,
619
+ "set_lesson_definition_values",
620
+ asset_id=asset_id,
621
+ data=data,
622
+ dry_run=dry_run,
623
+ )
624
+
625
+
626
+ @asset_group.command("remove-lesson")
627
+ @click.option("--asset-id", required=True, help="Course definition ID (cd_...).")
628
+ @click.option("--lesson-id", "lesson_definition_id", required=True, help="Lesson ID (cl_...).")
629
+ @click.option("--dry-run", is_flag=True, help="Preview without removing.")
630
+ @pass_context
631
+ def asset_remove_lesson(ctx, asset_id, lesson_definition_id, dry_run):
632
+ """Remove a single lesson definition from a course_definition."""
633
+ _execute_asset(
634
+ ctx,
635
+ "remove_lesson_definition",
636
+ asset_id=asset_id,
637
+ data={"lessonDefinitionId": lesson_definition_id},
638
+ dry_run=dry_run,
639
+ )
640
+
641
+
642
+ @asset_group.command("move-lesson")
643
+ @click.option("--asset-id", required=True, help="Course definition ID (cd_...).")
644
+ @click.option("--from-id", "from_id", required=True, help="Lesson ID to move (cl_...).")
645
+ @click.option("--to-id", "to_id", required=True, help="Target sibling lesson ID (cl_...).")
646
+ @click.option("--dry-run", is_flag=True, help="Preview without reordering.")
647
+ @pass_context
648
+ def asset_move_lesson(ctx, asset_id, from_id, to_id, dry_run):
649
+ """Reorder a lesson definition within a course_definition."""
650
+ _execute_asset(
651
+ ctx,
652
+ "move_lesson_definition",
653
+ asset_id=asset_id,
654
+ data={"fromId": from_id, "toId": to_id},
655
+ dry_run=dry_run,
656
+ )
657
+
658
+
491
659
  def _execute_asset(ctx, operation, asset_id=None, asset_type=None, data=None, dry_run=False):
492
660
  """Execute an asset operation."""
493
- from src.cli.helpers import make_client
494
- from src.formatters.json_fmt import format_json
661
+ from src.cli.helpers import emit_action_result, emit_dry_run, make_client
495
662
 
496
663
  payload = {"operation": operation}
497
664
  if asset_id:
@@ -502,11 +669,11 @@ def _execute_asset(ctx, operation, asset_id=None, asset_type=None, data=None, dr
502
669
  payload["data"] = data
503
670
 
504
671
  if dry_run:
505
- if ctx.json_mode:
506
- click.echo(format_json({"dry_run": True, "payload": payload}, compact=True))
507
- else:
508
- click.echo(f"[dry-run] Would {operation} asset:")
509
- click.echo(format_json(payload))
672
+ emit_dry_run(
673
+ ctx,
674
+ summary=f"would {operation} {asset_type or asset_id or 'asset'}",
675
+ payload=payload,
676
+ )
510
677
  return
511
678
 
512
679
  client = make_client(ctx)
@@ -517,11 +684,9 @@ def _execute_asset(ctx, operation, asset_id=None, asset_type=None, data=None, dr
517
684
  handle_error(ctx, e)
518
685
  return
519
686
 
520
- if ctx.json_mode:
521
- click.echo(format_json(result, compact=True))
522
- else:
523
- d = result.get("data", {})
524
- click.echo(f"Asset {operation}: {d.get('assetId', d.get('asset_id', ''))}")
687
+ emit_action_result(
688
+ ctx, result, label=f"Asset {operation}", id_keys=("assetId", "asset_id", "id")
689
+ )
525
690
 
526
691
 
527
692
  # File-upload flow for Pattern A (Documents). Documents persist `values` as
@@ -678,9 +843,7 @@ def _dry_run_asset_with_files(ctx, asset_id, files, data):
678
843
  if ctx.json_mode:
679
844
  click.echo(format_json({"dry_run": True, "payload": dry_payload}, compact=True))
680
845
  else:
681
- click.echo(
682
- f"[dry-run] Would upload {len(files)} file(s) and update-content asset:"
683
- )
846
+ click.echo(f"[dry-run] Would upload {len(files)} file(s) and update-content asset:")
684
847
  click.echo(format_json(dry_payload))
685
848
 
686
849
 
src/cli/context.py CHANGED
@@ -12,6 +12,7 @@ class Context:
12
12
 
13
13
  def __init__(self):
14
14
  self.json_mode: bool = False
15
+ self.quiet_mode: bool = False
15
16
  self.verbose: bool = False
16
17
  self.server_url: Optional[str] = None
17
18
  self.profile: str = DEFAULT_PROFILE
src/cli/entry.py CHANGED
@@ -34,7 +34,8 @@ def entry_group():
34
34
 
35
35
  @entry_group.command("create")
36
36
  @click.option(
37
- "--definition-id", required=True,
37
+ "--definition-id",
38
+ required=True,
38
39
  help="Definition ID (rd_ = record, rg_ = register).",
39
40
  )
40
41
  @click.option("--data", "data_input", required=True, help="JSON data (value, - for stdin, @file).")
@@ -126,9 +127,7 @@ def entry_update(ctx, entry_id, data_input, file_pairs, task_id, content_types,
126
127
 
127
128
  @entry_group.command("upload-file")
128
129
  @click.option("--entry-id", required=True, help="Entry ID to upload into.")
129
- @click.option(
130
- "--field", "field_name", required=True, help="File-field name (options.name)."
131
- )
130
+ @click.option("--field", "field_name", required=True, help="File-field name (options.name).")
132
131
  @click.option(
133
132
  "--path",
134
133
  "file_path",
@@ -230,9 +229,12 @@ def entry_complete_task(ctx, entry_id, task_id, data_input, dry_run):
230
229
  return
231
230
 
232
231
  _execute_entry(
233
- ctx, "complete_task",
234
- entry_id=entry_id, task_id=task_id,
235
- data=data, dry_run=dry_run,
232
+ ctx,
233
+ "complete_task",
234
+ entry_id=entry_id,
235
+ task_id=task_id,
236
+ data=data,
237
+ dry_run=dry_run,
236
238
  )
237
239
 
238
240
 
@@ -262,7 +264,7 @@ def _execute_entry(
262
264
  files=None,
263
265
  ):
264
266
  """Execute an entry operation."""
265
- from src.cli.helpers import make_client
267
+ from src.cli.helpers import emit_action_result, make_client
266
268
  from src.formatters.json_fmt import format_json
267
269
 
268
270
  payload = {"operation": operation}
@@ -281,10 +283,10 @@ def _execute_entry(
281
283
  # then issue ONE mutation. The no-files path below stays unchanged.
282
284
  if dry_run:
283
285
  dry_payload = dict(payload)
284
- dry_payload["files"] = [
285
- {"field": f["field"], "path": str(f["path"])} for f in files
286
- ]
287
- if ctx.json_mode:
286
+ dry_payload["files"] = [{"field": f["field"], "path": str(f["path"])} for f in files]
287
+ if ctx.quiet_mode:
288
+ click.echo(f"[dry-run] would upload {len(files)} file(s) and {operation} entry")
289
+ elif ctx.json_mode:
288
290
  click.echo(format_json({"dry_run": True, "payload": dry_payload}, compact=True))
289
291
  else:
290
292
  click.echo(f"[dry-run] Would upload {len(files)} file(s) and {operation} entry:")
@@ -300,15 +302,16 @@ def _execute_entry(
300
302
  return
301
303
 
302
304
  if dry_run:
305
+ if ctx.quiet_mode:
306
+ click.echo(f"[dry-run] would {operation} entry")
307
+ return
303
308
  dry_run_data = {"dry_run": True, "payload": payload}
304
309
 
305
310
  # For complete_task, fetch and show the task's form fields
306
311
  if operation == "complete_task" and task_id:
307
312
  try:
308
313
  client = make_client(ctx)
309
- task_result = asyncio.run(
310
- client.request("GET", f"/api/v1/tasks/{task_id}")
311
- )
314
+ task_result = asyncio.run(client.request("GET", f"/api/v1/tasks/{task_id}"))
312
315
  task_data = task_result.get("data", {})
313
316
  definition = task_data.get("definition", [])
314
317
  fields = []
@@ -317,12 +320,14 @@ def _execute_entry(
317
320
  if block.get("type", "").startswith("form-"):
318
321
  opts = block.get("options", {})
319
322
  if opts.get("name"):
320
- fields.append({
321
- "key": opts["name"],
322
- "label": opts.get("title", opts["name"]),
323
- "type": block["type"],
324
- "required": opts.get("required", False),
325
- })
323
+ fields.append(
324
+ {
325
+ "key": opts["name"],
326
+ "label": opts.get("title", opts["name"]),
327
+ "type": block["type"],
328
+ "required": opts.get("required", False),
329
+ }
330
+ )
326
331
  if fields:
327
332
  dry_run_data["task_fields"] = fields
328
333
  current_values = task_data.get("values", {})
@@ -346,13 +351,13 @@ def _execute_entry(
346
351
  handle_error(ctx, e)
347
352
  return
348
353
 
349
- # After record creation, enrich tasks with their form field definitions
350
- if operation == "create" and result.get("data", {}).get("tasks"):
354
+ # After record creation, enrich tasks with their form field definitions.
355
+ # Skipped in --quiet mode: the enrichment makes extra calls and only
356
+ # populates output that quiet mode discards anyway.
357
+ if not ctx.quiet_mode and operation == "create" and result.get("data", {}).get("tasks"):
351
358
  try:
352
359
  for task in result["data"]["tasks"]:
353
- task_detail = asyncio.run(
354
- client.request("GET", f"/api/v1/tasks/{task['id']}")
355
- )
360
+ task_detail = asyncio.run(client.request("GET", f"/api/v1/tasks/{task['id']}"))
356
361
  task_def = task_detail.get("data", {}).get("definition", [])
357
362
  if isinstance(task_def, list):
358
363
  fields = []
@@ -360,22 +365,20 @@ def _execute_entry(
360
365
  if block.get("type", "").startswith("form-"):
361
366
  opts = block.get("options", {})
362
367
  if opts.get("name"):
363
- fields.append({
364
- "key": opts["name"],
365
- "label": opts.get("title", opts["name"]),
366
- "type": block["type"],
367
- "required": opts.get("required", False),
368
- })
368
+ fields.append(
369
+ {
370
+ "key": opts["name"],
371
+ "label": opts.get("title", opts["name"]),
372
+ "type": block["type"],
373
+ "required": opts.get("required", False),
374
+ }
375
+ )
369
376
  if fields:
370
377
  task["fields"] = fields
371
378
  except Exception:
372
379
  pass # Non-critical enrichment
373
380
 
374
- if ctx.json_mode:
375
- click.echo(format_json(result, compact=True))
376
- else:
377
- d = result.get("data", {})
378
- click.echo(f"Entry {operation}: {d.get('id', d.get('entryId', ''))}")
381
+ emit_action_result(ctx, result, label=f"Entry {operation}", id_keys=("id", "entryId"))
379
382
 
380
383
 
381
384
  # File-upload flow for Pattern B (Items + Records, which persist `values`
@@ -397,12 +400,21 @@ def _execute_entry_with_files(ctx, *, entry_id, task_id, data, files):
397
400
  client = make_client(ctx)
398
401
  landed_uids: list[str] = []
399
402
 
400
- schema_fields = _fetch_schema_or_die(ctx, client, entry_id)
403
+ # Records hold their file fields on individual tasks, so an upload must name
404
+ # the task: it drives both the schema lookup and the values read/merge.
405
+ if _is_record_id(entry_id) and not task_id:
406
+ click.echo(
407
+ "Record file uploads require --task-id (records hold fields on tasks).",
408
+ err=True,
409
+ )
410
+ sys.exit(1)
411
+
412
+ schema_fields = _fetch_schema_or_die(ctx, client, entry_id, task_id=task_id)
401
413
 
402
414
  # Hard-fail before any S3 upload on unknown/ambiguous/non-file fields.
403
415
  resolved = _resolve_field_descriptors(ctx, schema_fields, files)
404
416
 
405
- values, expected_version = _fetch_entry_values(ctx, client, entry_id)
417
+ values, expected_version = _fetch_entry_values(ctx, client, entry_id, task_id=task_id)
406
418
 
407
419
  # The splice spec is built once during upload and re-applied on
408
420
  # every conflict retry. Top-level entries land via append_file_to_form_field
@@ -427,9 +439,7 @@ def _execute_entry_with_files(ctx, *, entry_id, task_id, data, files):
427
439
  landed_uids.append(file_item["uid"])
428
440
 
429
441
  if "table_key" in descriptor:
430
- table_rows = splice_spec["nested"].setdefault(
431
- descriptor["table_key"], {}
432
- )
442
+ table_rows = splice_spec["nested"].setdefault(descriptor["table_key"], {})
433
443
  table_rows.setdefault(descriptor["col_key"], []).append(file_item)
434
444
  else:
435
445
  splice_spec["top_level"].append((descriptor["ff_key"], file_item))
@@ -485,11 +495,18 @@ def _apply_splice_spec(values, splice_spec):
485
495
  return values
486
496
 
487
497
 
488
- def _fetch_schema_or_die(ctx, client, entry_id):
498
+ def _is_record_id(entry_id):
499
+ return bool(entry_id) and (entry_id.startswith("r_") or entry_id.startswith("rec_"))
500
+
501
+
502
+ def _fetch_schema_or_die(ctx, client, entry_id, task_id=None):
503
+ path = f"/api/v1/assets/{entry_id}/schema"
504
+ if task_id:
505
+ # Records carry fields per-task; name the task so the backend returns
506
+ # that task's field schema instead of a (non-existent) record schema.
507
+ path += f"?taskId={task_id}"
489
508
  try:
490
- resp = asyncio.run(
491
- client.request("GET", f"/api/v1/assets/{entry_id}/schema")
492
- )
509
+ resp = asyncio.run(client.request("GET", path))
493
510
  except Exception as e:
494
511
  handle_error(ctx, e)
495
512
  sys.exit(1)
@@ -518,12 +535,8 @@ def _resolve_field_descriptors(ctx, schema_fields, files):
518
535
  All resolution failures (unknown table, unknown column, ambiguous label,
519
536
  non-file target) raise SystemExit before any S3 traffic.
520
537
  """
521
- top_file_fields = [
522
- f for f in schema_fields if f.get("type") == AGENT_SCHEMA_FILE_UPLOAD_TYPE
523
- ]
524
- table_fields = [
525
- f for f in schema_fields if f.get("type") == AGENT_SCHEMA_TABLE_TYPE
526
- ]
538
+ top_file_fields = [f for f in schema_fields if f.get("type") == AGENT_SCHEMA_FILE_UPLOAD_TYPE]
539
+ table_fields = [f for f in schema_fields if f.get("type") == AGENT_SCHEMA_TABLE_TYPE]
527
540
  by_key = {f["key"]: f for f in schema_fields if f.get("key")}
528
541
  titles_to_keys: dict[str, list[str]] = {}
529
542
  for f in top_file_fields:
@@ -603,9 +616,7 @@ def _resolve_nested(ctx, descriptor, raw_stripped, table_fields):
603
616
  _fail_loud_unknown_table(ctx, table_raw, table_fields)
604
617
 
605
618
  columns = [c for c in (table.get("columns") or []) if isinstance(c, dict)]
606
- file_columns = [
607
- c for c in columns if c.get("type") == AGENT_SCHEMA_FILE_UPLOAD_TYPE
608
- ]
619
+ file_columns = [c for c in columns if c.get("type") == AGENT_SCHEMA_FILE_UPLOAD_TYPE]
609
620
 
610
621
  # Match the column by its full schema key OR its bare last segment: the
611
622
  # backend reports table columns as the dotted `ff_table.ff_col`, but a user
@@ -621,9 +632,7 @@ def _resolve_nested(ctx, descriptor, raw_stripped, table_fields):
621
632
  if col_raw in col_by_key:
622
633
  col = col_by_key[col_raw]
623
634
  if col.get("type") != AGENT_SCHEMA_FILE_UPLOAD_TYPE:
624
- _fail_loud_non_file_column(
625
- ctx, table, _bare_col_key(col.get("key")), file_columns
626
- )
635
+ _fail_loud_non_file_column(ctx, table, _bare_col_key(col.get("key")), file_columns)
627
636
  return {
628
637
  **descriptor,
629
638
  "table_key": table["key"],
@@ -645,7 +654,7 @@ def _resolve_nested(ctx, descriptor, raw_stripped, table_fields):
645
654
  if len(candidates) > 1:
646
655
  click.echo(
647
656
  f'Column "{col_raw}" in table "{table.get("label") or table["key"]}" '
648
- f'matches multiple file columns: '
657
+ f"matches multiple file columns: "
649
658
  f'{", ".join(_bare_col_key(k) for k in candidates)}. '
650
659
  f"Pass the ff_* key to disambiguate.",
651
660
  err=True,
@@ -659,10 +668,7 @@ def _match_table(table_raw, table_fields):
659
668
  if table_raw in by_key:
660
669
  return by_key[table_raw]
661
670
  norm = table_raw.lower()
662
- label_matches = [
663
- t for t in table_fields
664
- if (t.get("label") or "").strip().lower() == norm
665
- ]
671
+ label_matches = [t for t in table_fields if (t.get("label") or "").strip().lower() == norm]
666
672
  if len(label_matches) == 1:
667
673
  return label_matches[0]
668
674
  # Ambiguous tables (two with the same label) fall through to the
@@ -671,14 +677,12 @@ def _match_table(table_raw, table_fields):
671
677
 
672
678
 
673
679
  def _fail_loud_unknown(ctx, field_name, file_fields, table_fields=()):
674
- available = ", ".join(
675
- f'"{f.get("label")}" → {f.get("key")}' for f in file_fields
676
- ) or "(none)"
680
+ available = ", ".join(f'"{f.get("label")}" → {f.get("key")}' for f in file_fields) or "(none)"
677
681
  extra = ""
678
682
  if table_fields:
679
683
  nested = []
680
684
  for t in table_fields:
681
- for c in (t.get("columns") or []):
685
+ for c in t.get("columns") or []:
682
686
  if isinstance(c, dict) and c.get("type") == AGENT_SCHEMA_FILE_UPLOAD_TYPE:
683
687
  nested.append(
684
688
  f'"{t.get("label") or t.get("key")}.'
@@ -686,9 +690,7 @@ def _fail_loud_unknown(ctx, field_name, file_fields, table_fields=()):
686
690
  f'{t.get("key")}.{_bare_col_key(c.get("key"))}'
687
691
  )
688
692
  if nested:
689
- extra = (
690
- f" Nested file columns (use TABLE.COLUMN form): {', '.join(nested)}."
691
- )
693
+ extra = f" Nested file columns (use TABLE.COLUMN form): {', '.join(nested)}."
692
694
  click.echo(
693
695
  f'No file field named "{field_name}" on this entry. '
694
696
  f"Available file fields (title → id): {available}.{extra}",
@@ -706,17 +708,14 @@ def _fail_loud_non_file(ctx, key, file_fields, table_fields=()):
706
708
  "into a nested file column)"
707
709
  )
708
710
  click.echo(
709
- f'Field "{key}" is not a file-upload field{extra}. '
710
- f"Available file fields: {available}.",
711
+ f'Field "{key}" is not a file-upload field{extra}. ' f"Available file fields: {available}.",
711
712
  err=True,
712
713
  )
713
714
  sys.exit(1)
714
715
 
715
716
 
716
717
  def _fail_loud_unknown_table(ctx, table_raw, table_fields):
717
- available = ", ".join(
718
- f'"{t.get("label")}" → {t.get("key")}' for t in table_fields
719
- ) or "(none)"
718
+ available = ", ".join(f'"{t.get("label")}" → {t.get("key")}' for t in table_fields) or "(none)"
720
719
  click.echo(
721
720
  f'No table field named "{table_raw}" on this entry. '
722
721
  f"Available tables (title → id): {available}.",
@@ -726,9 +725,10 @@ def _fail_loud_unknown_table(ctx, table_raw, table_fields):
726
725
 
727
726
 
728
727
  def _fail_loud_unknown_column(ctx, table, col_raw, file_columns):
729
- available = ", ".join(
730
- f'"{c.get("label")}" → {_bare_col_key(c.get("key"))}' for c in file_columns
731
- ) or "(none)"
728
+ available = (
729
+ ", ".join(f'"{c.get("label")}" → {_bare_col_key(c.get("key"))}' for c in file_columns)
730
+ or "(none)"
731
+ )
732
732
  click.echo(
733
733
  f'No file column "{col_raw}" in table '
734
734
  f'"{table.get("label") or table.get("key")}". '
@@ -739,9 +739,7 @@ def _fail_loud_unknown_column(ctx, table, col_raw, file_columns):
739
739
 
740
740
 
741
741
  def _fail_loud_non_file_column(ctx, table, col_key, file_columns):
742
- available = ", ".join(
743
- _bare_col_key(c.get("key")) for c in file_columns
744
- ) or "(none)"
742
+ available = ", ".join(_bare_col_key(c.get("key")) for c in file_columns) or "(none)"
745
743
  click.echo(
746
744
  f'Column "{col_key}" in table '
747
745
  f'"{table.get("label") or table.get("key")}" is not a file-upload column. '
@@ -751,7 +749,24 @@ def _fail_loud_non_file_column(ctx, table, col_key, file_columns):
751
749
  sys.exit(1)
752
750
 
753
751
 
754
- def _fetch_entry_values(ctx, client, entry_id):
752
+ def _fetch_entry_values(ctx, client, entry_id, task_id=None):
753
+ # A record's values live on its tasks, not at the record level, so a file
754
+ # upload to a record reads (and later merges into) the named task's values.
755
+ # completeTask is last-writer-wins, so no version is needed for records.
756
+ if _is_record_id(entry_id) and task_id:
757
+ try:
758
+ task = asyncio.run(client.request("GET", f"/api/v1/tasks/{task_id}"))
759
+ except Exception as e:
760
+ handle_error(ctx, e)
761
+ sys.exit(1)
762
+ task_data = (task or {}).get("data") or {}
763
+ values = task_data.get("values") if isinstance(task_data, dict) else None
764
+ if values is None:
765
+ values = {}
766
+ if not isinstance(values, dict):
767
+ click.echo("Task values are not a flat dict.", err=True)
768
+ sys.exit(1)
769
+ return values, None
755
770
  try:
756
771
  entry = asyncio.run(client.request("GET", f"/api/v1/assets/{entry_id}"))
757
772
  except Exception as e:
@@ -796,9 +811,7 @@ def _submit_with_retry(
796
811
  expected_version=current_version if not task_id else None,
797
812
  )
798
813
  try:
799
- result = asyncio.run(
800
- client.request("POST", "/api/v1/entries", json=payload)
801
- )
814
+ result = asyncio.run(client.request("POST", "/api/v1/entries", json=payload))
802
815
  except Exception as e:
803
816
  if _is_version_conflict(e) and not task_id and attempt < _CONFLICT_RETRY_BUDGET:
804
817
  current_values, current_version = _refresh_and_reapply(
@@ -935,9 +948,7 @@ def _emit_retry_exhausted(ctx, landed_uids):
935
948
  else:
936
949
  click.echo(err_body["message"], err=True)
937
950
  if landed_uids:
938
- click.echo(
939
- "Files uploaded to S3 (subject to lifecycle cleanup):", err=True
940
- )
951
+ click.echo("Files uploaded to S3 (subject to lifecycle cleanup):", err=True)
941
952
  for uid in landed_uids:
942
953
  click.echo(f" {uid}", err=True)
943
954
  sys.exit(1)
src/cli/get.py CHANGED
@@ -17,7 +17,7 @@ _PREFIX_ROUTES = {
17
17
  }
18
18
 
19
19
  # Known asset prefixes (routed to /api/v1/assets)
20
- _ASSET_PREFIXES = ("d_", "doc_", "rg_", "rd_", "r_", "rec_", "i_")
20
+ _ASSET_PREFIXES = ("d_", "doc_", "rg_", "rd_", "r_", "rec_", "i_", "cd_")
21
21
 
22
22
  # Explicit --type → endpoint
23
23
  _TYPE_ROUTES = {
@@ -62,8 +62,13 @@ def _detect_route(resource_id, resource_type):
62
62
  type=click.Choice(["member", "group", "task", "todo"]),
63
63
  help="Resource type (for IDs without a known prefix, e.g. member).",
64
64
  )
65
+ @click.option(
66
+ "--draft", is_flag=True, default=False,
67
+ help="Read the unpublished working copy instead of the published version "
68
+ "(record definitions: shows edits not yet released).",
69
+ )
65
70
  @pass_context
66
- def get_asset(ctx, resource_id, fmt, resource_type):
71
+ def get_asset(ctx, resource_id, fmt, resource_type, draft):
67
72
  """Retrieve an asset, member, or group by ID.
68
73
 
69
74
  Asset and group IDs are auto-detected by prefix (rg_, d_, g_, etc.).
@@ -72,6 +77,7 @@ def get_asset(ctx, resource_id, fmt, resource_type):
72
77
  Examples:
73
78
  upscaler get rg_abc123
74
79
  upscaler get rg_abc123 --format schema
80
+ upscaler get rd_abc123 --format schema --draft
75
81
  upscaler get g_abc123
76
82
  upscaler get <uid> --type member
77
83
  upscaler --json get <uid> --type member
@@ -84,10 +90,19 @@ def get_asset(ctx, resource_id, fmt, resource_type):
84
90
  endpoint, is_asset = _detect_route(resource_id, resource_type)
85
91
 
86
92
  if endpoint is None:
87
- msg = (
88
- f"Unknown resource ID format: {resource_id}. "
89
- "Use --type member or --type group for IDs without a known prefix."
90
- )
93
+ if resource_id.startswith("cl_"):
94
+ # Lessons are not standalone assets: they live inside a course and
95
+ # are read through it. `get <cd_…>` returns every lesson with its
96
+ # id, title, description, and body.
97
+ msg = (
98
+ f"{resource_id} is a lesson, which is read through its course. "
99
+ "Run `upscaler get <cd_…>` to see every lesson (id, title, body)."
100
+ )
101
+ else:
102
+ msg = (
103
+ f"Unknown resource ID format: {resource_id}. "
104
+ "Use --type member or --type group for IDs without a known prefix."
105
+ )
91
106
  if ctx.json_mode:
92
107
  import json
93
108
  click.echo(json.dumps({"error": msg, "exit_code": 1}), err=True)
@@ -97,14 +112,16 @@ def get_asset(ctx, resource_id, fmt, resource_type):
97
112
  return
98
113
 
99
114
  if is_asset:
100
- _get_asset(ctx, client, resource_id, fmt, format_json)
115
+ _get_asset(ctx, client, resource_id, fmt, format_json, draft=draft)
101
116
  else:
102
117
  _get_simple(ctx, client, f"{endpoint}/{resource_id}", format_json)
103
118
 
104
119
 
105
- def _get_asset(ctx, client, asset_id, fmt, format_json):
120
+ def _get_asset(ctx, client, asset_id, fmt, format_json, draft=False):
106
121
  """Fetch and display an asset with format control."""
107
122
  params = {}
123
+ if draft:
124
+ params["draft"] = "true"
108
125
  if fmt:
109
126
  params["format"] = fmt
110
127
 
src/cli/helpers.py CHANGED
@@ -50,6 +50,60 @@ def parse_data(value: str) -> dict:
50
50
  return data
51
51
 
52
52
 
53
+ def build_values_payload(data_input, values_file, values_type) -> dict:
54
+ """Build the ``{"values": ...}`` payload for a ``set-*-values`` command.
55
+
56
+ Content comes from exactly one of ``--data`` or ``--values-file``. A
57
+ ``--values-file`` is read as a markdown string, or parsed as a JSON Slate
58
+ body when ``--values-type`` is not ``markdown`` (packing slateJson as a raw
59
+ string sends a string where the platform expects a list, which it silently
60
+ drops, so a malformed file must fail loudly). On any usage error this prints
61
+ to stderr and exits non-zero, matching the rest of the CLI.
62
+
63
+ Args:
64
+ data_input: The ``--data`` flag value (or None).
65
+ values_file: The ``--values-file`` path (or None).
66
+ values_type: The ``--values-type`` value (e.g. "markdown", "slateJson").
67
+
68
+ Returns:
69
+ A dict with a ``values`` key, ready for the command's id/valuesType keys.
70
+ """
71
+ import click
72
+
73
+ if not data_input and not values_file:
74
+ click.echo("Provide either --data or --values-file.", err=True)
75
+ sys.exit(1)
76
+ if data_input and values_file:
77
+ click.echo("--data and --values-file are mutually exclusive.", err=True)
78
+ sys.exit(1)
79
+
80
+ if values_file:
81
+ raw = Path(values_file).read_text()
82
+ if values_type == "markdown":
83
+ values = raw
84
+ else:
85
+ try:
86
+ values = json.loads(raw)
87
+ except json.JSONDecodeError as e:
88
+ click.echo(
89
+ f"--values-file expects a JSON Slate body for --values-type "
90
+ f"slateJson, but {values_file} is not valid JSON: {e}",
91
+ err=True,
92
+ )
93
+ sys.exit(1)
94
+ return {"values": values}
95
+
96
+ try:
97
+ data = parse_data(data_input)
98
+ except Exception as e:
99
+ click.echo(str(e), err=True)
100
+ sys.exit(1)
101
+ if "values" not in data:
102
+ click.echo("--data must include a 'values' key.", err=True)
103
+ sys.exit(1)
104
+ return data
105
+
106
+
53
107
  def validate_entry_data(data: dict) -> dict:
54
108
  """Reject entry payloads that put ff_* keys at the top level.
55
109
 
@@ -88,6 +142,87 @@ def handle_error(ctx, e) -> None:
88
142
  sys.exit(exit_code)
89
143
 
90
144
 
145
+ def _resolve_id(data: dict, id_keys) -> str:
146
+ """Return the first non-empty id value among id_keys, or ''."""
147
+ for key in id_keys:
148
+ value = (data or {}).get(key)
149
+ if value:
150
+ return value
151
+ return ""
152
+
153
+
154
+ _RESULT_ID_KEYS = ("id", "entryId", "entry_id", "assetId", "asset_id")
155
+
156
+
157
+ def _envelope_error_message(error) -> str:
158
+ """Extract a human-readable message from a native-tool error envelope.
159
+
160
+ `error` is the unified envelope dict ({error_code, message, ...}) on most
161
+ failures, but tolerate a bare string or missing value too.
162
+ """
163
+ if isinstance(error, dict):
164
+ return error.get("message") or error.get("error_code") or "Operation failed"
165
+ if error:
166
+ return str(error)
167
+ return "Operation failed"
168
+
169
+
170
+ def emit_action_result(ctx, result: dict, *, label: str, id_keys=_RESULT_ID_KEYS) -> None:
171
+ """Print the outcome of a create/update mutation, honoring --quiet/--json.
172
+
173
+ quiet : print only the resolved id (one line); nothing else.
174
+ json : print the full compact envelope.
175
+ human : print "<label>: <id>".
176
+
177
+ A native-tool envelope can report failure with HTTP 200
178
+ ({"success": false, "error": {...}}); surface that error and exit non-zero
179
+ instead of printing an empty id.
180
+ """
181
+ import click
182
+
183
+ from src.formatters.json_fmt import format_json
184
+
185
+ if result.get("success") is False:
186
+ if ctx.json_mode:
187
+ click.echo(format_json(result, compact=True), err=True)
188
+ else:
189
+ click.echo(f"{label} failed: {_envelope_error_message(result.get('error'))}", err=True)
190
+ sys.exit(1)
191
+ return
192
+
193
+ if getattr(ctx, "quiet_mode", False):
194
+ rid = _resolve_id(result.get("data", {}), id_keys)
195
+ if rid:
196
+ click.echo(rid)
197
+ return
198
+ if ctx.json_mode:
199
+ click.echo(format_json(result, compact=True))
200
+ else:
201
+ rid = _resolve_id(result.get("data", {}), id_keys)
202
+ click.echo(f"{label}: {rid}")
203
+
204
+
205
+ def emit_dry_run(ctx, *, summary: str, payload: dict) -> None:
206
+ """Print a dry-run preview, honoring --quiet/--json.
207
+
208
+ quiet : print only the one-line summary (no body).
209
+ json : print {"dry_run": true, "payload": ...} compact.
210
+ human : print the summary line followed by the pretty payload.
211
+ """
212
+ import click
213
+
214
+ from src.formatters.json_fmt import format_json
215
+
216
+ if getattr(ctx, "quiet_mode", False):
217
+ click.echo(f"[dry-run] {summary}")
218
+ return
219
+ if ctx.json_mode:
220
+ click.echo(format_json({"dry_run": True, "payload": payload}, compact=True))
221
+ else:
222
+ click.echo(f"[dry-run] {summary}")
223
+ click.echo(format_json(payload))
224
+
225
+
91
226
  def make_client(ctx):
92
227
  """Create an UpscalerClient from CLI context.
93
228
 
src/cli/list_cmd.py CHANGED
@@ -116,10 +116,16 @@ def list_group():
116
116
 
117
117
 
118
118
  @list_group.command("definitions")
119
+ @click.option("--limit", default=20, type=int, help="Max results to return (1-200).")
120
+ @click.option("--offset", default=0, type=int, help="Offset for pagination.")
119
121
  @pass_context
120
- def list_definitions(ctx):
121
- """List all definitions (registers, records)."""
122
- _do_list(ctx, type_name="definitions")
122
+ def list_definitions(ctx, limit, offset):
123
+ """List all definitions (registers, records).
124
+
125
+ Results are paginated; use --limit/--offset to page through, matching
126
+ `list entries`.
127
+ """
128
+ _do_list(ctx, type_name="definitions", limit=limit, offset=offset)
123
129
 
124
130
 
125
131
  @list_group.command("entries")
@@ -167,6 +173,16 @@ def list_definitions(ctx):
167
173
  "human label (case-insensitive). Implies --include-values."
168
174
  ),
169
175
  )
176
+ @click.option(
177
+ "--fields",
178
+ "fields_csv",
179
+ default=None,
180
+ help=(
181
+ "Comma-separated field keys/labels to return under `values` "
182
+ "(e.g. --fields status,owner). Convenience alias for repeated "
183
+ "--select-value; both may be combined. Implies --include-values."
184
+ ),
185
+ )
170
186
  @click.option(
171
187
  "--resolve-labels",
172
188
  is_flag=True,
@@ -187,6 +203,7 @@ def list_entries(
187
203
  offset,
188
204
  include_values,
189
205
  select_values_raw,
206
+ fields_csv,
190
207
  resolve_labels,
191
208
  ):
192
209
  """List entries for a definition with optional filtering and sorting."""
@@ -199,13 +216,19 @@ def list_entries(
199
216
 
200
217
  sort = _parse_sort(sort_raw) if sort_raw else None
201
218
 
219
+ # --fields is a comma-separated convenience alias for repeated
220
+ # --select-value; combine both into one projection spec list.
221
+ select_specs = list(select_values_raw)
222
+ if fields_csv:
223
+ select_specs.extend(f.strip() for f in fields_csv.split(",") if f.strip())
224
+
202
225
  schema_fields = None
203
- if select_values_raw or resolve_labels:
226
+ if select_specs or resolve_labels:
204
227
  schema_fields = _fetch_schema_fields(ctx, definition_id)
205
228
 
206
229
  select_values = None
207
- if select_values_raw:
208
- select_values = _resolve_select_values(select_values_raw, schema_fields)
230
+ if select_specs:
231
+ select_values = _resolve_select_values(select_specs, schema_fields)
209
232
  include_values = True
210
233
 
211
234
  _do_list(
src/cli/main.py CHANGED
@@ -6,7 +6,9 @@ Global flags:
6
6
  --json / --no-json Output structured JSON to stdout. Default from
7
7
  $UPSCALER_OUTPUT (set to 'json' to make JSON the
8
8
  shell-wide default; --no-json forces human output
9
- when the env var is set).
9
+ when the env var is set). Passing --json explicitly
10
+ is always accepted and idempotent, even when the env
11
+ var already makes JSON the default.
10
12
  --verbose Log HTTP request/response to stderr
11
13
  --server Override REST API server URL
12
14
  --profile NAME Use a named profile (auth + config). Default: prod.
@@ -25,8 +27,7 @@ from src.profile import resolve_profile
25
27
 
26
28
  AGENT_SKILLS_URL = "https://github.com/upscaler-io/agent-skills"
27
29
  _VERSION_MESSAGE = (
28
- f"%(prog)s %(version)s\n"
29
- f"Agent skills: {AGENT_SKILLS_URL}/tree/{src.RECOMMENDED_SKILLS_REF}"
30
+ f"%(prog)s %(version)s\n" f"Agent skills: {AGENT_SKILLS_URL}/tree/{src.RECOMMENDED_SKILLS_REF}"
30
31
  )
31
32
 
32
33
 
@@ -34,27 +35,100 @@ def _default_json_mode():
34
35
  return os.environ.get("UPSCALER_OUTPUT", "").lower() == "json"
35
36
 
36
37
 
37
- @click.group(epilog=f"Agent skills for Upscaler: {AGENT_SKILLS_URL}")
38
+ # Global options that GlobalFlagGroup accepts in any position. Boolean flags
39
+ # take no value; value options carry the following token (or the --opt=value
40
+ # form).
41
+ _GLOBAL_BOOL_FLAGS = frozenset({"--json", "--no-json", "--quiet", "-q", "--verbose", "-v"})
42
+ _GLOBAL_VALUE_OPTS = frozenset({"--server", "--profile"})
43
+
44
+
45
+ def _hoist_global_options(args):
46
+ """Move the CLI's global options to the front of the argument list.
47
+
48
+ Click only consumes group-level options that appear before the subcommand,
49
+ so `upscaler health --json` fails with "No such option" even though
50
+ `upscaler --json health` works. Agents and people routinely place `--json`
51
+ (and friends) after the subcommand, so the root group hoists the known
52
+ global options to the front before Click parses the command.
53
+
54
+ Scanning stops at `--`, the end-of-options marker, leaving genuinely
55
+ positional arguments after it untouched. A global flag's literal spelling
56
+ passed as another option's value would be hoisted too; that pathological
57
+ case is not supported.
58
+ """
59
+ hoisted = []
60
+ rest = []
61
+ i = 0
62
+ n = len(args)
63
+ while i < n:
64
+ tok = args[i]
65
+ if tok == "--":
66
+ rest.extend(args[i:])
67
+ break
68
+ if tok in _GLOBAL_BOOL_FLAGS:
69
+ hoisted.append(tok)
70
+ i += 1
71
+ elif tok in _GLOBAL_VALUE_OPTS:
72
+ hoisted.append(tok)
73
+ if i + 1 < n:
74
+ hoisted.append(args[i + 1])
75
+ i += 2
76
+ else:
77
+ i += 1
78
+ elif "=" in tok and tok.split("=", 1)[0] in _GLOBAL_VALUE_OPTS:
79
+ hoisted.append(tok)
80
+ i += 1
81
+ else:
82
+ rest.append(tok)
83
+ i += 1
84
+ return hoisted + rest
85
+
86
+
87
+ class GlobalFlagGroup(click.Group):
88
+ """Root group that accepts the CLI's global options in any position.
89
+
90
+ Global flags are defined on this group, which by default Click only parses
91
+ before the subcommand. Hoisting them (see _hoist_global_options) makes
92
+ `upscaler list entries --json` behave like `upscaler --json list entries`.
93
+ """
94
+
95
+ def parse_args(self, ctx, args):
96
+ return super().parse_args(ctx, _hoist_global_options(args))
97
+
98
+
99
+ @click.group(cls=GlobalFlagGroup, epilog=f"Agent skills for Upscaler: {AGENT_SKILLS_URL}")
38
100
  @click.option(
39
101
  "--json/--no-json",
40
102
  "json_mode",
41
103
  default=_default_json_mode,
42
104
  help="Output structured JSON. Default from $UPSCALER_OUTPUT=json.",
43
105
  )
106
+ @click.option(
107
+ "--quiet",
108
+ "-q",
109
+ "quiet_mode",
110
+ is_flag=True,
111
+ help="Print only the resulting id(s); suppress full payload/schema echo.",
112
+ )
44
113
  @click.option("--verbose", "-v", is_flag=True, help="Log HTTP details to stderr.")
45
114
  @click.option("--server", default=None, help="Override REST API server URL.")
46
115
  @click.option(
47
116
  "--profile",
48
117
  default=None,
49
- help="Profile name (own auth + config). Default: prod. "
50
- "Overrides $UPSCALER_PROFILE.",
118
+ help="Profile name (own auth + config). Default: prod. " "Overrides $UPSCALER_PROFILE.",
51
119
  )
52
120
  @click.version_option(version=src.__version__, prog_name="upscaler", message=_VERSION_MESSAGE)
53
121
  @click.pass_context
54
- def cli(ctx, json_mode, verbose, server, profile):
55
- """Upscaler CLI: search, retrieve, and manage documents, records, and workflows."""
122
+ def cli(ctx, json_mode, quiet_mode, verbose, server, profile):
123
+ """Upscaler CLI: search, retrieve, and manage documents, records, and workflows.
124
+
125
+ Global flags (--json, --quiet, --profile, --server, --verbose) are options on
126
+ this top-level group but are accepted in any position, so both
127
+ `upscaler --json list entries ...` and `upscaler list entries --json` work.
128
+ """
56
129
  ctx.ensure_object(Context)
57
130
  ctx.obj.json_mode = json_mode
131
+ ctx.obj.quiet_mode = quiet_mode
58
132
  ctx.obj.verbose = verbose
59
133
  ctx.obj.server_url = server
60
134
  try:
@@ -86,8 +160,7 @@ def health(ctx):
86
160
  verify_ssl = config.resolve_verify_ssl()
87
161
  if not server_url:
88
162
  click.echo(
89
- "Server URL not configured. "
90
- "Use --server or: upscaler config set server_url <url>",
163
+ "Server URL not configured. " "Use --server or: upscaler config set server_url <url>",
91
164
  err=True,
92
165
  )
93
166
  sys.exit(1)
@@ -98,21 +171,30 @@ def health(ctx):
98
171
  )
99
172
  data = response.json()
100
173
  if ctx.obj.json_mode:
101
- click.echo(json.dumps({
102
- "success": True,
103
- "status": data.get("status"),
104
- "server": server_url,
105
- }))
174
+ click.echo(
175
+ json.dumps(
176
+ {
177
+ "success": True,
178
+ "status": data.get("status"),
179
+ "server": server_url,
180
+ }
181
+ )
182
+ )
106
183
  else:
107
184
  click.echo(f"Server: {server_url}")
108
185
  click.echo(f"Status: {data.get('status', 'unknown')}")
109
186
  except Exception as e:
110
187
  if ctx.obj.json_mode:
111
- click.echo(json.dumps({
112
- "success": False,
113
- "error": str(e),
114
- "server": server_url,
115
- }), err=True)
188
+ click.echo(
189
+ json.dumps(
190
+ {
191
+ "success": False,
192
+ "error": str(e),
193
+ "server": server_url,
194
+ }
195
+ ),
196
+ err=True,
197
+ )
116
198
  else:
117
199
  click.echo(f"Server: {server_url}")
118
200
  click.echo(f"Status: unreachable ({e})", err=True)
src/client.py CHANGED
@@ -105,7 +105,17 @@ class UpscalerClient:
105
105
  raise APIError(f"Invalid JSON response from {path}", response.status_code)
106
106
 
107
107
  if response.status_code >= 400:
108
- error_msg = result.get("error", f"HTTP {response.status_code}")
108
+ err = result.get("error")
109
+ if isinstance(err, dict):
110
+ # Unified error envelope ({error_code, message, ...}); pass a
111
+ # readable string to APIError, not the raw dict.
112
+ error_msg = (
113
+ err.get("message")
114
+ or err.get("error_code")
115
+ or f"HTTP {response.status_code}"
116
+ )
117
+ else:
118
+ error_msg = err or f"HTTP {response.status_code}"
109
119
  raise APIError(error_msg, response.status_code)
110
120
 
111
121
  return result
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: upscaler-cli
3
- Version: 0.2.3.dev6696
3
+ Version: 0.2.3.dev6697
4
4
  Summary: Upscaler CLI - search, retrieve, and manage documents, records, and workflows
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: click>=8.0
@@ -1,5 +1,5 @@
1
1
  src/__init__.py,sha256=uPUKlWJW1lQznOQiQGxfCjKn6GR6sNapNL-2ihXFIqs,272
2
- src/client.py,sha256=LCYw5lIxnTgMaCl-7lk6oAUR3ppc0myya1agGWaxY3I,4694
2
+ src/client.py,sha256=uQbutI4MXPupXOIVUfKUIr_Z0PTM1W5uqDfjwxrvTZ8,5107
3
3
  src/config.py,sha256=nOfHEXnUnxLNIv6Ko1LVbrCtrJyvLpGDoe_m7qG_0tw,4803
4
4
  src/errors.py,sha256=BMaD32cmXavKfH6SdhsoD3jhBZgap-YpTWZC0IZO_Vo,832
5
5
  src/profile.py,sha256=XhCkmVhs_G-ELM6avTytrpTeJ-REoMgx6a-iSA-liOk,3918
@@ -9,20 +9,20 @@ src/auth/encryption.py,sha256=ct3pC9kW94LnfZN3puGf9_PeRHSvNzfwK78Yf_YuYo0,2397
9
9
  src/auth/oauth.py,sha256=PQ7Ov6LLkRYUyQp5lUuhFDBGIqQ-TGfLeaxqbn9qW6U,21213
10
10
  src/auth/token_store.py,sha256=oQOArbgTozxNyypz6KFhC7OJxee0xCsQIMML6Jy88CI,4005
11
11
  src/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
- src/cli/asset.py,sha256=G7wN2qT_Ls_i17OIw8vRH9EhfxqWRyZpVoPAV573n4I,24277
12
+ src/cli/asset.py,sha256=SouscANT7rmkRMrdjnoF4GJYS8-RvmkLQDk7drDJijU,29487
13
13
  src/cli/auth.py,sha256=N04ITXNrMfc8IvnjJDmgJRm3R5S62SdYDPm_s3tny9A,13976
14
14
  src/cli/automation.py,sha256=jwHaE2zfRJ-k5d1p2rwdrOrGe56pqsqKopxpW3IYaZU,7062
15
15
  src/cli/completions.py,sha256=skCxzy7h92Rt2a9xq_oFzWmf6ApaIGCVu0Dsc9TauYQ,786
16
16
  src/cli/config_cmd.py,sha256=qqQNRaDKdtGajpOyIx0qFFgAH5lJbbrOLNcc0Dhq_oQ,1186
17
- src/cli/context.py,sha256=H1doPOx9jShR78k9E52MfgFZNW_SOwQN0Pa5r6hdJMM,442
18
- src/cli/entry.py,sha256=O2J0hkl2h6_baBP-3JbbljMdBNY-N_9PajOXEUYCyx4,33040
17
+ src/cli/context.py,sha256=uqL_EWjLcDLsFv2YmNBtJlFud5Jd1mJv63mZMu4eIuo,480
18
+ src/cli/entry.py,sha256=WQba9TqUr4X8FYGeAhktu3iZpHGCfSgXVW-jb75D6tI,34728
19
19
  src/cli/files.py,sha256=g8yiX2Nr2Nbg3bFbSDucLKUNOBCBHokNwqOAV_XyFeM,2349
20
20
  src/cli/framework.py,sha256=XI5PU3oDbzzSJqPXkbuxkJxKoXaf6UbyzpRp23SD90M,14820
21
- src/cli/get.py,sha256=NA6Ms1HkqRH9TQ1_pyMBqPh3wwJQ75QwcFS3hKqZV9M,4436
22
- src/cli/helpers.py,sha256=3r2xRAbdVekOuViQGS-OLaxtrZG808xxk_UeX6YLogo,5442
21
+ src/cli/get.py,sha256=40SVjx5aDcYT8Pj-TRuAUCcI5qEuPfTpRa3ZlOkeC2I,5261
22
+ src/cli/helpers.py,sha256=o4iGNlxmm_cU4qtoJqrKgU4ZKm5FElXejsFSTRgaZ2U,10107
23
23
  src/cli/hierarchy.py,sha256=EUwkAMlstWqYI9dEazpmKAhDUMIM2qE3e8QTlvxYER8,1247
24
- src/cli/list_cmd.py,sha256=54iTA-8gH1mQbReBhK-DP8PYWNaphtepvVD8_71T9rA,12758
25
- src/cli/main.py,sha256=urKRmHGvGEGpnkatGm2pTYJJ0zAyI5eCdeFZTdx9k3A,5039
24
+ src/cli/list_cmd.py,sha256=Ldo0Dw-e39wG0tVcOMxP_z0UlpPTjgtxsBpoX0jpKm4,13648
25
+ src/cli/main.py,sha256=gDigKVHnSWIK7j7Up3CUeAtCaogiwxOcj2IoT1yrltE,8053
26
26
  src/cli/profile_cmd.py,sha256=jhBg_Ac63Mwbiz7tgjOZ263vaexoIyWwHnbzSmAWa_Y,3365
27
27
  src/cli/search.py,sha256=PfhWfULvglmwvp5Gm2XFA6Bu1yuiqkD-q7Muo1V7iGU,3735
28
28
  src/cli/todo.py,sha256=Y51t5vvP4LiKKDlqf4dTALIw6VAan0W4X3UEVMowGKA,3586
@@ -30,8 +30,8 @@ src/formatters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
30
30
  src/formatters/json_fmt.py,sha256=S66lMzKTlfG5ImLOO1y7TCEvbAOsg-bIBmVaYX--zLs,1091
31
31
  src/formatters/table.py,sha256=rd2mHRIDpmiGH-QQnuwJWS4L0QC6cq7v14pe00GvKa4,1374
32
32
  src/formatters/tree.py,sha256=5rC58JqZvsQLq68v0WMcyl6dI2t0dyBPNI9CicBO0gg,1311
33
- upscaler_cli-0.2.3.dev6696.dist-info/METADATA,sha256=f7_szXuOoojU1FODa1FwMAhjF-jnsMUzE71P88JB5lY,601
34
- upscaler_cli-0.2.3.dev6696.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
35
- upscaler_cli-0.2.3.dev6696.dist-info/entry_points.txt,sha256=fG1SBWk0NO-b7H3BTDlxANSf2a9mztWytQ08xeI6U8I,46
36
- upscaler_cli-0.2.3.dev6696.dist-info/top_level.txt,sha256=74rtVfumQlgAPzR5_2CgYN24MB0XARCg0t-gzk6gTrM,4
37
- upscaler_cli-0.2.3.dev6696.dist-info/RECORD,,
33
+ upscaler_cli-0.2.3.dev6697.dist-info/METADATA,sha256=oUhv-oaCXbh-M0ELnTQ1_jAzIuqZAygitVhTcpNAST0,601
34
+ upscaler_cli-0.2.3.dev6697.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
35
+ upscaler_cli-0.2.3.dev6697.dist-info/entry_points.txt,sha256=fG1SBWk0NO-b7H3BTDlxANSf2a9mztWytQ08xeI6U8I,46
36
+ upscaler_cli-0.2.3.dev6697.dist-info/top_level.txt,sha256=74rtVfumQlgAPzR5_2CgYN24MB0XARCg0t-gzk6gTrM,4
37
+ upscaler_cli-0.2.3.dev6697.dist-info/RECORD,,