unique-sdk 2026.44.0.dev12__tar.gz → 2026.44.0.dev14__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 (92) hide show
  1. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/PKG-INFO +1 -1
  2. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/pyproject.toml +1 -1
  3. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/cli/cli.py +44 -3
  4. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/cli/commands/files.py +19 -42
  5. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/cli/commands/navigation.py +148 -25
  6. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/cli/metadata_filter.py +14 -0
  7. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/cli/shell.py +54 -4
  8. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/cli/skills/unique-cli-file-management/SKILL.md +5 -0
  9. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/cli/skills/unique-cli-search/SKILL.md +3 -2
  10. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/README.md +0 -0
  11. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/__init__.py +0 -0
  12. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/_api_requestor.py +0 -0
  13. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/_api_resource.py +0 -0
  14. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/_api_version.py +0 -0
  15. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/_error.py +0 -0
  16. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/_http_client.py +0 -0
  17. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/_list_object.py +0 -0
  18. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/_object_classes.py +0 -0
  19. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/_request_options.py +0 -0
  20. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/_unique_object.py +0 -0
  21. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/_unique_ql.py +0 -0
  22. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/_unique_response.py +0 -0
  23. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/_util.py +0 -0
  24. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/_version.py +0 -0
  25. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/_webhook.py +0 -0
  26. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/api_resources/__init__.py +0 -0
  27. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/api_resources/_acronyms.py +0 -0
  28. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/api_resources/_agentic_table.py +0 -0
  29. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/api_resources/_analytics_order.py +0 -0
  30. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/api_resources/_benchmarking.py +0 -0
  31. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/api_resources/_briefing.py +0 -0
  32. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/api_resources/_chat_completion.py +0 -0
  33. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/api_resources/_content.py +0 -0
  34. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/api_resources/_context_memory.py +0 -0
  35. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/api_resources/_elicitation.py +0 -0
  36. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/api_resources/_embedding.py +0 -0
  37. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/api_resources/_event.py +0 -0
  38. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/api_resources/_folder.py +0 -0
  39. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/api_resources/_group.py +0 -0
  40. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/api_resources/_integrated.py +0 -0
  41. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/api_resources/_llm_models.py +0 -0
  42. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/api_resources/_mcp.py +0 -0
  43. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/api_resources/_message.py +0 -0
  44. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/api_resources/_message_assessment.py +0 -0
  45. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/api_resources/_message_execution.py +0 -0
  46. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/api_resources/_message_log.py +0 -0
  47. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/api_resources/_message_tool.py +0 -0
  48. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/api_resources/_module.py +0 -0
  49. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/api_resources/_scheduled_task.py +0 -0
  50. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/api_resources/_search.py +0 -0
  51. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/api_resources/_search_string.py +0 -0
  52. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/api_resources/_short_term_memory.py +0 -0
  53. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/api_resources/_space.py +0 -0
  54. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/api_resources/_user.py +0 -0
  55. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/api_resources/_web_search.py +0 -0
  56. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/cli/__init__.py +0 -0
  57. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/cli/__main__.py +0 -0
  58. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/cli/commands/__init__.py +0 -0
  59. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/cli/commands/_citation_manifest.py +0 -0
  60. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/cli/commands/agentic_table.py +0 -0
  61. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/cli/commands/agentic_table_write.py +0 -0
  62. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/cli/commands/browser.py +0 -0
  63. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/cli/commands/cite_file.py +0 -0
  64. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/cli/commands/elicitation.py +0 -0
  65. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/cli/commands/folders.py +0 -0
  66. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/cli/commands/mcp.py +0 -0
  67. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/cli/commands/read.py +0 -0
  68. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/cli/commands/scheduled_tasks.py +0 -0
  69. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/cli/commands/search.py +0 -0
  70. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/cli/commands/subagent.py +0 -0
  71. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/cli/commands/web_search.py +0 -0
  72. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/cli/commands/web_search_config.py +0 -0
  73. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/cli/config.py +0 -0
  74. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/cli/formatting.py +0 -0
  75. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/cli/identity.py +0 -0
  76. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/cli/paths.py +0 -0
  77. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/cli/skills/unique-cli-agentic-table/SKILL.md +0 -0
  78. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/cli/skills/unique-cli-elicitation/SKILL.md +0 -0
  79. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/cli/skills/unique-cli-mcp/SKILL.md +0 -0
  80. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/cli/skills/unique-cli-scheduled-tasks/SKILL.md +0 -0
  81. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/cli/skills/unique-cli-subagent/SKILL.md +0 -0
  82. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/cli/skills/unique-cli-uploaded-search/SKILL.md +0 -0
  83. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/cli/skills/unique-cli-web-search/SKILL.md +0 -0
  84. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/cli/state.py +0 -0
  85. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/cli/workspace.py +0 -0
  86. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/utils/analytics_order_run.py +0 -0
  87. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/utils/benchmarking_run.py +0 -0
  88. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/utils/chat_history.py +0 -0
  89. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/utils/chat_in_space.py +0 -0
  90. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/utils/file_io.py +0 -0
  91. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/unique_sdk/utils/sources.py +0 -0
  92. {unique_sdk-2026.44.0.dev12 → unique_sdk-2026.44.0.dev14}/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.44.0.dev12
