unique-sdk 2026.44.0.dev2__tar.gz → 2026.44.0.dev4__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.dev2 → unique_sdk-2026.44.0.dev4}/PKG-INFO +1 -1
  2. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/pyproject.toml +1 -1
  3. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/cli/cli.py +19 -7
  4. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/cli/commands/files.py +29 -2
  5. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/cli/commands/folders.py +64 -5
  6. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/cli/commands/mcp.py +76 -7
  7. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/cli/commands/navigation.py +90 -6
  8. unique_sdk-2026.44.0.dev4/unique_sdk/cli/paths.py +42 -0
  9. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/cli/shell.py +7 -3
  10. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/cli/state.py +16 -5
  11. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/README.md +0 -0
  12. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/__init__.py +0 -0
  13. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/_api_requestor.py +0 -0
  14. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/_api_resource.py +0 -0
  15. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/_api_version.py +0 -0
  16. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/_error.py +0 -0
  17. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/_http_client.py +0 -0
  18. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/_list_object.py +0 -0
  19. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/_object_classes.py +0 -0
  20. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/_request_options.py +0 -0
  21. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/_unique_object.py +0 -0
  22. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/_unique_ql.py +0 -0
  23. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/_unique_response.py +0 -0
  24. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/_util.py +0 -0
  25. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/_version.py +0 -0
  26. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/_webhook.py +0 -0
  27. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/api_resources/__init__.py +0 -0
  28. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/api_resources/_acronyms.py +0 -0
  29. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/api_resources/_agentic_table.py +0 -0
  30. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/api_resources/_analytics_order.py +0 -0
  31. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/api_resources/_benchmarking.py +0 -0
  32. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/api_resources/_briefing.py +0 -0
  33. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/api_resources/_chat_completion.py +0 -0
  34. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/api_resources/_content.py +0 -0
  35. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/api_resources/_context_memory.py +0 -0
  36. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/api_resources/_elicitation.py +0 -0
  37. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/api_resources/_embedding.py +0 -0
  38. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/api_resources/_event.py +0 -0
  39. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/api_resources/_folder.py +0 -0
  40. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/api_resources/_group.py +0 -0
  41. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/api_resources/_integrated.py +0 -0
  42. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/api_resources/_llm_models.py +0 -0
  43. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/api_resources/_mcp.py +0 -0
  44. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/api_resources/_message.py +0 -0
  45. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/api_resources/_message_assessment.py +0 -0
  46. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/api_resources/_message_execution.py +0 -0
  47. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/api_resources/_message_log.py +0 -0
  48. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/api_resources/_message_tool.py +0 -0
  49. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/api_resources/_module.py +0 -0
  50. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/api_resources/_scheduled_task.py +0 -0
  51. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/api_resources/_search.py +0 -0
  52. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/api_resources/_search_string.py +0 -0
  53. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/api_resources/_short_term_memory.py +0 -0
  54. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/api_resources/_space.py +0 -0
  55. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/api_resources/_user.py +0 -0
  56. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/api_resources/_web_search.py +0 -0
  57. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/cli/__init__.py +0 -0
  58. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/cli/__main__.py +0 -0
  59. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/cli/commands/__init__.py +0 -0
  60. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/cli/commands/_citation_manifest.py +0 -0
  61. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/cli/commands/agentic_table.py +0 -0
  62. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/cli/commands/agentic_table_write.py +0 -0
  63. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/cli/commands/browser.py +0 -0
  64. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/cli/commands/cite_file.py +0 -0
  65. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/cli/commands/elicitation.py +0 -0
  66. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/cli/commands/read.py +0 -0
  67. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/cli/commands/scheduled_tasks.py +0 -0
  68. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/cli/commands/search.py +0 -0
  69. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/cli/commands/subagent.py +0 -0
  70. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/cli/commands/web_search.py +0 -0
  71. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/cli/commands/web_search_config.py +0 -0
  72. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/cli/config.py +0 -0
  73. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/cli/formatting.py +0 -0
  74. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/cli/identity.py +0 -0
  75. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/cli/metadata_filter.py +0 -0
  76. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/cli/skills/unique-cli-agentic-table/SKILL.md +0 -0
  77. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/cli/skills/unique-cli-elicitation/SKILL.md +0 -0
  78. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/cli/skills/unique-cli-file-management/SKILL.md +0 -0
  79. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/cli/skills/unique-cli-mcp/SKILL.md +0 -0
  80. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/cli/skills/unique-cli-scheduled-tasks/SKILL.md +0 -0
  81. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/cli/skills/unique-cli-search/SKILL.md +0 -0
  82. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/cli/skills/unique-cli-subagent/SKILL.md +0 -0
  83. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/cli/skills/unique-cli-uploaded-search/SKILL.md +0 -0
  84. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/cli/skills/unique-cli-web-search/SKILL.md +0 -0
  85. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/cli/workspace.py +0 -0
  86. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/utils/analytics_order_run.py +0 -0
  87. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/utils/benchmarking_run.py +0 -0
  88. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/utils/chat_history.py +0 -0
  89. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/utils/chat_in_space.py +0 -0
  90. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/utils/file_io.py +0 -0
  91. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/unique_sdk/utils/sources.py +0 -0
  92. {unique_sdk-2026.44.0.dev2 → unique_sdk-2026.44.0.dev4}/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.dev2
