unique-sdk 2026.44.0.dev3__tar.gz → 2026.44.0.dev5__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.dev3 → unique_sdk-2026.44.0.dev5}/PKG-INFO +1 -1
  2. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/pyproject.toml +1 -1
  3. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/cli/cli.py +41 -18
  4. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/cli/commands/files.py +29 -2
  5. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/cli/commands/folders.py +64 -5
  6. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/cli/commands/navigation.py +90 -6
  7. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/cli/commands/web_search.py +174 -20
  8. unique_sdk-2026.44.0.dev5/unique_sdk/cli/paths.py +42 -0
  9. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/cli/shell.py +7 -3
  10. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/cli/skills/unique-cli-web-search/SKILL.md +52 -2
  11. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/cli/state.py +16 -5
  12. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/README.md +0 -0
  13. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/__init__.py +0 -0
  14. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/_api_requestor.py +0 -0
  15. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/_api_resource.py +0 -0
  16. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/_api_version.py +0 -0
  17. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/_error.py +0 -0
  18. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/_http_client.py +0 -0
  19. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/_list_object.py +0 -0
  20. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/_object_classes.py +0 -0
  21. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/_request_options.py +0 -0
  22. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/_unique_object.py +0 -0
  23. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/_unique_ql.py +0 -0
  24. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/_unique_response.py +0 -0
  25. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/_util.py +0 -0
  26. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/_version.py +0 -0
  27. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/_webhook.py +0 -0
  28. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/api_resources/__init__.py +0 -0
  29. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/api_resources/_acronyms.py +0 -0
  30. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/api_resources/_agentic_table.py +0 -0
  31. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/api_resources/_analytics_order.py +0 -0
  32. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/api_resources/_benchmarking.py +0 -0
  33. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/api_resources/_briefing.py +0 -0
  34. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/api_resources/_chat_completion.py +0 -0
  35. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/api_resources/_content.py +0 -0
  36. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/api_resources/_context_memory.py +0 -0
  37. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/api_resources/_elicitation.py +0 -0
  38. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/api_resources/_embedding.py +0 -0
  39. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/api_resources/_event.py +0 -0
  40. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/api_resources/_folder.py +0 -0
  41. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/api_resources/_group.py +0 -0
  42. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/api_resources/_integrated.py +0 -0
  43. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/api_resources/_llm_models.py +0 -0
  44. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/api_resources/_mcp.py +0 -0
  45. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/api_resources/_message.py +0 -0
  46. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/api_resources/_message_assessment.py +0 -0
  47. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/api_resources/_message_execution.py +0 -0
  48. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/api_resources/_message_log.py +0 -0
  49. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/api_resources/_message_tool.py +0 -0
  50. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/api_resources/_module.py +0 -0
  51. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/api_resources/_scheduled_task.py +0 -0
  52. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/api_resources/_search.py +0 -0
  53. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/api_resources/_search_string.py +0 -0
  54. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/api_resources/_short_term_memory.py +0 -0
  55. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/api_resources/_space.py +0 -0
  56. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/api_resources/_user.py +0 -0
  57. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/api_resources/_web_search.py +0 -0
  58. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/cli/__init__.py +0 -0
  59. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/cli/__main__.py +0 -0
  60. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/cli/commands/__init__.py +0 -0
  61. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/cli/commands/_citation_manifest.py +0 -0
  62. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/cli/commands/agentic_table.py +0 -0
  63. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/cli/commands/agentic_table_write.py +0 -0
  64. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/cli/commands/browser.py +0 -0
  65. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/cli/commands/cite_file.py +0 -0
  66. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/cli/commands/elicitation.py +0 -0
  67. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/cli/commands/mcp.py +0 -0
  68. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/cli/commands/read.py +0 -0
  69. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/cli/commands/scheduled_tasks.py +0 -0
  70. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/cli/commands/search.py +0 -0
  71. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/cli/commands/subagent.py +0 -0
  72. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/cli/commands/web_search_config.py +0 -0
  73. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/cli/config.py +0 -0
  74. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/cli/formatting.py +0 -0
  75. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/cli/identity.py +0 -0
  76. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/cli/metadata_filter.py +0 -0
  77. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/cli/skills/unique-cli-agentic-table/SKILL.md +0 -0
  78. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/cli/skills/unique-cli-elicitation/SKILL.md +0 -0
  79. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/cli/skills/unique-cli-file-management/SKILL.md +0 -0
  80. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/cli/skills/unique-cli-mcp/SKILL.md +0 -0
  81. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/cli/skills/unique-cli-scheduled-tasks/SKILL.md +0 -0
  82. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/cli/skills/unique-cli-search/SKILL.md +0 -0
  83. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/cli/skills/unique-cli-subagent/SKILL.md +0 -0
  84. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/cli/skills/unique-cli-uploaded-search/SKILL.md +0 -0
  85. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/cli/workspace.py +0 -0
  86. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/utils/analytics_order_run.py +0 -0
  87. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/utils/benchmarking_run.py +0 -0
  88. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/utils/chat_history.py +0 -0
  89. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/utils/chat_in_space.py +0 -0
  90. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/utils/file_io.py +0 -0
  91. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/unique_sdk/utils/sources.py +0 -0
  92. {unique_sdk-2026.44.0.dev3 → unique_sdk-2026.44.0.dev5}/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.dev3