3
+ Version: 2026.44.0.dev14
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.44.0.dev12"
3
+ version = "2026.44.0.dev14"
4
4
  description = ""
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -69,7 +69,13 @@ from unique_sdk.cli.commands.mcp import cmd_mcp
69
69
  from unique_sdk.cli.commands.mcp import (
70
70
  is_error_output as _is_mcp_error_output,
71
71
  )
72
- from unique_sdk.cli.commands.navigation import cmd_cd, cmd_ls, cmd_pwd
72
+ from unique_sdk.cli.commands.navigation import (
73
+ LS_DEFAULT_LIMIT,
74
+ LS_MAX_LIMIT,
75
+ cmd_cd,
76
+ cmd_ls,
77
+ cmd_pwd,
78
+ )
73
79
  from unique_sdk.cli.commands.navigation import (
74
80
  is_ls_error_output as _is_ls_error_output,
75
81
  )
@@ -309,13 +315,44 @@ def cd(ctx: click.Context, target: str) -> None:
309
315
 
310
316
  @main.command()
311
317
  @click.argument("target", required=False, default=None)
318
+ @click.option(
319
+ "--limit",
320
+ "-l",
321
+ type=int,
322
+ default=LS_DEFAULT_LIMIT,
323
+ show_default=True,
324
+ help=f"Max folders and max files to show (1-{LS_MAX_LIMIT}).",
325
+ )
326
+ @click.option(
327
+ "--skip",
328
+ type=int,
329
+ default=0,
330
+ show_default=True,
331
+ help="Skip this many folders and files first (for paging).",
332
+ )
333
+ @click.option(
334
+ "--all",
335
+ "-a",
336
+ "all_items",
337
+ is_flag=True,
338
+ help=f"Show up to {LS_MAX_LIMIT} folders and {LS_MAX_LIMIT} files.",
339
+ )
312
340
  @click.pass_context
313
- def ls(ctx: click.Context, target: str | None) -> None:
341
+ def ls(
342
+ ctx: click.Context,
343
+ target: str | None,
344
+ limit: int,
345
+ skip: int,
346
+ all_items: bool,
347
+ ) -> None:
314
348
  """List folders and files in a directory.
315
349
 
316
350
  \b
317
351
  Without TARGET, lists the current directory. With TARGET, lists
318
352
  the specified folder (by name, absolute path, or scope ID).
353
+ Shows 50 folders and 50 files by default. When a folder holds more,
354
+ the output says "showing ... of N" and prints the command for the
355
+ rest: use --skip to page, --limit to show more, or --all.
319
356
 
320
357
  \b
321
358
  Output columns: TYPE NAME ID SIZE UPDATED
@@ -327,8 +364,12 @@ def ls(ctx: click.Context, target: str | None) -> None:
327
364
  unique-cli ls List root folders
328
365
  unique-cli ls /Reports List a specific path
329
366
  unique-cli ls scope_abc List by scope ID
367
+ unique-cli ls /Reports --skip 50 Next page of a large folder
368
+ unique-cli ls /Reports --all Up to 1000 folders and files
330
369
  """
331
- output = cmd_ls(LazyState.get(ctx), target)
370
+ output = cmd_ls(
371
+ LazyState.get(ctx), target, skip=skip, limit=limit, all_items=all_items
372
+ )
332
373
  emit(output, is_error=_is_ls_error_output)
333
374
 
334
375
 
@@ -11,6 +11,7 @@ from typing import Any
11
11
 
