unique-sdk 2026.26.0.dev8__tar.gz → 2026.26.0.dev10__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 (85) hide show
  1. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/PKG-INFO +1 -1
  2. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/pyproject.toml +1 -1
  3. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_content.py +158 -0
  4. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/cli.py +128 -15
  5. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/commands/files.py +136 -10
  6. unique_sdk-2026.26.0.dev10/unique_sdk/cli/commands/read.py +176 -0
  7. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/shell.py +188 -32
  8. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/skills/unique-cli-file-management/SKILL.md +81 -3
  9. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/utils/file_io.py +7 -0
  10. unique_sdk-2026.26.0.dev8/unique_sdk/cli/commands/read.py +0 -93
  11. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/README.md +0 -0
  12. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/__init__.py +0 -0
  13. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/_api_requestor.py +0 -0
  14. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/_api_resource.py +0 -0
  15. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/_api_version.py +0 -0
  16. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/_error.py +0 -0
  17. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/_http_client.py +0 -0
  18. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/_list_object.py +0 -0
  19. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/_object_classes.py +0 -0
  20. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/_request_options.py +0 -0
  21. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/_unique_object.py +0 -0
  22. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/_unique_ql.py +0 -0
  23. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/_unique_response.py +0 -0
  24. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/_util.py +0 -0
  25. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/_version.py +0 -0
  26. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/_webhook.py +0 -0
  27. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/__init__.py +0 -0
  28. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_acronyms.py +0 -0
  29. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_agentic_table.py +0 -0
  30. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_analytics_order.py +0 -0
  31. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_benchmarking.py +0 -0
  32. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_briefing.py +0 -0
  33. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_chat_completion.py +0 -0
  34. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_dynamic_frontend.py +0 -0
  35. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_elicitation.py +0 -0
  36. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_embedding.py +0 -0
  37. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_event.py +0 -0
  38. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_folder.py +0 -0
  39. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_group.py +0 -0
  40. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_integrated.py +0 -0
  41. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_llm_models.py +0 -0
  42. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_mcp.py +0 -0
  43. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_message.py +0 -0
  44. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_message_assessment.py +0 -0
  45. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_message_execution.py +0 -0
  46. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_message_log.py +0 -0
  47. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_message_tool.py +0 -0
  48. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_module.py +0 -0
  49. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_scheduled_task.py +0 -0
  50. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_search.py +0 -0
  51. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_search_string.py +0 -0
  52. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_short_term_memory.py +0 -0
  53. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_space.py +0 -0
  54. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_user.py +0 -0
  55. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/api_resources/_web_search.py +0 -0
  56. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/__init__.py +0 -0
  57. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/__main__.py +0 -0
  58. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/commands/__init__.py +0 -0
  59. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/commands/_citation_manifest.py +0 -0
  60. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/commands/cite_file.py +0 -0
  61. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/commands/dynamic_frontend.py +0 -0
  62. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/commands/elicitation.py +0 -0
  63. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/commands/folders.py +0 -0
  64. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/commands/mcp.py +0 -0
  65. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/commands/navigation.py +0 -0
  66. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/commands/scheduled_tasks.py +0 -0
  67. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/commands/search.py +0 -0
  68. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/commands/subagent.py +0 -0
  69. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/commands/web_search.py +0 -0
  70. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/commands/web_search_config.py +0 -0
  71. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/config.py +0 -0
  72. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/formatting.py +0 -0
  73. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/skills/unique-cli-elicitation/SKILL.md +0 -0
  74. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/skills/unique-cli-mcp/SKILL.md +0 -0
  75. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/skills/unique-cli-scheduled-tasks/SKILL.md +0 -0
  76. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/skills/unique-cli-search/SKILL.md +0 -0
  77. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/skills/unique-cli-subagent/SKILL.md +0 -0
  78. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/skills/unique-cli-web-search/SKILL.md +0 -0
  79. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/cli/state.py +0 -0
  80. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/utils/analytics_order_run.py +0 -0
  81. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/utils/benchmarking_run.py +0 -0
  82. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/utils/chat_history.py +0 -0
  83. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/utils/chat_in_space.py +0 -0
  84. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/utils/sources.py +0 -0
  85. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev10}/unique_sdk/utils/token.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: unique-sdk