3
+ Version: 2026.44.0.dev4
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.dev2"
3
+ version = "2026.44.0.dev4"
4
4
  description = ""
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -58,12 +58,21 @@ 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,
64
67
  )
65
68
  from unique_sdk.cli.commands.mcp import cmd_mcp
69
+ from unique_sdk.cli.commands.mcp import (
70
+ is_error_output as _is_mcp_error_output,
71
+ )
66
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
+ )
67
76
  from unique_sdk.cli.commands.read import cmd_read
68
77
  from unique_sdk.cli.commands.read import (
69
78
  is_error_output as _is_read_error_output,
@@ -319,23 +328,25 @@ def ls(ctx: click.Context, target: str | None) -> None:
319
328
  unique-cli ls scope_abc List by scope ID
320
329
  """
321
330
  output = cmd_ls(LazyState.get(ctx), target)
322
- emit(output)
331
+ emit(output, is_error=_is_ls_error_output)
323
332
 
324
333
 
325
334
  @main.command()
326
335
  @click.argument("name")
327
336
  @click.pass_context
328
337
  def mkdir(ctx: click.Context, name: str) -> None:
329
- """Create a new folder under the current directory.
338
+ """Create a folder by relative name or absolute path.
330
339
 
331
340
  \b
332
- NAME is the folder name to create. Nested paths are supported
333
- (e.g., "A/B/C" creates the full hierarchy).
341
+ A relative NAME is created under the current directory. An absolute
342
+ path (starting with /) is created from the root, including under
343
+ /home-<userId>. Nested paths create the full hierarchy.
334
344
 
335
345
  \b
336
346
  Examples:
337
347
  unique-cli mkdir Q2
338
348
  unique-cli mkdir "2025/Q1"
349
+ unique-cli mkdir /home-user_1/second-brain/clients/X
339
350
  """
340
351
  output = cmd_mkdir(LazyState.get(ctx), name)
341
352
  emit(output, is_error=_is_mkdir_error_output)
@@ -418,7 +429,7 @@ def upload(ctx: click.Context, local_path: str, destination: str | None) -> None
418
429
  unique-cli upload ./report.pdf /Archive/2025/
419
430
  """
420
431
  output = cmd_upload(LazyState.get(ctx), local_path, destination)
421
- emit(output)
432
+ emit(output, is_error=_is_upload_error_output)
422
433
 
423
434
 
424
435
  @main.command()
@@ -844,7 +855,7 @@ def mcp(
844
855
  """
845
856
  resolved_message_id = _resolve_cli_message_id(ctx, message_id, required=True)
846
857
  resolved_chat_id = _resolve_cli_chat_id(ctx, chat_id, required=True)
847
- click.echo(
858
+ emit(
848
859
  cmd_mcp(
849
860
  LazyState.get(ctx),
850
861
  chat_id=resolved_chat_id or "",
@@ -852,7 +863,8 @@ def mcp(
852
863
  payload=payload,
853
864
  file=file_path,
854
865
  stdin=use_stdin,
855
- )
866
+ ),
867
+ is_error=_is_mcp_error_output,
856
868
  )
857
869
 
858
870
 
@@ -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
 
@@ -26,6 +26,9 @@ from unique_sdk.cli.workspace import unique_dir as workspace_unique_dir
26
26
 
27
27
  _LOGGER = logging.getLogger(__name__)
28
28
 
29
+ MCP_ERROR_PREFIX = "mcp:"
30
+ _FORMATTER_ERROR_PREFIX = "mcp: formatter error"
31
+
29
32
  # Per-turn manifest of MCP tool output text, consumed by the Swappable
30
33
  # Intelligence runner to ground the hallucination check against MCP-retrieved
31
34
  # information (UN-21951). Carries *text only* — no source numbers/markers
@@ -1058,6 +1061,58 @@ def _parse_and_validate(raw: str) -> tuple[str, dict[str, Any]]:
1058
1061
  return name, arguments
1059
1062
 
1060
1063
 
1064
+ class McpCommandResult(str):
1065
+ """Text returned by ``cmd_mcp``, with whether that call failed.
1066
+
1067
+ Subclasses ``str`` so callers can print and search the text. ``failed``
1068
+ is set when the process should exit 1: a payload error, any
1069
+ ``UniqueError``, or a tool result with ``isError`` set.
1070
+ """
1071
+
1072
+ failed: bool
1073
+
1074
+ def __new__(cls, text: str, *, failed: bool) -> McpCommandResult:
1075
+ result = str.__new__(cls, text)
1076
+ result.failed = failed
1077
+ return result
1078
+
1079
+
1080
+ def _response_is_error(response: Any) -> bool:
1081
+ """Return whether the tool result itself is marked ``isError``.
1082
+
1083
+ Args:
1084
+ response (Any): Raw MCP tool response.
1085
+
1086
+ Returns:
1087
+ bool: True when ``isError`` is set. False when it is absent, false,
1088
+ or unreadable, so a formatter failure on a successful result still
1089
+ exits 0.
1090
+ """
1091
+ try:
1092
+ return bool(getattr(response, "isError", False))
1093
+ except Exception:
1094
+ return False
1095
+
1096
+
1097
+ def is_error_output(output: str) -> bool:
1098
+ """Return whether an MCP command result should exit non-zero.
1099
+
1100
+ Args:
1101
+ output (str): Text returned by ``cmd_mcp``.
1102
+
1103
+ Returns:
1104
+ bool: True for payload errors, SDK errors, and ``isError`` tool
1105
+ results. False when the call succeeded, including when formatting
1106
+ that successful result failed and the fallback text was returned.
1107
+ """
1108
+ failed = getattr(output, "failed", None)
1109
+ if isinstance(failed, bool):
1110
+ return failed
1111
+ if output.startswith(_FORMATTER_ERROR_PREFIX):
1112
+ return False
1113
+ return output.startswith(MCP_ERROR_PREFIX)
1114
+
1115
+
1061
1116
  def cmd_mcp(
1062
1117
  state: ShellState,
1063
1118
  chat_id: str,
@@ -1065,8 +1120,21 @@ def cmd_mcp(
1065
1120
  payload: str | None = None,
1066
1121
  file: str | None = None,
1067
1122
  stdin: bool = False,
1068
- ) -> str:
1069
- """Call an MCP tool with a JSON payload containing name and arguments."""
1123
+ ) -> McpCommandResult:
1124
+ """Call an MCP tool with a JSON payload containing name and arguments.
1125
+
1126
+ Args:
1127
+ state (ShellState): CLI session holding the user and company ids.
1128
+ chat_id (str): Chat the tool call is attributed to.
1129
+ message_id (str): Message the tool call is attributed to.
1130
+ payload (str | None): Inline JSON payload.
1131
+ file (str | None): Path to a JSON payload file.
1132
+ stdin (bool): Read the JSON payload from stdin.
1133
+
1134
+ Returns:
1135
+ McpCommandResult: Formatted tool output, or an ``mcp:`` error line.
1136
+ ``failed`` is true when the call itself failed.
1137
+ """
1070
1138
  try:
1071
1139
  raw = _read_payload(payload, file, stdin)
1072
1140
  name, arguments = _parse_and_validate(raw)
@@ -1080,8 +1148,8 @@ def cmd_mcp(
1080
1148
  arguments=arguments,
1081
1149
  )
1082
1150
 
1083
- except (ValueError, OSError, unique_sdk.APIError) as e:
1084
- return f"mcp: {e}"
1151
+ except (ValueError, OSError, unique_sdk.UniqueError) as e:
1152
+ return McpCommandResult(f"{MCP_ERROR_PREFIX} {e}", failed=True)
1085
1153
 
1086
1154
  try:
1087
1155
  formatted = format_mcp_response(response, tool_name=name)
@@ -1090,7 +1158,8 @@ def cmd_mcp(
1090
1158
  fallback = json.dumps(dict(response), indent=2, default=str)
1091
1159
  except Exception:
1092
1160
  fallback = repr(response)
1093
- formatted = f"mcp: formatter error ({fmt_exc}); raw response:\n{fallback}"
1161
+ formatted = f"{_FORMATTER_ERROR_PREFIX} ({fmt_exc}); raw response:\n{fallback}"
1162
+ call_failed = _response_is_error(response)
1094
1163
 
1095
1164
  server_name = _server_name_from_tool(name) or getattr(response, "mcpServerId", None)
1096
1165
  sources_block = record_mcp_citations(
@@ -1104,5 +1173,5 @@ def cmd_mcp(
1104
1173
  # Sources block FIRST: large outputs get spilled/truncated tail-first by
1105
1174
  # the agent harness, and a trailing block is what partial reads miss.
1106
1175
  if sources_block:
1107
- return sources_block + "\n\n" + formatted
1108
- return formatted
1176
+ formatted = sources_block + "\n\n" + formatted
1177
+ return McpCommandResult(formatted, failed=call_failed)
@@ -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.
@@ -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:
@@ -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.