12
12
  import unique_sdk
13
13
  from unique_sdk.cli.formatting import format_content_info
14
+ from unique_sdk.cli.metadata_filter import direct_children_filter
14
15
  from unique_sdk.cli.paths import canonical_folder_path, same_folder_path
15
16
  from unique_sdk.cli.state import ShellState
16
17
  from unique_sdk.utils.file_io import download_content, upload_file
@@ -40,9 +41,6 @@ _SUPPORTED_UPLOAD_MIME_TYPES = {
40
41
 
41
42
  # Bounds so one name resolve can never page through the whole knowledge base.
42
43
  _MAX_RESOLVE_MATCH_CANDIDATES = 100
43
- _MAX_RESOLVE_INFOS_PAGES = 10
44
- _RESOLVE_INFOS_PAGE_SIZE = 100
45
- _MAX_RESOLVE_INFOS_ENTRIES = _MAX_RESOLVE_INFOS_PAGES * _RESOLVE_INFOS_PAGE_SIZE
46
44
 
47
45
 
48
46
  def _normalize_unique_file_path(cwd: str, path: str) -> str:
@@ -178,19 +176,10 @@ def _resolve_content_id_in_folder(
178
176
  ),
179
177
  lookup_name=lookup_name,
180
178
  )
181
- capped = False
182
179
  if match is None:
183
- hits, capped = _hits_by_search(
184
- state=state, lookup_name=lookup_name, scope_id=scope_id
185
- )
180
+ hits = _hits_by_search(state=state, lookup_name=lookup_name, scope_id=scope_id)
186
181
  match = _pick_match(hits=hits, lookup_name=lookup_name)
187
182
  if match is None:
188
- if capped:
189
- raise ValueError(
190
- f"File not found in the first {_MAX_RESOLVE_INFOS_ENTRIES} "
191
- f"entries of this folder: {name_or_id}. Use 'unique-cli "
192
- "search' or pass a content id (cont_...)."
193
- )
194
183
  raise ValueError(f"File not found: {name_or_id}")
195
184
 
196
185
  resolved_id, display_name = match
@@ -234,8 +223,7 @@ def _hits_by_file_path(
234
223
 
235
224
  def _hits_by_search(
236
225
  *, state: ShellState, lookup_name: str, scope_id: str
237
- ) -> tuple[Sequence[Any], bool]:
238
- """Return the hits, and whether the get_infos fallback hit its page cap."""
226
+ ) -> Sequence[Any]:
239
227
  try:
240
228
  hits = unique_sdk.Content.search(
241
229
  user_id=state.config.user_id,
@@ -259,7 +247,7 @@ def _hits_by_search(
259
247
  lookup_name=lookup_name,
260
248
  scope_id=scope_id,
261
249
  )
262
- return hits, False
250
+ return hits
263
251
 
264
252
 
265
253
  def _pick_match(*, hits: Sequence[Any], lookup_name: str) -> tuple[str, str] | None:
@@ -290,32 +278,21 @@ def _resolve_content_id_via_infos_fallback(
290
278
  state: ShellState,
291
279
  lookup_name: str,
292
280
  scope_id: str,
293
- ) -> tuple[list[Mapping[str, Any]], bool]:
294
- """Folder-scoped get_infos fallback with a hard page cap.
295
-
296
- Returns the match (if any), and whether the cap stopped the scan early.
297
- """
298
- skip = 0
299
- for _ in range(_MAX_RESOLVE_INFOS_PAGES):
300
- result = unique_sdk.Content.get_infos(
301
- user_id=state.config.user_id,
302
- company_id=state.config.company_id,
303
- parentId=scope_id,
304
- skip=skip,
305
- take=_RESOLVE_INFOS_PAGE_SIZE,
306
- )
307
- content_infos = result.get("contentInfos", [])
308
- if not content_infos:
309
- return [], False
310
- for info in content_infos:
311
- title = info.get("title") or ""
312
- key = info.get("key") or ""
313
- if lookup_name in {title, key}:
314
- return [info], False
315
- if len(content_infos) < _RESOLVE_INFOS_PAGE_SIZE:
316
- return [], False
317
- skip += len(content_infos)
318
- return [], True
281
+ ) -> list[Mapping[str, Any]]:
282
+ """Folder-scoped get_infos fallback, filtered by name so one page suffices."""
283
+ name_filter = {
284
+ "or": [
285
+ {"path": ["title"], "operator": "equals", "value": lookup_name},
286
+ {"path": ["key"], "operator": "equals", "value": lookup_name},
287
+ ]
288
+ }
289
+ result = unique_sdk.Content.get_infos(
290
+ user_id=state.config.user_id,
291
+ company_id=state.config.company_id,
292
+ metadataFilter={"and": [direct_children_filter(scope_id), name_filter]},
293
+ take=_MAX_RESOLVE_MATCH_CANDIDATES,
294
+ )
295
+ return list(result.get("contentInfos", []))
319
296
 