3
- Version: 2026.26.0.dev8
3
+ Version: 2026.26.0.dev10
4
4
  Summary:
5
5
  Author: Martin Fadler, Konstantin Krauss, Andreas Hauri
6
6
  Author-email: Martin Fadler <martin.fadler@unique.ch>, Konstantin Krauss <konstantin@unique.ch>, Andreas Hauri <andreas@unique.ch>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "unique_sdk"
3
- version = "2026.26.0.dev8"
3
+ version = "2026.26.0.dev10"
4
4
  description = ""
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -170,6 +170,50 @@ class Content(APIResource["Content"]):
170
170
  # a SAS URL the caller PUTs the PDF bytes to. ``file_io.upload_file``
171
171
  # exposes this as ``preview_pdf_path`` for a one-call flow.
172
172
  previewPdfFileName: NotRequired[str | None]
173
+ # When true, the platform archives previous blobs for this
174
+ # content and makes them restorable through the version APIs.
175
+ versioningEnabled: NotRequired[bool | None]
176
+
177
+ class VersionsParams(RequestOptions):
178
+ contentId: str
179
+ skip: NotRequired[int | None]
180
+ take: NotRequired[int | None]
181
+
182
+ class ContentVersion(TypedDict):
183
+ id: str
184
+ contentId: str
185
+ versionNumber: int
186
+ reason: str
187
+ blobObjectKey: str
188
+ key: str
189
+ title: str | None
190
+ description: str | None
191
+ url: str | None
192
+ byteSize: int
193
+ mimeType: str
194
+ ownerType: str
195
+ ownerId: str
196
+ contentHash: str | None
197
+ archivedAt: str
198
+ archivedBy: str | None
199
+ originalCreatedAt: str
200
+ originalCreatedBy: str | None
201
+ createdBy: str | None
202
+
203
+ class PaginatedContentVersions(TypedDict):
204
+ data: list["Content.ContentVersion"]
205
+ object: str
206
+
207
+ class VersionDownloadUrlParams(RequestOptions):
208
+ contentVersionId: str
209
+
210
+ class ContentVersionDownloadUrl(TypedDict):
211
+ id: str
212
+ object: str
213
+ url: str
214
+
215
+ class RestoreVersionParams(RequestOptions):
216
+ contentVersionId: str
173
217
 
174
218
  class UpdateParams(RequestOptions):
175
219
  contentId: NotRequired[str]
@@ -467,6 +511,120 @@ class Content(APIResource["Content"]):
467
511
  ),
468
512
  )
469
513
 