3
+ Version: 2026.44.0.dev5
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.dev3"
3
+ version = "2026.44.0.dev5"
4
4
  description = ""
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -58,6 +58,9 @@ from unique_sdk.cli.commands.files import (
58
58
  from unique_sdk.cli.commands.files import (
59
59
  is_permission_denied_output as _is_permission_denied_output,
60
60
  )
61
+ from unique_sdk.cli.commands.files import (
62
+ is_upload_error_output as _is_upload_error_output,
63
+ )
61
64
  from unique_sdk.cli.commands.folders import cmd_mkdir, cmd_mvdir, cmd_rmdir
62
65
  from unique_sdk.cli.commands.folders import (
63
66
  is_mkdir_error_output as _is_mkdir_error_output,
@@ -67,6 +70,9 @@ from unique_sdk.cli.commands.mcp import (
67
70
  is_error_output as _is_mcp_error_output,
68
71
  )
69
72
  from unique_sdk.cli.commands.navigation import cmd_cd, cmd_ls, cmd_pwd
73
+ from unique_sdk.cli.commands.navigation import (
74
+ is_ls_error_output as _is_ls_error_output,
75
+ )
70
76
  from unique_sdk.cli.commands.read import cmd_read
71
77
  from unique_sdk.cli.commands.read import (
72
78
  is_error_output as _is_read_error_output,
@@ -99,6 +105,7 @@ from unique_sdk.cli.commands.subagent import (
99
105
  from unique_sdk.cli.commands.web_search import (
100
106
  cmd_web_crawl,
101
107
  cmd_web_search,
108
+ cmd_web_search_many,
102
109
  )
103
110
  from unique_sdk.cli.commands.web_search import (
104
111
  is_error_output as _is_web_search_error_output,
@@ -322,23 +329,25 @@ def ls(ctx: click.Context, target: str | None) -> None:
322
329
  unique-cli ls scope_abc List by scope ID
323
330
  """
324
331
  output = cmd_ls(LazyState.get(ctx), target)
325
- emit(output)
332
+ emit(output, is_error=_is_ls_error_output)
326
333
 
327
334
 
328
335
  @main.command()
329
336
  @click.argument("name")
330
337
  @click.pass_context
331
338
  def mkdir(ctx: click.Context, name: str) -> None:
332
- """Create a new folder under the current directory.
339
+ """Create a folder by relative name or absolute path.
333
340
 
334
341
  \b
335
- NAME is the folder name to create. Nested paths are supported
336
- (e.g., "A/B/C" creates the full hierarchy).
342
+ A relative NAME is created under the current directory. An absolute
343
+ path (starting with /) is created from the root, including under
344
+ /home-<userId>. Nested paths create the full hierarchy.
337
345
 
338
346
  \b
339
347
  Examples:
340
348
  unique-cli mkdir Q2
341
349
  unique-cli mkdir "2025/Q1"
350
+ unique-cli mkdir /home-user_1/second-brain/clients/X
342
351
  """
343
352
  output = cmd_mkdir(LazyState.get(ctx), name)
344
353
  emit(output, is_error=_is_mkdir_error_output)
@@ -421,7 +430,7 @@ def upload(ctx: click.Context, local_path: str, destination: str | None) -> None
421
430
  unique-cli upload ./report.pdf /Archive/2025/
422
431
  """
423
432
  output = cmd_upload(LazyState.get(ctx), local_path, destination)
424
- emit(output)
433
+ emit(output, is_error=_is_upload_error_output)
425
434
 
426
435
 
427
436
  @main.command()
@@ -2119,6 +2128,7 @@ Examples:
2119
2128
  unique-cli web-search search "quarterly earnings 2026"
2120
2129
  unique-cli web-search search "AI regulation" -n 10
2121
2130
  unique-cli web-search search "python tutorial" --json
2131
+ unique-cli web-search search "git history" "redis history" "docker history"
2122
2132
  unique-cli web-search search "sustainability" --include-content --json
2123
2133
  unique-cli web-search search "tax reform" \\
2124
2134
  --engine-config '{"searchEngineName":"Google","fetchSize":3}'
@@ -2127,7 +2137,7 @@ Examples:
2127
2137
 
2128
2138
 
2129
2139
  @web_search_group.command("search", help=_SEARCH_HELP)
2130
- @click.argument("query")
2140
+ @click.argument("queries", nargs=-1, required=True)
2131
2141
  @click.option(
2132
2142
  "--fetch-size",
2133
2143
  "-n",
@@ -2183,7 +2193,7 @@ Examples:
2183
2193
  @click.pass_context
2184
2194
  def web_search_search_cmd(
2185
2195
  ctx: click.Context,
2186
- query: str,
2196
+ queries: tuple[str, ...],
2187
2197
  fetch_size: int | None,
2188
2198
  include_content: bool,
2189
2199
  engine_config_raw: str | None,
@@ -2194,17 +2204,30 @@ def web_search_search_cmd(
2194
2204
  ) -> None:
2195
2205
  """Search the web via the Unique public API."""
2196
2206
  resolved_config = _resolve_web_search_config_path(ctx, config_path)
2197
- output = cmd_web_search(
2198
- LazyState.get(ctx),
2199
- query,
2200
- fetch_size=fetch_size,
2201
- include_content=include_content,
2202
- engine_config_raw=engine_config_raw,
2203
- crawler_config_raw=crawler_config_raw,
2204
- output_json=output_json,
2205
- config_path=resolved_config,
2206
- chat_id=_resolve_cli_chat_id(ctx, chat_id),
2207
- )
2207
+ if len(queries) == 1:
2208
+ output = cmd_web_search(
2209
+ LazyState.get(ctx),
2210
+ queries[0],
2211
+ fetch_size=fetch_size,
2212
+ include_content=include_content,
2213
+ engine_config_raw=engine_config_raw,
2214
+ crawler_config_raw=crawler_config_raw,
2215
+ output_json=output_json,
2216
+ config_path=resolved_config,
2217
+ chat_id=_resolve_cli_chat_id(ctx, chat_id),
2218
+ )
2219
+ else:
2220
+ output = cmd_web_search_many(
2221
+ LazyState.get(ctx),
2222
+ list(queries),
2223
+ fetch_size=fetch_size,
2224
+ include_content=include_content,
2225
+ engine_config_raw=engine_config_raw,
2226
+ crawler_config_raw=crawler_config_raw,
2227
+ output_json=output_json,
2228
+ config_path=resolved_config,
2229
+ chat_id=_resolve_cli_chat_id(ctx, chat_id),
2230
+ )
2208
2231
  _emit_web_search(ctx, output)
2209
2232
 
2210
2233
 
@@ -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.paths import canonical_folder_path, same_folder_path
14
15
  from unique_sdk.cli.state import ShellState
15
16
  from unique_sdk.utils.file_io import download_content, upload_file
16
17
 
@@ -280,6 +281,18 @@ def _resolve_upload_destination(
280
281
  resolved_id = info.get("id")
281
282
  if not resolved_id:
282
283
  raise ValueError(f"folder not found: {folder_path}")
284
+ try:
285
+ actual = canonical_folder_path(
286
+ state.config.user_id,
287
+ state.config.company_id,
288
+ resolved_id,
289
+ )
290
+ except (ValueError, unique_sdk.UniqueError) as e:
291
+ raise ValueError(f"refused to upload to {folder_path}: {e}") from e
292
+ if not same_folder_path(actual, folder_path):
293
+ raise ValueError(
294
+ f"refused to upload to {folder_path}: resolved to {actual}"
295
+ )
283
296
  return resolved_id, display_name
284
297
 
285
298
  scope_id = state.scope_id
@@ -350,7 +363,12 @@ def cmd_upload(
350
363
  versioning_enabled=True,
351
364
  )
352
365
 
353
- content_id = result.id if hasattr(result, "id") else "?"
366
+ content_id = getattr(result, "id", None)
367
+ if not content_id:
368
+ return (
369
+ f"upload: refused to upload {display_name} to {scope_id}: "
370
+ "no content id returned"
371
+ )
354
372
  folder_path = unique_sdk.Folder.get_folder_path(
355
373
  user_id=state.config.user_id,
356
374
  company_id=state.config.company_id,
@@ -358,7 +376,7 @@ def cmd_upload(
358
376
  ).get("folderPath", scope_id)
359
377
  return f"Uploaded: {display_name} ({content_id}) to {folder_path}"
360
378
 
361
- except (ValueError, unique_sdk.UniqueError, OSError) as e:
379
+ except (ValueError, unique_sdk.UniqueError, OSError, RuntimeError) as e:
362
380
  return f"upload: {e}"
363
381
 
364
382
 
@@ -492,6 +510,15 @@ def cmd_mv_file(state: ShellState, old_name: str, new_name: str) -> str:
492
510
  return f"mv: {e}"
493
511
 
494
512
 
513
+ def is_upload_error_output(output: str) -> bool:
514
+ """Return True when *output* is a ``cmd_upload`` error.
515
+
516
+ Success text is ``Uploaded: ...``. Local misses, missing destinations,
517
+ and API failures all start with ``upload:`` and must exit non-zero.
518
+ """
519
+ return output.startswith("upload:")
520
+
521
+
495
522
  def is_permission_denied_output(output: str) -> bool:
496
523
  """Return ``True`` when a file-op result is a permission/scope denial.
497
524
 
@@ -6,6 +6,7 @@ import os
6
6
 
7
7
  import unique_sdk
8
8
  from unique_sdk.cli.formatting import format_folder_info
9
+ from unique_sdk.cli.paths import canonical_folder_path, same_folder_path
9
10
  from unique_sdk.cli.state import ShellState
10
11
 
11
12
 
@@ -40,8 +41,60 @@ def _metadata_filter_denies_folder(state: ShellState, scope_id: str | None) -> b
40
41
  return not state.folder_allowed_by_metadata_filter(scope_id)
41
42
 
42
43
 
44
+ def _existing_folder_id(state: ShellState, folder_path: str) -> str | None:
45
+ """Return the scope id at *folder_path* when that exact folder exists."""
46
+ try:
47
+ info = unique_sdk.Folder.get_info(
48
+ user_id=state.config.user_id,
49
+ company_id=state.config.company_id,
50
+ folderPath=folder_path,
51
+ )
52
+ except (ValueError, unique_sdk.UniqueError):
53
+ return None
54
+ folder_id = info.get("id")
55
+ if not folder_id:
56
+ return None
57
+ try:
58
+ actual = canonical_folder_path(
59
+ state.config.user_id,
60
+ state.config.company_id,
61
+ folder_id,
62
+ )
63
+ except (ValueError, unique_sdk.UniqueError):
64
+ return None
65
+ if not same_folder_path(actual, folder_path):
66
+ return None
67
+ return folder_id
68
+
69
+
70
+ def _created_folder_error(
71
+ state: ShellState,
72
+ scope_id: str | None,
73
+ folder_path: str,
74
+ ) -> str | None:
75
+ """Return an ``mkdir:`` error when *scope_id* is not exactly *folder_path*.
76
+
77
+ ``create_paths`` can return the ``/home-<userId>`` id in place of the leaf.
78
+ A failed lookup is a refusal: printing ``Created:`` would claim the folder
79
+ exists.
80
+ """
81
+ if not scope_id:
82
+ return f"mkdir: refused to create {folder_path}"
83
+ try:
84
+ actual = canonical_folder_path(
85
+ state.config.user_id,
86
+ state.config.company_id,
87
+ scope_id,
88
+ )
89
+ except (ValueError, unique_sdk.UniqueError) as e:
90
+ return f"mkdir: refused to create {folder_path}: {e}"
91
+ if not same_folder_path(actual, folder_path):
92
+ return f"mkdir: refused to create {folder_path}: resolved to {actual}"
93
+ return None
94
+
95
+
43
96
  def cmd_mkdir(state: ShellState, name: str) -> str:
44
- """Create a new folder under the current directory."""
97
+ """Create a folder by relative name or absolute path."""
45
98
  # A per-message metaDataFilter *replaces* the static scopeIds, so the
46
99
  # static-scope check must not over-deny an in-filter destination; the
47
100
  # normalized path is gated against the filter below. See UN-21780.
@@ -49,7 +102,7 @@ def cmd_mkdir(state: ShellState, name: str) -> str:
49
102
  state.workspace_metadata_filter is None
50
103
  and not state.is_folder_target_within_workspace(name)
51
104
  ):
52
- return "mkdir: permission denied (outside workspace scope)"
105
+ return f"mkdir: permission denied: refused {name} (outside workspace scope)"
53
106
  # Resolve the destination (collapsing any `..`) and gate the *path*, not
54
107
  # just the current scope id: `mkdir ../../Other/X` would otherwise pass a
55
108
  # cwd-only check and create structure outside the per-message task scope.
@@ -68,9 +121,15 @@ def cmd_mkdir(state: ShellState, name: str) -> str:
68
121
  )
69
122
  created = result.get("createdFolders", [])
70
123
  if created:
71
- last = created[-1]
72
- return f"Created: {full_path} ({last['id']})"
73
- return f"Created: {full_path}"
124
+ last_id = created[-1].get("id")
125
+ mismatch = _created_folder_error(state, last_id, full_path)
126
+ if mismatch:
127
+ return mismatch
128
+ return f"Created: {full_path} ({last_id})"
129
+ existing_id = _existing_folder_id(state, full_path)
130
+ if existing_id:
131
+ return f"Created: {full_path} ({existing_id})"
132
+ return f"mkdir: refused to create {full_path}"
74
133
  except (ValueError, unique_sdk.UniqueError) as e:
75
134
  return f"mkdir: {e}"
76
135
 
@@ -7,6 +7,7 @@ from typing import Any
7
7
  import unique_sdk
8
8
  from unique_sdk.cli.formatting import format_ls
9
9
  from unique_sdk.cli.metadata_filter import _collect_filter_targets
10
+ from unique_sdk.cli.paths import canonical_folder_path, same_folder_path
10
11
  from unique_sdk.cli.state import ShellState
11
12
 
12
13
 
@@ -23,13 +24,73 @@ def cmd_cd(state: ShellState, target: str) -> str:
23
24
  return f"cd: {e}"
24
25
 
25
26
 
27
+ def _canonical_listing_error(
28
+ state: ShellState,
29
+ label: str,
30
+ claimed_path: str,
31
+ scope_id: str,
32
+ ) -> str | None:
33
+ """Return an ``ls:`` error when *scope_id* is not exactly *claimed_path*.
34
+
35
+ ``get_info`` can hand back an ancestor for ``/home-<userId>/...``. The
36
+ comparison runs before any rows are trusted, including when that ancestor
37
+ has children. A failed lookup is an error: printing ``(empty)`` would hide
38
+ it.
39
+ """
40
+ try:
41
+ actual = canonical_folder_path(
42
+ state.config.user_id,
43
+ state.config.company_id,
44
+ scope_id,
45
+ )
46
+ except (ValueError, unique_sdk.UniqueError) as e:
47
+ return f"ls: cannot list {label}: {e}"
48
+ if not same_folder_path(actual, claimed_path):
49
+ return (
50
+ f"ls: cannot list {label}: resolved to {actual} instead (not listable here)"
51
+ )
52
+ return None
53
+
54
+
55
+ def _counts_without_rows_error(
56
+ label: str,
57
+ total_folders: int,
58
+ total_files: int,
59
+ ) -> str | None:
60
+ """Return an ``ls:`` error when the API reports rows it did not return."""
61
+ if total_folders or total_files:
62
+ return (
63
+ f"ls: cannot list {label}: listing returned no entries "
64
+ f"({total_folders} folder(s), {total_files} file(s) reported)"
65
+ )
66
+ return None
67
+
68
+
69
+ def _with_unlistable_marker(output: str, unlistable: list[str]) -> str:
70
+ """Append folders that could not be listed, or fail when none could."""
71
+ if not unlistable:
72
+ return output
73
+ if output.startswith("(empty)"):
74
+ return "ls: cannot list " + ", ".join(unlistable) + " (not listable)"
75
+ return output + "\nnot listable: " + ", ".join(unlistable)
76
+
77
+
78
+ def is_ls_error_output(output: str) -> bool:
79
+ """Return True when *output* is a ``cmd_ls`` error.
80
+
81
+ Success listings start with a table, ``(empty)``, or a count. Failures
82
+ start with ``ls:`` and must exit non-zero from the one-shot CLI.
83
+ """
84
+ return output.startswith("ls:")
85
+
86
+
26
87
  def cmd_ls(state: ShellState, target: str | None = None) -> str:
27
88
  """List folders and files at the given (or current) path."""
28
89
  try:
29
90
  if target is not None:
30
- _, scope_id = state.resolve_path(target)
91
+ resolved_path, scope_id = state.resolve_path(target)
31
92
  else:
32
- scope_id = state.scope_id
93
+ resolved_path, scope_id = state.cwd, state.scope_id
33
94
 
34
95
  # A non-root target must lie inside the per-message scope: without
35
96
  # this, `ls <path>` would enumerate out-of-scope folders/files that
@@ -56,6 +117,7 @@ def cmd_ls(state: ShellState, target: str | None = None) -> str:
56
117
  # UN-21780.
57
118
  folder_ids = state.navigable_folder_ids()
58
119
  scoped_folders: list[Any] = []
120
+ unlistable_folders: list[str] = []
59
121
  for sid in folder_ids:
60
122
  try:
61
123
  scoped_folders.append(
@@ -66,7 +128,7 @@ def cmd_ls(state: ShellState, target: str | None = None) -> str:
66
128
  )
67
129
  )
68
130
  except unique_sdk.UniqueError:
69
- pass
131
+ unlistable_folders.append(sid)
70
132
  scoped_files: list[Any] = []
71
133
  for cid in content_ids:
72
134
  # A contentId mentioned in the filter is not necessarily in
@@ -93,12 +155,13 @@ def cmd_ls(state: ShellState, target: str | None = None) -> str:
93
155
  f"\n{len(scoped_folders)} folder(s), {len(scoped_files)} "
94
156
  "file(s) in task scope"
95
157
  )
96
- return output + summary
158
+ return _with_unlistable_marker(output + summary, unlistable_folders)
97
159
 
98
160
  # When at root with a workspace restriction, show only the allowed scope
99
161
  # folders — the agent must not see the full company folder tree.
100
162
  if scope_id is None and state.workspace_scope_ids:
101
163
  folders: list[Any] = []
164
+ unlistable_folders = []
102
165
  for ws_id in state.workspace_scope_ids:
103
166
  try:
104
167
  info = unique_sdk.Folder.get_info(
@@ -108,10 +171,21 @@ def cmd_ls(state: ShellState, target: str | None = None) -> str:
108
171
  )
109
172
  folders.append(info)
110
173
  except unique_sdk.UniqueError:
111
- pass
174
+ unlistable_folders.append(ws_id)
112
175
  output = format_ls(folders, [])
113
176
  summary = f"\n{len(folders)} folder(s), 0 file(s)"
114
- return output + summary
177
+ return _with_unlistable_marker(output + summary, unlistable_folders)
178
+
179
+ if scope_id:
180
+ label = target if target is not None else resolved_path
181
+ path_error = _canonical_listing_error(
182
+ state,
183
+ label,
184
+ resolved_path,
185
+ scope_id,
186
+ )
187
+ if path_error:
188
+ return path_error
115
189
 
116
190
  folder_params: dict[str, Any] = {}
117
191
  content_params: dict[str, Any] = {}
@@ -136,6 +210,16 @@ def cmd_ls(state: ShellState, target: str | None = None) -> str:
136
210
  total_folders = folder_result.get("totalCount", len(folders))
137
211
  total_files = content_result.get("totalCount", len(files))
138
212
 
213
+ if scope_id and not folders and not files:
214
+ label = target if target is not None else resolved_path
215
+ counts_error = _counts_without_rows_error(
216
+ label,
217
+ total_folders,
218
+ total_files,
219
+ )
220
+ if counts_error:
221
+ return counts_error
222
+
139
223
  # With a per-message filter, listing inside an allowed folder must not
140
224
  # reveal files the filter excludes (e.g. a combined folder + contentId
141
225
  # allowlist): read/cite would deny them, so ls must hide them too.
@@ -19,6 +19,7 @@ from __future__ import annotations
19
19
 
20
20
  import json
21
21
  import logging
22
+ from concurrent.futures import ThreadPoolExecutor
22
23
  from pathlib import Path
23
24
  from typing import Any, cast
24
25
 
@@ -40,6 +41,8 @@ from unique_sdk.cli.workspace import manifest_path as workspace_manifest_path
40
41
  _LOGGER = logging.getLogger(__name__)
41
42
 
42
43
  DEFAULT_PARALLEL = 10
44
+ MAX_SEARCH_QUERIES = 8
45
+ SEARCH_CONCURRENCY = 4
43
46
  _SNIPPET_PREVIEW_LIMIT = 200
44
47
  _CONTENT_PREVIEW_LIMIT = 500
45
48
  _WEB_REFS_LOG_RELATIVE_PATH = Path(".unique") / "web-refs.jsonl"
@@ -231,14 +234,18 @@ def _format_search_results(payload: dict[str, Any]) -> str:
231
234
  return "\n".join(lines).rstrip()
232
235
 
233
236
 
237
+ def _search_results_projection(payload: dict[str, Any]) -> dict[str, Any]:
238
+ """Stable projection — drops the SDK envelope ``object`` discriminator."""
239
+ return {
240
+ "engine": payload.get("engine"),
241
+ "query": payload.get("query"),
242
+ "results": payload.get("results", []),
243
+ }
244
+
245
+
234
246
  def _format_search_results_json(payload: dict[str, Any]) -> str:
235
- """Stable JSON projection — drops the SDK envelope ``object`` discriminator."""
236
247
  return json.dumps(
237
- {
238
- "engine": payload.get("engine"),
239
- "query": payload.get("query"),
240
- "results": payload.get("results", []),
241
- },
248
+ _search_results_projection(payload),
242
249
  indent=2,
243
250
  ensure_ascii=False,
244
251
  )
@@ -385,6 +392,40 @@ def cmd_web_search(
385
392
  chat_id: Optional chat id this call is made on behalf of. When
386
393
  set, the space's Web Search toggle is enforced server-side.
387
394
  """
395
+ options = _search_request_options(
396
+ fetch_size=fetch_size,
397
+ include_content=include_content,
398
+ engine_config_raw=engine_config_raw,
399
+ crawler_config_raw=crawler_config_raw,
400
+ config_path=config_path,
401
+ chat_id=chat_id,
402
+ )
403
+ if isinstance(options, str):
404
+ return options
405
+
406
+ payload = _fetch_search_payload(state, query, options)
407
+ if isinstance(payload, str):
408
+ return payload
409
+
410
+ try:
411
+ payload = _annotate_web_results_for_citations(payload)
412
+ except UnsafeRefsLogPathError as exc:
413
+ return f"{WEB_SEARCH_ERROR_PREFIX} {exc}"
414
+ if output_json:
415
+ return _format_search_results_json(payload)
416
+ return _format_search_results(payload)
417
+
418
+
419
+ def _search_request_options(
420
+ *,
421
+ fetch_size: int | None,
422
+ include_content: bool,
423
+ engine_config_raw: str | None,
424
+ crawler_config_raw: str | None,
425
+ config_path: str | None,
426
+ chat_id: str | None,
427
+ ) -> dict[str, Any] | str:
428
+ """Build the request fields shared by every query, or return an error string."""
388
429
  file_overrides = _load_file_overrides(config_path)
389
430
  if isinstance(file_overrides, str):
390
431
  return file_overrides
@@ -402,34 +443,147 @@ def cmd_web_search(
402
443
  if fetch_size is None:
403
444
  fetch_size = file_overrides.fetch_size
404
445
 
405
- params: dict[str, Any] = {"query": query}
446
+ options: dict[str, Any] = {}
406
447
  if fetch_size is not None:
407
- params["fetchSize"] = fetch_size
448
+ options["fetchSize"] = fetch_size
408
449
  if include_content:
409
- params["includeContent"] = True
450
+ options["includeContent"] = True
410
451
  if engine_override is not None:
411
- params["searchEngineConfig"] = engine_override
452
+ options["searchEngineConfig"] = engine_override
412
453
  if crawler_override is not None:
413
- params["crawlerConfig"] = crawler_override
454
+ options["crawlerConfig"] = crawler_override
414
455
  if chat_id:
415
- params["chatId"] = chat_id
456
+ options["chatId"] = chat_id
457
+ return options
416
458
 
459
+
460
+ def _fetch_search_payload(
461
+ state: ShellState,
462
+ query: str,
463
+ options: dict[str, Any],
464
+ ) -> dict[str, Any] | str:
465
+ """Run one search request; return the payload, or an error string."""
417
466
  try:
418
467
  resource = unique_sdk.WebSearch.search(
419
468
  user_id=state.config.user_id,
420
469
  company_id=state.config.company_id,
421
- **params,
470
+ query=query,
471
+ **options,
422
472
  )
423
- except (ValueError, unique_sdk.APIError) as exc:
473
+ except (ValueError, unique_sdk.UniqueError) as exc:
424
474
  return f"{WEB_SEARCH_ERROR_PREFIX} {exc}"
475
+ return _payload_from_resource(resource)
476
+
477
+
478
+ def cmd_web_search_many(
479
+ state: ShellState,
480
+ queries: list[str],
481
+ fetch_size: int | None = None,
482
+ include_content: bool = False,
483
+ engine_config_raw: str | None = None,
484
+ crawler_config_raw: str | None = None,
485
+ output_json: bool = False,
486
+ config_path: str | None = None,
487
+ chat_id: str | None = None,
488
+ ) -> str:
489
+ """Run several web searches in one call and return one block per query.
490
+
491
+ Requests run concurrently, at most ``SEARCH_CONCURRENCY`` at a time.
492
+ Citation numbers are assigned afterwards, one query at a time in the
493
+ order given, so ``[websourceN]`` is the same on every run. A URL found
494
+ by several queries keeps one number; the manifest gets one row per search.
495
+
496
+ A failed query shows its own error and does not drop the others. The
497
+ output is a ``web-search:`` error only when every query failed.
498
+ ``output_json`` returns a list with one entry per query: the usual
499
+ ``{engine, query, results}`` object, or ``{query, error}`` on failure.
500
+ """
501
+ if not queries:
502
+ return f"{WEB_SEARCH_ERROR_PREFIX} no queries provided."
503
+ if len(queries) > MAX_SEARCH_QUERIES:
504
+ return (
505
+ f"{WEB_SEARCH_ERROR_PREFIX} too many queries ({len(queries)}); "
506
+ f"the limit is {MAX_SEARCH_QUERIES} per call. "
507
+ "Split them across several calls."
508
+ )
509
+
510
+ options = _search_request_options(
511
+ fetch_size=fetch_size,
512
+ include_content=include_content,
513
+ engine_config_raw=engine_config_raw,
514
+ crawler_config_raw=crawler_config_raw,
515
+ config_path=config_path,
516
+ chat_id=chat_id,
517
+ )
518
+ if isinstance(options, str):
519
+ return options
520
+
521
+ with ThreadPoolExecutor(
522
+ max_workers=min(SEARCH_CONCURRENCY, len(queries))
523
+ ) as executor:
524
+ fetched = list(
525
+ executor.map(
526
+ lambda query: _fetch_search_payload(state, query, options), queries
527
+ )
528
+ )
529
+
530
+ outcomes: list[dict[str, Any] | str] = []
531
+ for item in fetched:
532
+ if isinstance(item, str):
533
+ outcomes.append(item)
534
+ continue
535
+ try:
536
+ outcomes.append(_annotate_web_results_for_citations(item))
537
+ except UnsafeRefsLogPathError as exc:
538
+ return f"{WEB_SEARCH_ERROR_PREFIX} {exc}"
539
+
540
+ failures = [
541
+ f"{query!r}: {outcome}"
542
+ for query, outcome in zip(queries, outcomes, strict=True)
543
+ if isinstance(outcome, str)
544
+ ]
545
+ if len(failures) == len(queries):
546
+ return (
547
+ f"{WEB_SEARCH_ERROR_PREFIX} all {len(queries)} queries failed.\n"
548
+ + "\n".join(failures)
549
+ )
425
550
 
426
- try:
427
- payload = _annotate_web_results_for_citations(_payload_from_resource(resource))
428
- except UnsafeRefsLogPathError as exc:
429
- return f"{WEB_SEARCH_ERROR_PREFIX} {exc}"
430
551
  if output_json:
431
- return _format_search_results_json(payload)
432
- return _format_search_results(payload)
552
+ return _format_many_search_results_json(queries, outcomes)
553
+ return _format_many_search_results(queries, outcomes)
554
+
555
+
556
+ def _format_many_search_results(
557
+ queries: list[str],
558
+ outcomes: list[dict[str, Any] | str],
559
+ ) -> str:
560
+ blocks: list[str] = []
561
+ failed = 0
562
+ for position, (query, outcome) in enumerate(
563
+ zip(queries, outcomes, strict=True), start=1
564
+ ):
565
+ header = f"=== query {position}/{len(queries)} ==="
566
+ if isinstance(outcome, str):
567
+ failed += 1
568
+ blocks.append(f"{header}\nquery: {query!r}\nERROR: {outcome}")
569
+ continue
570
+ blocks.append(f"{header}\n{_format_search_results(outcome)}")
571
+ if failed:
572
+ blocks.append(f"{failed} of {len(queries)} queries failed.")
573
+ return "\n\n".join(blocks)
574
+
575
+
576
+ def _format_many_search_results_json(
577
+ queries: list[str],
578
+ outcomes: list[dict[str, Any] | str],
579
+ ) -> str:
580
+ entries = [
581
+ {"query": query, "error": outcome}
582
+ if isinstance(outcome, str)
583
+ else _search_results_projection(outcome)
584
+ for query, outcome in zip(queries, outcomes, strict=True)
585
+ ]
586
+ return json.dumps(entries, indent=2, ensure_ascii=False)
433
587
 
434
588
 
435
589
  def cmd_web_crawl(
@@ -0,0 +1,42 @@
1
+ """Folder-path comparisons shared by the CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ import unique_sdk
8
+
9
+
10
+ def same_folder_path(left: str, right: str) -> bool:
11
+ """True when two folder paths name the same location.
12
+
13
+ A missing leading slash and a trailing slash do not make two paths differ.
14
+ """
15
+
16
+ def norm(value: str) -> str:
17
+ text = value.strip()
18
+ if text and not text.startswith("/"):
19
+ text = f"/{text}"
20
+ return os.path.normpath(text.rstrip("/")) or "/"
21
+
22
+ return norm(left) == norm(right)
23
+
24
+
25
+ def canonical_folder_path(user_id: str, company_id: str, scope_id: str) -> str:
26
+ """Return the stored path for *scope_id*.
27
+
28
+ Raises:
29
+ ValueError: The folder has no path.
30
+ unique_sdk.UniqueError: The path lookup failed.
31
+ """
32
+ actual = (
33
+ unique_sdk.Folder.get_folder_path(
34
+ user_id=user_id,
35
+ company_id=company_id,
36
+ scope_id=scope_id,
37
+ ).get("folderPath")
38
+ or ""
39
+ )
40
+ if not actual:
41
+ raise ValueError(f"folder {scope_id} has no path")
42
+ return actual
@@ -239,12 +239,13 @@ class UniqueShell(cmd.Cmd):
239
239
  # -- Folder operations --
240
240
 
241
241
  def do_mkdir(self, arg: str) -> None:
242
- """Create a new folder under the current directory.
242
+ """Create a folder by relative name or absolute path.
243
243
 
244
244
  Usage: mkdir <name>
245
245
 
246
- Creates a child folder with the given name. Nested paths are
247
- supported (e.g., "A/B/C" creates the full hierarchy).
246
+ A relative name is created under the current directory. An absolute
247
+ path (starting with /) is created from the root, including under
248
+ /home-<userId>. Nested paths create the full hierarchy.
248
249
 
249
250
  Examples:
250
251
  /Reports> mkdir Q2
@@ -252,6 +253,9 @@ class UniqueShell(cmd.Cmd):
252
253
 
253
254
  /Reports> mkdir "2025/Q1"
254
255
  Created: /Reports/2025/Q1 (scope_xyz999)
256
+
257
+ /> mkdir /home-user_1/second-brain/clients/X
258
+ Created: /home-user_1/second-brain/clients/X (scope_home1)
255
259
  """
256
260
  name = arg.strip()
257
261
  if not name:
@@ -58,6 +58,31 @@ unique-cli web-search search "python tutorial" --json
58
58
  unique-cli web-search search "sustainability reports" --include-content --json
59
59
  ```
60
60
 
61
+ ### Several searches: pass every query in ONE call
62
+
63
+ When you need more than one search (several topics, companies or
64
+ questions), pass all queries as separate arguments of a single `search`
65
+ command. Do **not** run one `unique-cli web-search search` call per query,
66
+ and do not start several in the background: the queries run concurrently
67
+ inside the one call and the answer comes back in one round.
68
+
69
+ ```bash
70
+ unique-cli web-search search "Git first release" "Redis first release" "Docker first release" --json
71
+ ```
72
+
73
+ - Up to 8 queries per call (4 run at the same time). More than 8 is an
74
+ error: split them across calls.
75
+ - Options such as `-n`, `--include-content` and `--json` apply to every
76
+ query. With `--include-content`, prefer fewer queries per call: the output
77
+ grows with every query.
78
+ - Output has one block per query, in the order you gave them, each with
79
+ its query. `--json` returns a list with one entry per query.
80
+ - `[websourceN]` numbers follow the query order. A URL found by two
81
+ queries has one number.
82
+ - A query that fails shows its own error and the other queries still
83
+ return. The command exits `1` only when every query failed.
84
+ - A single query behaves exactly as before.
85
+
61
86
  ### Per-call engine override
62
87
 
63
88
  Force a specific engine (or any engine-specific knob) without changing server config:
@@ -141,7 +166,7 @@ discriminator:
141
166
 
142
167
  ## Command Reference
143
168
 
144
- ### `unique-cli web-search search <query>`
169
+ ### `unique-cli web-search search <query> [<query>...]`
145
170
 
146
171
  | Option | Short | Default | Description |
147
172
  |--------|-------|---------|-------------|
@@ -217,6 +242,28 @@ Found 3 result(s):
217
242
  > | unique-cli web-search crawl --stdin
218
243
  > ```
219
244
 
245
+ ### `search` with several queries (text)
246
+
247
+ ```
248
+ === query 1/2 ===
249
+ engine: Google query: 'Git first release'
250
+ Found 1 result(s):
251
+
252
+ 1. Git history [websource1]
253
+ https://example.com/git
254
+ ...
255
+
256
+ === query 2/2 ===
257
+ query: 'Redis first release'
258
+ ERROR: web-search: upstream failed
259
+
260
+ 1 of 2 queries failed.
261
+ ```
262
+
263
+ With `--json` the output is a list. Each entry is the usual
264
+ `{engine, query, results}` object, or `{"query": "...", "error": "..."}`
265
+ for a query that failed.
266
+
220
267
  ### `crawl` (text)
221
268
 
222
269
  ```
@@ -261,7 +308,10 @@ In one-shot mode, the CLI exits **`0`** on success and **`1`** on
261
308
  failures (config-file errors, invalid JSON overrides, API errors,
262
309
  missing URLs for `crawl`, and citation-manifest I/O failures — e.g.
263
310
  when `.unique/web-refs.jsonl` cannot be written or its parent
264
- directory is unsafe). Error messages are prefixed with
311
+ directory is unsafe). With several queries in one `search` call, a failed
312
+ query alone does not change the exit code: it exits `1` only when every
313
+ query failed, or when the call is invalid (more than 8 queries, bad
314
+ override JSON). Error messages are prefixed with
265
315
  `web-search:` or `web-crawl:` so they're easy to grep.
266
316
 
267
317
  ```bash
@@ -10,6 +10,7 @@ from typing import Any
10
10
  import unique_sdk
11
11
  from unique_sdk.cli.config import Config
12
12
  from unique_sdk.cli.metadata_filter import MetadataFilter
13
+ from unique_sdk.cli.paths import canonical_folder_path, same_folder_path
13
14
  from unique_sdk.cli.workspace import manifest_path as workspace_manifest_path
14
15
 
15
16
  _SEARCH_CONFIG_FILENAME = ".unique-search.json"
@@ -561,6 +562,19 @@ class ShellState:
561
562
  def prompt(self) -> str:
562
563
  return f"{self._path}> "
563
564
 
565
+ def _enter_folder(self, scope_id: str, folder_path: str) -> str:
566
+ """Store *folder_path* only when *scope_id* is that exact folder."""
567
+ actual = canonical_folder_path(
568
+ self.config.user_id,
569
+ self.config.company_id,
570
+ scope_id,
571
+ )
572
+ if not same_folder_path(actual, folder_path):
573
+ raise ValueError(f"cannot change to {folder_path}: resolved to {actual}")
574
+ self._scope_id = scope_id
575
+ self._path = folder_path
576
+ return self._path
577
+
564
578
  def cd(self, target: str) -> str:
565
579
  """Change directory. Returns the new path.
566
580
 
@@ -590,8 +604,7 @@ class ShellState:
590
604
  company_id=self.config.company_id,
591
605
  folderPath=parent,
592
606
  )
593
- self._path = parent
594
- self._scope_id = info["id"]
607
+ return self._enter_folder(info["id"], parent)
595
608
  return self._path
596
609
 
597
610
  if target.startswith("scope_"):
@@ -619,9 +632,7 @@ class ShellState:
619
632
  company_id=self.config.company_id,
620
633
  folderPath=resolved_path,
621
634
  )
622
- self._scope_id = info["id"]
623
- self._path = resolved_path
624
- return self._path
635
+ return self._enter_folder(info["id"], resolved_path)
625
636
 
626
637
  def resolve_path(self, target: str | None) -> tuple[str, str | None]:
627
638
  """Resolve a target to (path, scope_id) without changing state.