320
297
 
321
298
  def _detect_upload_mime_type(path: Path) -> str:
@@ -2,14 +2,24 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import shlex
6
+ from collections.abc import Callable, Mapping
5
7
  from typing import Any
6
8
 
7
9
  import unique_sdk
8
10
  from unique_sdk.cli.formatting import format_ls
9
- from unique_sdk.cli.metadata_filter import _collect_filter_targets
11
+ from unique_sdk.cli.metadata_filter import (
12
+ _collect_filter_targets,
13
+ direct_children_filter,
14
+ )
10
15
  from unique_sdk.cli.paths import canonical_folder_path, same_folder_path
11
16
  from unique_sdk.cli.state import ShellState
12
17
 
18
+ # Server take is capped at 100 and defaults to 50; ls pages to reach --limit rows.
19
+ LS_PAGE_SIZE = 100
20
+ LS_DEFAULT_LIMIT = 50
21
+ LS_MAX_LIMIT = 1000
22
+
13
23
 
14
24
  def cmd_pwd(state: ShellState) -> str:
15
25
  return state.cwd
@@ -84,8 +94,90 @@ def is_ls_error_output(output: str) -> bool:
84
94
  return output.startswith("ls:")
85
95
 
86
96
 
87
- def cmd_ls(state: ShellState, target: str | None = None) -> str:
88
- """List folders and files at the given (or current) path."""
97
+ def _fetch_window(
98
+ fetch_page: Callable[[int, int], Mapping[str, Any]],
99
+ rows_key: str,
100
+ skip: int,
101
+ limit: int,
102
+ ) -> tuple[list[Any], int]:
103
+ """Return up to *limit* rows starting at *skip*, and the API total."""
104
+ rows: list[Any] = []
105
+ total: int | None = None
106
+ offset = skip
107
+ while len(rows) < limit:
108
+ take = min(LS_PAGE_SIZE, limit - len(rows))
109
+ result = fetch_page(offset, take)
110
+ page = result.get(rows_key, [])
111
+ total = result.get("totalCount", total)
112
+ rows.extend(page)
113
+ offset += len(page)
114
+ if len(page) < take or (total is not None and offset >= total):
115
+ break
116
+ return rows, total if total is not None else skip + len(rows)
117
+
118
+
119
+ def _window_note(
120
+ label: str,
121
+ skip: int,
122
+ limit: int,
123
+ shown: dict[str, int],
124
+ totals: dict[str, int],
125
+ hidden_files: int = 0,
126
+ ) -> str:
127
+ """Say which rows this call read and how to list the rest; empty when complete.
128
+
129
+ ``shown`` counts rows read from the server. ``hidden_files`` of them were
130
+ dropped by the task scope and are not printed, so the wording says "read".
131
+ """
132
+ parts: list[str] = []
133
+ for kind in ("folders", "files"):
134
+ total = totals[kind]
135
+ count = shown[kind]
136
+ if total == 0 or (skip == 0 and count >= total):
137
+ continue
138
+ if count == 0:
139
+ parts.append(f"no {kind} after {skip} (of {total})")
140
+ else:
141
+ parts.append(f"{kind} {skip + 1}-{skip + count} of {total}")
142
+ if not parts:
143
+ return ""
144
+ verb = "read" if hidden_files else "showing"
145
+ note = f"\n{verb} " + ", ".join(parts)
146
+ if hidden_files:
147
+ note += f" ({hidden_files} file(s) hidden by task scope)"
148
+ if any(skip + shown[kind] < totals[kind] for kind in shown):
149
+ quoted = shlex.quote(label)
150
+ limit_arg = "" if limit == LS_DEFAULT_LIMIT else f" --limit {limit}"
151
+ note += f"\nmore: unique-cli ls {quoted} --skip {skip + limit}{limit_arg}"
152
+ # --all restarts at row 0, so it only helps when it would show more
153
+ # than this window already does; at the cap it would repeat rows.
154
+ if skip == 0 and limit < LS_MAX_LIMIT:
155
+ note += f" (or: unique-cli ls {quoted} --all)"
156
+ return note
157
+
158
+
159
+ def cmd_ls(
160
+ state: ShellState,
161
+ target: str | None = None,
162
+ *,
163
+ skip: int = 0,
164
+ limit: int = LS_DEFAULT_LIMIT,
165
+ all_items: bool = False,
166
+ ) -> str:
167
+ """List folders and files at the given (or current) path.
168
+
169
+ Shows up to *limit* folders and *limit* files starting at *skip*
170
+ (``all_items`` raises *limit* to ``LS_MAX_LIMIT``). When more exist, the
171
+ output says which rows are shown and ends with the ``ls`` call for the rest.
172
+ """
173
+ if all_items and limit != LS_DEFAULT_LIMIT:
174
+ return "ls: --all and --limit cannot be combined"
175
+ if all_items:
176
+ limit = LS_MAX_LIMIT
177
+ if skip < 0:
178
+ return "ls: --skip must be 0 or more"
179
+ if not 1 <= limit <= LS_MAX_LIMIT:
180
+ return f"ls: --limit must be between 1 and {LS_MAX_LIMIT}"
89
181
  try:
90
182
  if target is not None:
91
183
  resolved_path, scope_id = state.resolve_path(target)
@@ -105,6 +197,16 @@ def cmd_ls(state: ShellState, target: str | None = None) -> str:
105
197
  f"({state.scope_denial_hint()})."
106
198
  )
107
199
 
200
+ # The scoped root lists a fixed set of folders; there is nothing to page.
201
+ if (
202
+ scope_id is None
203
+ and skip
204
+ and (
205
+ state.workspace_metadata_filter is not None or state.workspace_scope_ids
206
+ )
207
+ ):
208
+ return "ls: paging is not supported at the scoped root"
209
+
108
210
  # At root with a per-message KB scope (e.g. an Agentic Table column's
109
211
  # scope_rules), show only the in-scope folders and explicitly-scoped
110
212
  # documents so the agent explores within the boundary rather than the
@@ -188,29 +290,39 @@ def cmd_ls(state: ShellState, target: str | None = None) -> str:
188
290
  return path_error
189
291
 
190
292
  folder_params: dict[str, Any] = {}
191
- content_params: dict[str, Any] = {}
192
293
  if scope_id:
193
294
  folder_params["parentId"] = scope_id
194
- content_params["parentId"] = scope_id
195
295
 
196
- folder_result = unique_sdk.Folder.get_infos(
197
- user_id=state.config.user_id,
198
- company_id=state.config.company_id,
199
- **folder_params,
296
+ folders, total_folders = _fetch_window(
297
+ lambda offset, take: unique_sdk.Folder.get_infos(
298
+ user_id=state.config.user_id,
299
+ company_id=state.config.company_id,
300
+ skip=offset,
301
+ take=take,
302
+ **folder_params,
303
+ ),
304
+ "folderInfos",
305
+ skip,
306
+ limit,
200
307
  )
201
- folders = folder_result.get("folderInfos", [])
202
-
203
- content_result = unique_sdk.Content.get_infos(
204
- user_id=state.config.user_id,
205
- company_id=state.config.company_id,
206
- **content_params,
207
- )
208
- files = content_result.get("contentInfos", [])
209
-
210
- total_folders = folder_result.get("totalCount", len(folders))
211
- total_files = content_result.get("totalCount", len(files))
308
+ # Every file lives in a folder, so the root has no files of its own.
309
+ files: list[Any] = []
310
+ total_files = 0
311
+ if scope_id:
312
+ files, total_files = _fetch_window(
313
+ lambda offset, take: unique_sdk.Content.get_infos(
314
+ user_id=state.config.user_id,
315
+ company_id=state.config.company_id,
316
+ metadataFilter=direct_children_filter(scope_id),
317
+ skip=offset,
318
+ take=take,
319
+ ),
320
+ "contentInfos",
321
+ skip,
322
+ limit,
323
+ )
212
324
 
213
- if scope_id and not folders and not files:
325
+ if scope_id and not folders and not files and skip == 0:
214
326
  label = target if target is not None else resolved_path
215
327
  counts_error = _counts_without_rows_error(
216
328
  label,
@@ -225,25 +337,36 @@ def cmd_ls(state: ShellState, target: str | None = None) -> str:
225
337
  # allowlist): read/cite would deny them, so ls must hide them too.
226
338
  # Folder-only filters keep every file (each passes the folderIdPath
227
339
  # leaf); the verdict is cached per content id. See UN-21780.
340
+ # The paging note counts rows read from the server, before the scope filter.
341
+ scanned_files = len(files)
228
342
  if state.workspace_metadata_filter is not None:
229
343
  files = [
230
344
  f for f in files if state.is_content_within_workspace(f.get("id", ""))
231
345
  ]
346
+ note = _window_note(
347
+ target if target is not None else resolved_path,
348
+ skip,
349
+ limit,
350
+ {"folders": len(folders), "files": scanned_files},
351
+ {"folders": total_folders, "files": total_files},
352
+ hidden_files=scanned_files - len(files),
353
+ )
354
+
355
+ output = format_ls(folders, files)
356
+ if state.workspace_metadata_filter is not None:
232
357
  # ``files`` is only the current API page, so the in-scope count is
233
358
  # per-page, not the folder total. Report it as the shown-in-scope
234
359
  # count and keep the API ``totalCount`` as the folder's real size,
235
360
  # rather than overwriting total_files with the page length (which
236
361
  # would silently undercount paginated folders). See UN-21780.
237
- output = format_ls(folders, files)
238
362
  summary = (
239
363
  f"\n{total_folders} folder(s), {len(files)} file(s) in task scope "
240
364
  f"(of {total_files} in folder)"
241
365
  )
242
- return output + summary
366
+ return output + summary + note
243
367
 
244
- output = format_ls(folders, files)
245
368
  summary = f"\n{total_folders} folder(s), {total_files} file(s)"
246
- return output + summary
369
+ return output + summary + note
247
370
 
248
371
  except (ValueError, unique_sdk.UniqueError) as e:
249
372
  return f"ls: {e}"
@@ -38,6 +38,20 @@ ScopePathResolver = Callable[[str], "str | None"]
38
38
  ContentOwnerPathResolver = Callable[[str], "str | None"]
39
39
 
40
40
 
41
+ def direct_children_filter(scope_id: str) -> dict[str, Any]:
42
+ """UniqueQL for files directly in ``scope_id``, not in its subfolders."""
43
+ return {
44
+ "and": [
45
+ {"path": ["folderIdPath"], "operator": "contains", "value": scope_id},
46
+ {
47
+ "path": ["folderIdPath"],
48
+ "operator": "notContains",
49
+ "value": f"{scope_id}/",
50
+ },
51
+ ]
52
+ }
53
+
54
+
41
55
  def _extract_target_scope_id(value: Any) -> str | None:
42
56
  """Deepest folder scope id referenced by a ``folderIdPath`` filter value.
43
57
 
@@ -27,7 +27,12 @@ from unique_sdk.cli.commands.files import (
27
27
  )
28
28
  from unique_sdk.cli.commands.folders import cmd_mkdir, cmd_mvdir, cmd_rmdir
29
29
  from unique_sdk.cli.commands.mcp import cmd_mcp
30
- from unique_sdk.cli.commands.navigation import cmd_cd, cmd_ls, cmd_pwd
30
+ from unique_sdk.cli.commands.navigation import (
31
+ LS_DEFAULT_LIMIT,
32
+ cmd_cd,
33
+ cmd_ls,
34
+ cmd_pwd,
35
+ )
31
36
  from unique_sdk.cli.commands.scheduled_tasks import (
32
37
  cmd_schedule_create,
33
38
  cmd_schedule_delete,
@@ -217,10 +222,12 @@ class UniqueShell(cmd.Cmd):
217
222
  def do_ls(self, arg: str) -> None:
218
223
  """List folders and files in a directory.
219
224
 
220
- Usage: ls [target]
225
+ Usage: ls [target] [--limit N] [--skip N] [--all]
221
226
 
222
227
  Without arguments, lists the current directory. With an argument,
223
228
  lists the specified folder (by name, path, or scope ID).
229
+ Shows 50 folders and 50 files by default; when a folder holds more,
230
+ the output says so and prints the command for the rest.
224
231
 
225
232
  Output columns: TYPE NAME ID SIZE UPDATED
226
233
 
@@ -233,8 +240,51 @@ class UniqueShell(cmd.Cmd):
233
240
  /> ls /Reports/Q1
234
241
  /> ls scope_abc123
235
242
  """
236
- target = arg.strip() or None
237
- self._print(cmd_ls(self.state, target))
243
+ try:
244
+ parts = shlex.split(arg)
245
+ except ValueError as e:
246
+ self._print(f"ls: {e}")
247
+ return
248
+ target: str | None = None
249
+ skip = 0
250
+ limit = LS_DEFAULT_LIMIT
251
+ all_items = False
252
+ # Accept --limit=20 / --skip=50 like the one-shot CLI does.
253
+ expanded: list[str] = []
254
+ for part in parts:
255
+ name, sep, value = part.partition("=")
256
+ if sep and name in ("--limit", "--skip"):
257
+ expanded.extend([name, value])
258
+ else:
259
+ expanded.append(part)
260
+ it = iter(expanded)
261
+ for part in it:
262
+ if part in ("--all", "-a"):
263
+ all_items = True
264
+ elif part in ("--limit", "-l", "--skip"):
265
+ value = next(it, None)
266
+ try:
267
+ number = int(value) if value is not None else None
268
+ except ValueError:
269
+ number = None
270
+ if number is None:
271
+ self._print(f"ls: {part} needs a whole number")
272
+ return
273
+ if part == "--skip":
274
+ skip = number
275
+ else:
276
+ limit = number
277
+ elif part.startswith("-") and len(part) > 1:
278
+ self._print(f"ls: unknown option {part}")
279
+ return
280
+ elif target is None:
281
+ target = part
282
+ else:
283
+ self._print("ls: expected at most one target")
284
+ return
285
+ self._print(
286
+ cmd_ls(self.state, target, skip=skip, limit=limit, all_items=all_items)
287
+ )
238
288
 
239
289
  # -- Folder operations --
240
290
 
@@ -42,6 +42,11 @@ unique-cli ls /Reports/Q1
42
42
  # List by scope ID
43
43
  unique-cli ls scope_abc123
44
44
 
45
+ # Large folder: ls shows 50 folders and 50 files by default. When it prints
46
+ # "showing files 1-50 of N", run the "more:" command it gives you
47
+ unique-cli ls /Reports/Q1 --skip 50 # next page
48
+ unique-cli ls /Reports/Q1 --all # up to 1000 folders and 1000 files
49
+
45
50
  # Create a folder
46
51
  unique-cli mkdir Q2
47
52
 
@@ -12,7 +12,8 @@ description: >-
12
12
  (.xlsx/.xls), CSV (.csv), and image files are NOT full-text indexed,
13
13
  so they will not appear in search results. To locate these file types,
14
14
  use the unique-cli-file-management skill instead (browse folders with
15
- `unique-cli ls` to find them by name).
15
+ `unique-cli ls` to find them by name; `ls` shows 50 files at a time, so
16
+ follow its "more:" line or use `--all` for larger folders).
16
17
  ---
17
18
 
18
19
  # Unique CLI -- Knowledge Base Search & Document Read
@@ -32,7 +33,7 @@ Use `read` after a `ls` or `search` surfaces a content ID and you need to go dee
32
33
 
33
34
  Search the Unique knowledge base using combined vector + full-text search via the `unique-cli search` command. Every invocation wraps each result in a `<sourceN>...</sourceN>` block and records a per-turn citation manifest at `.unique/kb-search-refs.jsonl`, so any fact you cite as `[sourceN]` is rendered with a footnote and a clickable reference chip in the chat UI.
34
35
 
35
- > **Limitation:** Excel (`.xlsx`/`.xls`), CSV (`.csv`), and image files are **not** full-text indexed. They will not appear in search results. To find these files, use the `unique-cli-file-management` skill and browse folders with `unique-cli ls` to locate them by name.
36
+ > **Limitation:** Excel (`.xlsx`/`.xls`), CSV (`.csv`), and image files are **not** full-text indexed. They will not appear in search results. To find these files, use the `unique-cli-file-management` skill and browse folders with `unique-cli ls` to locate them by name (`ls` shows 50 files at a time; follow its "more:" line or use `--all` for larger folders).
36
37
 
37
38
 
38
39
  ## Basic Search