514
+ @classmethod
515
+ def versions(
516
+ cls,
517
+ user_id: str,
518
+ company_id: str,
519
+ **params: Unpack["Content.VersionsParams"],
520
+ ) -> "Content.PaginatedContentVersions":
521
+ content_id = params.pop("contentId")
522
+ return cast(
523
+ Content.PaginatedContentVersions,
524
+ cls._static_request(
525
+ "get",
526
+ f"/content/{content_id}/versions",
527
+ user_id,
528
+ company_id,
529
+ params=params,
530
+ ),
531
+ )
532
+
533
+ @classmethod
534
+ async def versions_async(
535
+ cls,
536
+ user_id: str,
537
+ company_id: str,
538
+ **params: Unpack["Content.VersionsParams"],
539
+ ) -> "Content.PaginatedContentVersions":
540
+ content_id = params.pop("contentId")
541
+ return cast(
542
+ Content.PaginatedContentVersions,
543
+ await cls._static_request_async(
544
+ "get",
545
+ f"/content/{content_id}/versions",
546
+ user_id,
547
+ company_id,
548
+ params=params,
549
+ ),
550
+ )
551
+
552
+ @classmethod
553
+ def version_download_url(
554
+ cls,
555
+ user_id: str,
556
+ company_id: str,
557
+ **params: Unpack["Content.VersionDownloadUrlParams"],
558
+ ) -> "Content.ContentVersionDownloadUrl":
559
+ content_version_id = params.pop("contentVersionId")
560
+ return cast(
561
+ Content.ContentVersionDownloadUrl,
562
+ cls._static_request(
563
+ "get",
564
+ f"/content/versions/{content_version_id}/download-url",
565
+ user_id,
566
+ company_id,
567
+ params=params,
568
+ ),
569
+ )
570
+
571
+ @classmethod
572
+ async def version_download_url_async(
573
+ cls,
574
+ user_id: str,
575
+ company_id: str,
576
+ **params: Unpack["Content.VersionDownloadUrlParams"],
577
+ ) -> "Content.ContentVersionDownloadUrl":
578
+ content_version_id = params.pop("contentVersionId")
579
+ return cast(
580
+ Content.ContentVersionDownloadUrl,
581
+ await cls._static_request_async(
582
+ "get",
583
+ f"/content/versions/{content_version_id}/download-url",
584
+ user_id,
585
+ company_id,
586
+ params=params,
587
+ ),
588
+ )
589
+
590
+ @classmethod
591
+ def restore_version(
592
+ cls,
593
+ user_id: str,
594
+ company_id: str,
595
+ **params: Unpack["Content.RestoreVersionParams"],
596
+ ) -> "Content.ContentInfo":
597
+ content_version_id = params.pop("contentVersionId")
598
+ return cast(
599
+ Content.ContentInfo,
600
+ cls._static_request(
601
+ "post",
602
+ f"/content/versions/{content_version_id}/restore",
603
+ user_id,
604
+ company_id,
605
+ params=params,
606
+ ),
607
+ )
608
+
609
+ @classmethod
610
+ async def restore_version_async(
611
+ cls,
612
+ user_id: str,
613
+ company_id: str,
614
+ **params: Unpack["Content.RestoreVersionParams"],
615
+ ) -> "Content.ContentInfo":
616
+ content_version_id = params.pop("contentVersionId")
617
+ return cast(
618
+ Content.ContentInfo,
619
+ await cls._static_request_async(
620
+ "post",
621
+ f"/content/versions/{content_version_id}/restore",
622
+ user_id,
623
+ company_id,
624
+ params=params,
625
+ ),
626
+ )
627
+
470
628
  @classmethod
471
629
  def ingest_magic_table_sheets(
472
630
  cls,
@@ -20,7 +20,14 @@ from unique_sdk.cli.commands.elicitation import (
20
20
  cmd_elicit_respond,
21
21
  cmd_elicit_wait,
22
22
  )
23
- from unique_sdk.cli.commands.files import cmd_download, cmd_mv_file, cmd_rm, cmd_upload
23
+ from unique_sdk.cli.commands.files import (
24
+ cmd_download,
25
+ cmd_mv_file,
26
+ cmd_restore_version,
27
+ cmd_rm,
28
+ cmd_upload,
29
+ cmd_versions,
30
+ )
24
31
  from unique_sdk.cli.commands.folders import cmd_mkdir, cmd_mvdir, cmd_rmdir
25
32
  from unique_sdk.cli.commands.mcp import cmd_mcp
26
33
  from unique_sdk.cli.commands.navigation import cmd_cd, cmd_ls, cmd_pwd
@@ -91,6 +98,7 @@ Path formats accepted by all commands:
91
98
  \b
92
99
  File identifiers:
93
100
  report.pdf File name (matched in current directory)
101
+ /Reports/report.pdf File path (absolute or relative)
94
102
  cont_abc123 Content ID (used directly)
95
103
 
96
104
  \b
@@ -99,8 +107,10 @@ Examples:
99
107
  unique-cli ls List root folders
100
108
  unique-cli ls /Reports List a specific folder
101
109
  unique-cli search "revenue" -l 50 Search with custom limit
102
- unique-cli upload ./file.pdf Upload to current folder
110
+ unique-cli upload ./file.pdf Upload versioned to current folder
103
111
  unique-cli download cont_abc123 Download by content ID
112
+ unique-cli versions cont_abc123 List archived file versions
113
+ unique-cli restore-version cver_1 Restore a file from a version
104
114
  unique-cli elicit ask "Which?" Ask the user a question synchronously
105
115
  unique-cli subagent Legal "Review" Invoke a connected space/subagent
106
116
  unique-cli web-search search "x" Search the web via the public API
@@ -261,12 +271,13 @@ def mvdir(ctx: click.Context, old_name: str, new_name: str) -> None:
261
271
  @click.argument("destination", required=False, default=None)
262
272
  @click.pass_context
263
273
  def upload(ctx: click.Context, local_path: str, destination: str | None) -> None:
264
- """Upload a local file (works like Linux cp).
274
+ """Upload a local file with versioning enabled (works like Linux cp).
265
275
 
266
276
  \b
267
- Uploads LOCAL_PATH to the Unique platform. DESTINATION works like
268
- the target in cp -- it can be a folder path, a new filename, or
269
- a combination of both. MIME type is auto-detected.
277
+ Uploads LOCAL_PATH to the Unique platform with immutable versioning
278
+ enabled. DESTINATION works like the target in cp -- it can be a
279
+ folder path, a new filename, or a combination of both. MIME type is
280
+ auto-detected.
270
281
 
271
282
  \b
272
283
  Destination formats:
@@ -288,6 +299,48 @@ def upload(ctx: click.Context, local_path: str, destination: str | None) -> None
288
299
  click.echo(cmd_upload(LazyState.get(ctx), local_path, destination))
289
300
 
290
301
 
302
+ @main.command()
303
+ @click.argument("name_or_id")
304
+ @click.option("--skip", type=int, default=None, help="Number of versions to skip.")
305
+ @click.option("--take", type=int, default=None, help="Number of versions to return.")
306
+ @click.pass_context
307
+ def versions(
308
+ ctx: click.Context,
309
+ name_or_id: str,
310
+ skip: int | None,
311
+ take: int | None,
312
+ ) -> None:
313
+ """List archived versions for a file.
314
+
315
+ \b
316
+ NAME_OR_ID is a file path, a file name matched in the current
317
+ directory, or a content ID (cont_...) which is resolved directly.
318
+
319
+ \b
320
+ Examples:
321
+ unique-cli versions report.pdf
322
+ unique-cli versions /Reports/Q1/report.pdf
323
+ unique-cli versions cont_abc123 --take 10
324
+ """
325
+ click.echo(cmd_versions(LazyState.get(ctx), name_or_id, skip=skip, take=take))
326
+
327
+
328
+ @main.command(name="restore-version")
329
+ @click.argument("content_version_id")
330
+ @click.pass_context
331
+ def restore_version(ctx: click.Context, content_version_id: str) -> None:
332
+ """Restore a file from a content version ID.
333
+
334
+ \b
335
+ CONTENT_VERSION_ID is returned by `unique-cli versions`.
336
+
337
+ \b
338
+ Examples:
339
+ unique-cli restore-version cver_abc123
340
+ """
341
+ click.echo(cmd_restore_version(LazyState.get(ctx), content_version_id))
342
+
343
+
291
344
  @main.command()
292
345
  @click.argument("name_or_id")
293
346
  @click.argument("local_dest", required=False, default=None)
@@ -296,8 +349,8 @@ def download(ctx: click.Context, name_or_id: str, local_dest: str | None) -> Non
296
349
  """Download a file to your local machine.
297
350
 
298
351
  \b
299
- NAME_OR_ID is a file name (matched in the current directory) or
300
- a content ID (cont_...) which is resolved directly.
352
+ NAME_OR_ID is a file path, a file name matched in the current
353
+ directory, or a content ID (cont_...) which is resolved directly.
301
354
 
302
355
  \b
303
356
  LOCAL_DEST is an optional path (directory or file) to save to.
@@ -306,6 +359,7 @@ def download(ctx: click.Context, name_or_id: str, local_dest: str | None) -> Non
306
359
  \b
307
360
  Examples:
308
361
  unique-cli download annual.pdf
362
+ unique-cli download /Reports/Q1/annual.pdf
309
363
  unique-cli download annual.pdf ./downloads/
310
364
  unique-cli download cont_abc123 ~/Desktop/
311
365
  """
@@ -331,10 +385,12 @@ def cite(
331
385
  \b
332
386
  Registers [filesourceN] markers for pages you referenced in your answer.
333
387
  Does NOT read or extract the file — use your own tools for that.
388
+ NAME_OR_ID can be a file path, current-directory file name, or content ID.
334
389
 
335
390
  \b
336
391
  Examples:
337
392
  unique-cli cite report.pdf --pages 3,5,7
393
+ unique-cli cite /Reports/Q1/report.pdf --pages 3,5,7
338
394
  unique-cli cite cont_abc123 --pages 1-4
339
395
  """
340
396
  click.echo(cmd_cite_file(LazyState.get(ctx), name_or_id, pages))
@@ -342,9 +398,41 @@ def cite(
342
398
 
343
399
  @main.command(name="read")
344
400
  @click.argument("cont_id")
401
+ @click.option(
402
+ "--page",
403
+ "-p",
404
+ type=int,
405
+ default=None,
406
+ help="Read a single page (shorthand for --from-page N --to-page N).",
407
+ )
408
+ @click.option(
409
+ "--from-page",
410
+ type=int,
411
+ default=None,
412
+ help="First page to include (inclusive).",
413
+ )
414
+ @click.option(
415
+ "--to-page",
416
+ type=int,
417
+ default=None,
418
+ help="Last page to include (inclusive).",
419
+ )
420
+ @click.option(
421
+ "--max-chars",
422
+ type=int,
423
+ default=None,
424
+ help="Truncate the printed text to at most N characters.",
425
+ )
345
426
  @click.pass_context
346
- def read_cmd(ctx: click.Context, cont_id: str) -> None:
347
- """Read all indexed text chunks for a known content ID.
427
+ def read_cmd(
428
+ ctx: click.Context,
429
+ cont_id: str,
430
+ page: int | None,
431
+ from_page: int | None,
432
+ to_page: int | None,
433
+ max_chars: int | None,
434
+ ) -> None:
435
+ """Read indexed text chunks for a known content ID.
348
436
 
349
437
  \b
350
438
  CONT_ID must be a content ID (cont_...) obtained from a prior `ls` or
@@ -355,11 +443,34 @@ def read_cmd(ctx: click.Context, cont_id: str) -> None:
355
443
  Use `search` when you need to find documents by topic or keyword.
356
444
  Use `read` when you already know the content ID and want the full text.
357
445
 
446
+ \b
447
+ Restrict to a page range with --page (single page) or --from-page/--to-page.
448
+ A chunk spanning pages 2-4 is returned for any overlapping request; files
449
+ without page numbers (e.g. plain text/markdown) are returned only without a
450
+ page range.
451
+
358
452
  \b
359
453
  Examples:
360
454
  unique-cli read cont_abc123
455
+ unique-cli read cont_abc123 --page 12
456
+ unique-cli read cont_abc123 --from-page 5 --to-page 9
457
+ unique-cli read cont_abc123 --to-page 3 --max-chars 8000
361
458
  """
362
- output = cmd_read(LazyState.get(ctx), cont_id)
459
+ if page is not None and (from_page is not None or to_page is not None):
460
+ click.echo(
461
+ "read: use either --page or --from-page/--to-page, not both", err=True
462
+ )
463
+ raise SystemExit(1)
464
+ if page is not None:
465
+ from_page = page
466
+ to_page = page
467
+ output = cmd_read(
468
+ LazyState.get(ctx),
469
+ cont_id,
470
+ from_page=from_page,
471
+ to_page=to_page,
472
+ max_chars=max_chars,
473
+ )
363
474
  if _is_read_error_output(output):
364
475
  click.echo(output, err=True)
365
476
  raise SystemExit(1)
@@ -442,15 +553,16 @@ def dynamic_frontend_list(ctx: click.Context, output_json: bool) -> None:
442
553
  @click.argument("name_or_id")
443
554
  @click.pass_context
444
555
  def rm(ctx: click.Context, name_or_id: str) -> None:
445
- """Delete a file by name or content ID.
556
+ """Delete a file by path, name, or content ID.
446
557
 
447
558
  \b
448
- NAME_OR_ID is a file name (matched in the current directory) or
449
- a content ID (cont_...).
559
+ NAME_OR_ID is a file path, a file name matched in the current
560
+ directory, or a content ID (cont_...).
450
561
 
451
562
  \b
452
563
  Examples:
453
564
  unique-cli rm report.pdf
565
+ unique-cli rm /Reports/Q1/report.pdf
454
566
  unique-cli rm cont_abc123
455
567
  """
456
568
  click.echo(cmd_rm(LazyState.get(ctx), name_or_id))
@@ -465,11 +577,12 @@ def mv(ctx: click.Context, old_name: str, new_name: str) -> None:
465
577
 
466
578
  \b
467
579
  Changes the file's display title without changing its content ID
468
- or location. OLD_NAME can be a file name or content ID.
580
+ or location. OLD_NAME can be a file path, file name, or content ID.
469
581
 
470
582
  \b
471
583
  Examples:
472
584
  unique-cli mv annual.pdf annual-2025.pdf
585
+ unique-cli mv /Reports/Q1/annual.pdf annual-2025.pdf
473
586
  unique-cli mv cont_abc123 "New Title.pdf"
474
587
  """
475
588
  click.echo(cmd_mv_file(LazyState.get(ctx), old_name, new_name))
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import mimetypes
6
6
  import shutil
7
+ from collections.abc import Mapping, Sequence
7
8
  from pathlib import Path
8
9
  from typing import Any
9
10
 
@@ -13,32 +14,113 @@ from unique_sdk.cli.state import ShellState
13
14
  from unique_sdk.utils.file_io import download_content, upload_file
14
15
 
15
16
 
17
+ def _normalize_unique_file_path(cwd: str, path: str) -> str:
18
+ """Normalize a Unique file path without allowing traversal above root."""
19
+ raw_parts = (
20
+ path.split("/") if path.startswith("/") else [*cwd.split("/"), *path.split("/")]
21
+ )
22
+ parts: list[str] = []
23
+ for part in raw_parts:
24
+ if part in ("", "."):
25
+ continue
26
+ if part == "..":
27
+ if not parts:
28
+ raise ValueError(f"File path escapes root: {path}")
29
+ parts.pop()
30
+ continue
31
+ parts.append(part)
32
+ return "/" + "/".join(parts)
33
+
34
+
16
35
  def _resolve_content_id(state: ShellState, name_or_id: str) -> tuple[str, str]:
17
36
  """Resolve a file name or content ID to (content_id, display_name).
18
37
 
19
- Accepts either a content ID (cont_...) or a file name/path.
38
+ Accepts a content ID (cont_...), a file name in the current folder,
39
+ or an absolute/relative Unique file path.
20
40
  """
21
41
  if name_or_id.startswith("cont_"):
42
+ if not state.is_content_within_workspace(name_or_id):
43
+ raise ValueError("permission denied (outside workspace scope)")
22
44
  return name_or_id, name_or_id
23
45
 
46
+ lookup_name = name_or_id
24
47
  scope_id = state.scope_id
48
+ if "/" in name_or_id:
49
+ unique_path = _normalize_unique_file_path(state.cwd, name_or_id)
50
+ folder_path, lookup_name = unique_path.rsplit("/", 1)
51
+ if not lookup_name:
52
+ raise ValueError(f"File path must include a file name: {name_or_id}")
53
+ if not folder_path:
54
+ folder_path = "/"
55
+ if not state.is_folder_target_within_workspace(folder_path):
56
+ raise ValueError("permission denied (outside workspace scope)")
57
+
58
+ info = unique_sdk.Folder.get_info(
59
+ user_id=state.config.user_id,
60
+ company_id=state.config.company_id,
61
+ folderPath=folder_path,
62
+ )
63
+ scope_id = info.get("id")
64
+ if not scope_id:
65
+ raise ValueError(f"folder not found: {folder_path}")
66
+ elif not state.is_within_workspace():
67
+ raise ValueError("permission denied (outside workspace scope)")
68
+
25
69
  params: dict[str, Any] = {}
26
70
  if scope_id:
27
71
  params["parentId"] = scope_id
28
72
 
29
- result = unique_sdk.Content.get_infos(
30
- user_id=state.config.user_id,
31
- company_id=state.config.company_id,
32
- **params,
33
- )
34
- for info in result.get("contentInfos", []):
35
- title = info.get("title") or info.get("key") or ""
36
- if title == name_or_id:
37
- return info["id"], title
73
+ take = 100
74
+ skip = 0
75
+ while True:
76
+ result = unique_sdk.Content.get_infos(
77
+ user_id=state.config.user_id,
78
+ company_id=state.config.company_id,
79
+ skip=skip,
80
+ take=take,
81
+ **params,
82
+ )
83
+ content_infos = result.get("contentInfos", [])
84
+ if not content_infos:
85
+ break
86
+
87
+ for info in content_infos:
88
+ title = info.get("title") or ""
89
+ key = info.get("key") or ""
90
+ if lookup_name in {title, key}:
91
+ return info["id"], title or key
92
+
93
+ skip += len(content_infos)
38
94
 
39
95
  raise ValueError(f"File not found: {name_or_id}")
40
96
 
41
97
 
98
+ def _format_version_value(value: Any) -> str:
99
+ if value is None:
100
+ return ""
101
+ return str(value)
102
+
103
+
104
+ def _format_content_versions(versions: Sequence[Mapping[str, Any]]) -> str:
105
+ if not versions:
106
+ return "No versions found."
107
+
108
+ lines = ["VERSION VERSION_ID ARCHIVED_AT REASON TITLE"]
109
+ for version in versions:
110
+ lines.append(
111
+ " ".join(
112
+ [
113
+ _format_version_value(version.get("versionNumber")),
114
+ _format_version_value(version.get("id")),
115
+ _format_version_value(version.get("archivedAt")),
116
+ _format_version_value(version.get("reason")),
117
+ _format_version_value(version.get("title") or version.get("key")),
118
+ ]
119
+ )
120
+ )
121
+ return "\n".join(lines)
122
+
123
+
42
124
  def _resolve_upload_destination(
43
125
  state: ShellState,
44
126
  local_filename: str,
@@ -144,6 +226,7 @@ def cmd_upload(
144
226
  displayed_filename=display_name,
145
227
  mime_type=mime_type,
146
228
  scope_or_unique_path=scope_id,
229
+ versioning_enabled=True,
147
230
  )
148
231
 
149
232
  content_id = result.id if hasattr(result, "id") else "?"
@@ -158,6 +241,49 @@ def cmd_upload(
158
241
  return f"upload: {e}"
159
242
 
160
243
 
244
+ def cmd_versions(
245
+ state: ShellState,
246
+ name_or_id: str,
247
+ skip: int | None = None,
248
+ take: int | None = None,
249
+ ) -> str:
250
+ """List archived versions for a file by name or content ID."""
251
+ try:
252
+ content_id, display_name = _resolve_content_id(state, name_or_id)
253
+ params: dict[str, Any] = {"contentId": content_id}
254
+ if skip is not None:
255
+ params["skip"] = skip
256
+ if take is not None:
257
+ params["take"] = take
258
+
259
+ result = unique_sdk.Content.versions(
260
+ user_id=state.config.user_id,
261
+ company_id=state.config.company_id,
262
+ **params,
263
+ )
264
+ data = result.get("data", [])
265
+ return f"Versions for {display_name} ({content_id}):\n{_format_content_versions(data)}"
266
+ except (ValueError, unique_sdk.APIError) as e:
267
+ return f"versions: {e}"
268
+
269
+
270
+ def cmd_restore_version(state: ShellState, content_version_id: str) -> str:
271
+ """Restore a file from an archived content version ID."""
272
+ if not state.is_within_workspace():
273
+ return "restore-version: permission denied (outside workspace scope)"
274
+ try:
275
+ result = unique_sdk.Content.restore_version(
276
+ user_id=state.config.user_id,
277
+ company_id=state.config.company_id,
278
+ contentVersionId=content_version_id,
279
+ )
280
+ title = result.get("title") or result.get("key") or result.get("id", "?")
281
+ content_id = result.get("id", "?")
282
+ return f"Restored: {title} ({content_id}) from version {content_version_id}"
283
+ except (ValueError, unique_sdk.APIError) as e:
284
+ return f"restore-version: {e}"
285
+
286
+
161
287
  def cmd_download(
162
288
  state: ShellState,
163
289
  name_or_id: str,