unique-sdk 2026.28.0.dev6__tar.gz → 2026.28.0.dev8__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.
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/PKG-INFO +1 -1
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/pyproject.toml +1 -1
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/cli.py +60 -19
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/cite_file.py +41 -2
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/files.py +154 -21
- unique_sdk-2026.28.0.dev8/unique_sdk/cli/commands/folders.py +149 -0
- unique_sdk-2026.28.0.dev8/unique_sdk/cli/commands/mcp.py +342 -0
- unique_sdk-2026.28.0.dev8/unique_sdk/cli/commands/navigation.py +165 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/read.py +5 -1
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/search.py +31 -1
- unique_sdk-2026.28.0.dev8/unique_sdk/cli/metadata_filter.py +434 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/skills/unique-cli-file-management/SKILL.md +20 -4
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/skills/unique-cli-mcp/SKILL.md +16 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/skills/unique-cli-search/SKILL.md +14 -4
- unique_sdk-2026.28.0.dev8/unique_sdk/cli/state.py +548 -0
- unique_sdk-2026.28.0.dev6/unique_sdk/cli/commands/folders.py +0 -80
- unique_sdk-2026.28.0.dev6/unique_sdk/cli/commands/mcp.py +0 -93
- unique_sdk-2026.28.0.dev6/unique_sdk/cli/commands/navigation.py +0 -79
- unique_sdk-2026.28.0.dev6/unique_sdk/cli/state.py +0 -252
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/README.md +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/__init__.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/_api_requestor.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/_api_resource.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/_api_version.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/_error.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/_http_client.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/_list_object.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/_object_classes.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/_request_options.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/_unique_object.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/_unique_ql.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/_unique_response.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/_util.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/_version.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/_webhook.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/__init__.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_acronyms.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_agentic_table.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_analytics_order.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_benchmarking.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_briefing.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_chat_completion.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_content.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_dynamic_frontend.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_elicitation.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_embedding.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_event.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_folder.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_group.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_integrated.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_llm_models.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_mcp.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_message.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_message_assessment.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_message_execution.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_message_log.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_message_tool.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_module.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_scheduled_task.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_search.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_search_string.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_short_term_memory.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_space.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_user.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_web_search.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/__init__.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/__main__.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/__init__.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/_citation_manifest.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/dynamic_frontend.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/elicitation.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/scheduled_tasks.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/subagent.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/web_search.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/web_search_config.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/config.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/formatting.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/shell.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/skills/unique-cli-dynamic-frontend/SKILL.md +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/skills/unique-cli-elicitation/SKILL.md +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/skills/unique-cli-scheduled-tasks/SKILL.md +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/skills/unique-cli-subagent/SKILL.md +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/skills/unique-cli-web-search/SKILL.md +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/utils/analytics_order_run.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/utils/benchmarking_run.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/utils/chat_history.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/utils/chat_in_space.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/utils/file_io.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/utils/sources.py +0 -0
- {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/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.28.0.
|
|
3
|
+
Version: 2026.28.0.dev8
|
|
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>
|
|
@@ -2,12 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from collections.abc import Callable
|
|
5
6
|
from typing import Any
|
|
6
7
|
|
|
7
8
|
import click
|
|
8
9
|
|
|
9
10
|
from unique_sdk.cli import __version__
|
|
10
11
|
from unique_sdk.cli.commands.cite_file import cmd_cite_file
|
|
12
|
+
from unique_sdk.cli.commands.cite_file import (
|
|
13
|
+
is_error_output as _is_cite_error_output,
|
|
14
|
+
)
|
|
11
15
|
from unique_sdk.cli.commands.dynamic_frontend import (
|
|
12
16
|
cmd_dynamic_frontend_delete,
|
|
13
17
|
cmd_dynamic_frontend_deploy,
|
|
@@ -30,6 +34,9 @@ from unique_sdk.cli.commands.files import (
|
|
|
30
34
|
cmd_upload,
|
|
31
35
|
cmd_versions,
|
|
32
36
|
)
|
|
37
|
+
from unique_sdk.cli.commands.files import (
|
|
38
|
+
is_permission_denied_output as _is_permission_denied_output,
|
|
39
|
+
)
|
|
33
40
|
from unique_sdk.cli.commands.folders import cmd_mkdir, cmd_mvdir, cmd_rmdir
|
|
34
41
|
from unique_sdk.cli.commands.mcp import cmd_mcp
|
|
35
42
|
from unique_sdk.cli.commands.navigation import cmd_cd, cmd_ls, cmd_pwd
|
|
@@ -132,6 +139,25 @@ class LazyState:
|
|
|
132
139
|
return ctx.obj
|
|
133
140
|
|
|
134
141
|
|
|
142
|
+
def emit(
|
|
143
|
+
output: str,
|
|
144
|
+
*,
|
|
145
|
+
is_error: Callable[[str], bool] = _is_permission_denied_output,
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Print a command's *output* and exit non-zero when it is an error.
|
|
148
|
+
|
|
149
|
+
Errors (e.g. a scope denial) are written to stderr and raise
|
|
150
|
+
``SystemExit(1)`` so agent ``&&`` chains stop cleanly; successful output
|
|
151
|
+
goes to stdout. The default predicate matches the ``<cmd>: permission
|
|
152
|
+
denied`` shape shared by the scope-gated commands; pass ``is_error`` for
|
|
153
|
+
commands with their own error-detection helper (cite/read/search/...).
|
|
154
|
+
"""
|
|
155
|
+
if is_error(output):
|
|
156
|
+
click.echo(output, err=True)
|
|
157
|
+
raise SystemExit(1)
|
|
158
|
+
click.echo(output)
|
|
159
|
+
|
|
160
|
+
|
|
135
161
|
@click.group(invoke_without_command=True, help=MAIN_HELP)
|
|
136
162
|
@click.version_option(version=__version__, prog_name="unique-cli")
|
|
137
163
|
@click.pass_context
|
|
@@ -203,7 +229,8 @@ def ls(ctx: click.Context, target: str | None) -> None:
|
|
|
203
229
|
unique-cli ls /Reports List a specific path
|
|
204
230
|
unique-cli ls scope_abc List by scope ID
|
|
205
231
|
"""
|
|
206
|
-
|
|
232
|
+
output = cmd_ls(LazyState.get(ctx), target)
|
|
233
|
+
emit(output)
|
|
207
234
|
|
|
208
235
|
|
|
209
236
|
@main.command()
|
|
@@ -221,7 +248,8 @@ def mkdir(ctx: click.Context, name: str) -> None:
|
|
|
221
248
|
unique-cli mkdir Q2
|
|
222
249
|
unique-cli mkdir "2025/Q1"
|
|
223
250
|
"""
|
|
224
|
-
|
|
251
|
+
output = cmd_mkdir(LazyState.get(ctx), name)
|
|
252
|
+
emit(output)
|
|
225
253
|
|
|
226
254
|
|
|
227
255
|
@main.command()
|
|
@@ -246,7 +274,8 @@ def rmdir(ctx: click.Context, target: str, recursive: bool) -> None:
|
|
|
246
274
|
unique-cli rmdir /Reports/Q1 --recursive
|
|
247
275
|
unique-cli rmdir scope_abc123 -r
|
|
248
276
|
"""
|
|
249
|
-
|
|
277
|
+
output = cmd_rmdir(LazyState.get(ctx), target, recursive=recursive)
|
|
278
|
+
emit(output)
|
|
250
279
|
|
|
251
280
|
|
|
252
281
|
@main.command()
|
|
@@ -265,7 +294,8 @@ def mvdir(ctx: click.Context, old_name: str, new_name: str) -> None:
|
|
|
265
294
|
unique-cli mvdir Q1 "Q1-2025"
|
|
266
295
|
unique-cli mvdir scope_abc123 "New Name"
|
|
267
296
|
"""
|
|
268
|
-
|
|
297
|
+
output = cmd_mvdir(LazyState.get(ctx), old_name, new_name)
|
|
298
|
+
emit(output)
|
|
269
299
|
|
|
270
300
|
|
|
271
301
|
@main.command()
|
|
@@ -298,7 +328,8 @@ def upload(ctx: click.Context, local_path: str, destination: str | None) -> None
|
|
|
298
328
|
unique-cli upload ./report.pdf Q1/
|
|
299
329
|
unique-cli upload ./report.pdf /Archive/2025/
|
|
300
330
|
"""
|
|
301
|
-
|
|
331
|
+
output = cmd_upload(LazyState.get(ctx), local_path, destination)
|
|
332
|
+
emit(output)
|
|
302
333
|
|
|
303
334
|
|
|
304
335
|
@main.command()
|
|
@@ -324,7 +355,8 @@ def versions(
|
|
|
324
355
|
unique-cli versions /Reports/Q1/report.pdf
|
|
325
356
|
unique-cli versions cont_abc123 --take 10
|
|
326
357
|
"""
|
|
327
|
-
|
|
358
|
+
output = cmd_versions(LazyState.get(ctx), name_or_id, skip=skip, take=take)
|
|
359
|
+
emit(output)
|
|
328
360
|
|
|
329
361
|
|
|
330
362
|
@main.command(name="restore-version")
|
|
@@ -340,7 +372,8 @@ def restore_version(ctx: click.Context, content_version_id: str) -> None:
|
|
|
340
372
|
Examples:
|
|
341
373
|
unique-cli restore-version cver_abc123
|
|
342
374
|
"""
|
|
343
|
-
|
|
375
|
+
output = cmd_restore_version(LazyState.get(ctx), content_version_id)
|
|
376
|
+
emit(output)
|
|
344
377
|
|
|
345
378
|
|
|
346
379
|
@main.command()
|
|
@@ -365,7 +398,8 @@ def download(ctx: click.Context, name_or_id: str, local_dest: str | None) -> Non
|
|
|
365
398
|
unique-cli download annual.pdf ./downloads/
|
|
366
399
|
unique-cli download cont_abc123 ~/Desktop/
|
|
367
400
|
"""
|
|
368
|
-
|
|
401
|
+
output = cmd_download(LazyState.get(ctx), name_or_id, local_dest)
|
|
402
|
+
emit(output)
|
|
369
403
|
|
|
370
404
|
|
|
371
405
|
@main.command(name="cite")
|
|
@@ -414,7 +448,8 @@ def cite(
|
|
|
414
448
|
unique-cli cite cont_abc123 --pages 1-4 --read-method indexed
|
|
415
449
|
unique-cli cite notes.docx --read-method text
|
|
416
450
|
"""
|
|
417
|
-
|
|
451
|
+
output = cmd_cite_file(LazyState.get(ctx), name_or_id, pages, read_method)
|
|
452
|
+
emit(output, is_error=_is_cite_error_output)
|
|
418
453
|
|
|
419
454
|
|
|
420
455
|
@main.command(name="read")
|
|
@@ -492,10 +527,7 @@ def read_cmd(
|
|
|
492
527
|
to_page=to_page,
|
|
493
528
|
max_chars=max_chars,
|
|
494
529
|
)
|
|
495
|
-
|
|
496
|
-
click.echo(output, err=True)
|
|
497
|
-
raise SystemExit(1)
|
|
498
|
-
click.echo(output)
|
|
530
|
+
emit(output, is_error=_is_read_error_output)
|
|
499
531
|
|
|
500
532
|
|
|
501
533
|
@main.group(name="dynamic-frontend")
|
|
@@ -609,7 +641,8 @@ def rm(ctx: click.Context, name_or_id: str) -> None:
|
|
|
609
641
|
unique-cli rm /Reports/Q1/report.pdf
|
|
610
642
|
unique-cli rm cont_abc123
|
|
611
643
|
"""
|
|
612
|
-
|
|
644
|
+
output = cmd_rm(LazyState.get(ctx), name_or_id)
|
|
645
|
+
emit(output)
|
|
613
646
|
|
|
614
647
|
|
|
615
648
|
@main.command()
|
|
@@ -629,7 +662,8 @@ def mv(ctx: click.Context, old_name: str, new_name: str) -> None:
|
|
|
629
662
|
unique-cli mv /Reports/Q1/annual.pdf annual-2025.pdf
|
|
630
663
|
unique-cli mv cont_abc123 "New Title.pdf"
|
|
631
664
|
"""
|
|
632
|
-
|
|
665
|
+
output = cmd_mv_file(LazyState.get(ctx), old_name, new_name)
|
|
666
|
+
emit(output)
|
|
633
667
|
|
|
634
668
|
|
|
635
669
|
@main.command()
|
|
@@ -638,7 +672,11 @@ def mv(ctx: click.Context, old_name: str, new_name: str) -> None:
|
|
|
638
672
|
"--folder",
|
|
639
673
|
"-f",
|
|
640
674
|
default=None,
|
|
641
|
-
help=
|
|
675
|
+
help=(
|
|
676
|
+
"Restrict search to a folder (path, name, or scope ID). Without an "
|
|
677
|
+
"active task scope, defaults to the current directory; with one, the "
|
|
678
|
+
"task scope is the boundary and --folder ANDs an extra constraint."
|
|
679
|
+
),
|
|
642
680
|
)
|
|
643
681
|
@click.option(
|
|
644
682
|
"--metadata",
|
|
@@ -668,9 +706,12 @@ def search(
|
|
|
668
706
|
both semantic similarity and keyword matching.
|
|
669
707
|
|
|
670
708
|
\b
|
|
671
|
-
|
|
672
|
-
to 200 results
|
|
673
|
-
|
|
709
|
+
Without an active task scope, searches within the current directory
|
|
710
|
+
scope with up to 200 results; use --folder to target a different
|
|
711
|
+
folder. When the task defines a per-message scope, that scope is the
|
|
712
|
+
search boundary regardless of the current directory, and --folder
|
|
713
|
+
ANDs an additional constraint. Use --metadata to filter by custom
|
|
714
|
+
metadata fields.
|
|
674
715
|
|
|
675
716
|
\b
|
|
676
717
|
Examples:
|
{unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/cite_file.py
RENAMED
|
@@ -131,12 +131,27 @@ def _resolve_content_id_with_manifest(
|
|
|
131
131
|
"""Resolve a filename/content_id checking the chat-files manifest first.
|
|
132
132
|
|
|
133
133
|
Resolution order:
|
|
134
|
-
1. If name_or_id starts with "cont_",
|
|
134
|
+
1. If name_or_id starts with "cont_", resolve its title via the API so the
|
|
135
|
+
citation renders with the document filename, not the opaque id.
|
|
135
136
|
2. Check .unique/chat-files.json for a matching filename (exact or basename).
|
|
136
137
|
3. Fall back to KB resolution via _resolve_content_id.
|
|
137
138
|
"""
|
|
138
139
|
if name_or_id.startswith("cont_"):
|
|
139
|
-
|
|
140
|
+
# Gate the id *before* resolving its title, exactly like read's cont_
|
|
141
|
+
# fast-path (_resolve_content_id): resolve_content_title hits
|
|
142
|
+
# Content.get_info, so checking scope afterwards would probe the KB
|
|
143
|
+
# ahead of denial (a cross-scope existence/title oracle) and — without
|
|
144
|
+
# a per-message filter — skip the static scopeIds boundary that read
|
|
145
|
+
# enforces. is_content_within_workspace covers both the per-message
|
|
146
|
+
# filter and the static scope, and keeps the read-only chat-attachment
|
|
147
|
+
# exemption. See UN-21780.
|
|
148
|
+
if not state.is_content_within_workspace(name_or_id):
|
|
149
|
+
raise ValueError(
|
|
150
|
+
f"permission denied: {name_or_id} is outside your task scope "
|
|
151
|
+
f"({state.scope_denial_hint()}). Only cite documents within that "
|
|
152
|
+
"scope or files attached to this chat."
|
|
153
|
+
)
|
|
154
|
+
return name_or_id, state.resolve_content_title(name_or_id)
|
|
140
155
|
|
|
141
156
|
manifest_path = Path.cwd() / _CHAT_FILES_MANIFEST
|
|
142
157
|
if manifest_path.is_file():
|
|
@@ -181,6 +196,20 @@ def cmd_cite_file(
|
|
|
181
196
|
except Exception as exc:
|
|
182
197
|
return f"{CITE_ERROR_PREFIX} {exc}"
|
|
183
198
|
|
|
199
|
+
# When a per-message KB scope filter is active (e.g. an Agentic Table
|
|
200
|
+
# column's scope_rules), don't let the agent cite documents outside it.
|
|
201
|
+
# Chat-attached files are exempt — is_content_within_workspace allows them.
|
|
202
|
+
# Static-scope (no per-message filter) cite behaviour is left unchanged.
|
|
203
|
+
if (
|
|
204
|
+
state.workspace_metadata_filter is not None
|
|
205
|
+
and not state.is_content_within_workspace(content_id)
|
|
206
|
+
):
|
|
207
|
+
return (
|
|
208
|
+
f"{CITE_ERROR_PREFIX} permission denied: {content_id} is outside your "
|
|
209
|
+
f"task scope ({state.scope_denial_hint()}). Only cite documents within "
|
|
210
|
+
"that scope or files attached to this chat."
|
|
211
|
+
)
|
|
212
|
+
|
|
184
213
|
if pages and pages.strip() and _is_non_paginated(filename):
|
|
185
214
|
suffix = Path(filename).suffix.lower()
|
|
186
215
|
return (
|
|
@@ -253,3 +282,13 @@ def cmd_cite_file(
|
|
|
253
282
|
return f"{CITE_ERROR_PREFIX} {exc}"
|
|
254
283
|
|
|
255
284
|
return "\n".join(output_lines)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def is_error_output(output: str) -> bool:
|
|
288
|
+
"""Return ``True`` when *output* is an error message from ``cmd_cite_file``.
|
|
289
|
+
|
|
290
|
+
Lets the one-shot dispatcher exit non-zero (so shell ``&&`` chains stop) on
|
|
291
|
+
any cite failure — invalid pages, missing file, or an out-of-scope denial.
|
|
292
|
+
Mirrors ``read.is_error_output``.
|
|
293
|
+
"""
|
|
294
|
+
return output.startswith(CITE_ERROR_PREFIX)
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import mimetypes
|
|
6
|
+
import re
|
|
6
7
|
import shutil
|
|
7
8
|
from collections.abc import Mapping, Sequence
|
|
8
9
|
from pathlib import Path
|
|
@@ -13,6 +14,19 @@ from unique_sdk.cli.formatting import format_content_info
|
|
|
13
14
|
from unique_sdk.cli.state import ShellState
|
|
14
15
|
from unique_sdk.utils.file_io import download_content, upload_file
|
|
15
16
|
|
|
17
|
+
# A denial result has the shape ``<command>: permission denied[…]`` (the
|
|
18
|
+
# command token is lowercase, e.g. ``upload``/``ls``/``restore-version``) and
|
|
19
|
+
# is always the *first thing* in the result string — each command returns the
|
|
20
|
+
# denial verbatim (e.g. ``"upload: permission denied: …"``, ``"read:
|
|
21
|
+
# permission denied: …"``). Anchor at the start of the string only: with
|
|
22
|
+
# re.MULTILINE, ``^`` would match the start of any line, so a multi-line
|
|
23
|
+
# *success* output containing a line shaped like ``token: permission denied``
|
|
24
|
+
# mid-result (e.g. quoted document text) would trigger a false non-zero exit.
|
|
25
|
+
# Successful results also start with a capitalised past-tense verb ("Uploaded:",
|
|
26
|
+
# "Downloaded:", "Renamed", "Restored:"), so the lowercase-prefix anchor never
|
|
27
|
+
# misfires on them. See UN-21780.
|
|
28
|
+
_PERMISSION_DENIED_RE = re.compile(r"^[a-z][a-z0-9-]*: permission denied")
|
|
29
|
+
|
|
16
30
|
_SUPPORTED_UPLOAD_MIME_TYPES = {
|
|
17
31
|
".pdf": "application/pdf",
|
|
18
32
|
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
@@ -42,15 +56,25 @@ def _normalize_unique_file_path(cwd: str, path: str) -> str:
|
|
|
42
56
|
return "/" + "/".join(parts)
|
|
43
57
|
|
|
44
58
|
|
|
45
|
-
def _resolve_content_id(
|
|
59
|
+
def _resolve_content_id(
|
|
60
|
+
state: ShellState, name_or_id: str, *, allow_chat_files: bool = True
|
|
61
|
+
) -> tuple[str, str]:
|
|
46
62
|
"""Resolve a file name or content ID to (content_id, display_name).
|
|
47
63
|
|
|
48
64
|
Accepts a content ID (cont_...), a file name in the current folder,
|
|
49
|
-
or an absolute/relative Unique file path.
|
|
65
|
+
or an absolute/relative Unique file path. Pass ``allow_chat_files=False``
|
|
66
|
+
from destructive ops so the chat-attachment exemption (read-only intent)
|
|
67
|
+
can't be used to delete/rename out-of-scope content. See UN-21780.
|
|
50
68
|
"""
|
|
51
69
|
if name_or_id.startswith("cont_"):
|
|
52
|
-
if not state.is_content_within_workspace(
|
|
53
|
-
|
|
70
|
+
if not state.is_content_within_workspace(
|
|
71
|
+
name_or_id, allow_chat_files=allow_chat_files
|
|
72
|
+
):
|
|
73
|
+
raise ValueError(
|
|
74
|
+
f"permission denied: {name_or_id} is outside your task scope "
|
|
75
|
+
f"({state.scope_denial_hint()}). Use 'unique-cli search' or "
|
|
76
|
+
"'ls' within that scope instead."
|
|
77
|
+
)
|
|
54
78
|
return name_or_id, name_or_id
|
|
55
79
|
|
|
56
80
|
lookup_name = name_or_id
|
|
@@ -62,7 +86,32 @@ def _resolve_content_id(state: ShellState, name_or_id: str) -> tuple[str, str]:
|
|
|
62
86
|
raise ValueError(f"File path must include a file name: {name_or_id}")
|
|
63
87
|
if not folder_path:
|
|
64
88
|
folder_path = "/"
|
|
65
|
-
|
|
89
|
+
# An active per-message filter replaces the static scopeIds for content
|
|
90
|
+
# access. Gate the *folder* against the navigable filter scope before
|
|
91
|
+
# Folder.get_info + the name scan: deferring solely to the per-id gate
|
|
92
|
+
# lets a path into an out-of-scope folder resolve the file and surface a
|
|
93
|
+
# task-scope "permission denied" instead of "File not found", leaking
|
|
94
|
+
# cross-boundary existence — the same oracle closed for bare filenames
|
|
95
|
+
# at root. The path check is structural (no API call), so it denies
|
|
96
|
+
# uniformly whether or not the file exists. See UN-21780.
|
|
97
|
+
if state.workspace_metadata_filter is not None:
|
|
98
|
+
# Only gate the folder when the filter actually constrains folders.
|
|
99
|
+
# A pure contentId scope has no navigable folders, so the document
|
|
100
|
+
# may live anywhere and the per-id gate below is authoritative —
|
|
101
|
+
# gating on folder here would wrongly deny it. When the filter does
|
|
102
|
+
# name folders, a path outside them is denied structurally (no API
|
|
103
|
+
# call), so an out-of-scope file can't leak existence via the per-id
|
|
104
|
+
# "permission denied" vs "File not found" distinction.
|
|
105
|
+
if (
|
|
106
|
+
state.navigable_folder_ids()
|
|
107
|
+
and not state.folder_path_allowed_by_metadata_filter(folder_path)
|
|
108
|
+
):
|
|
109
|
+
raise ValueError(
|
|
110
|
+
f"permission denied: {name_or_id} is outside your task scope "
|
|
111
|
+
f"({state.scope_denial_hint()}). Use 'unique-cli search' or "
|
|
112
|
+
"'ls' within that scope instead."
|
|
113
|
+
)
|
|
114
|
+
elif not state.is_folder_target_within_workspace(folder_path):
|
|
66
115
|
raise ValueError("permission denied (outside workspace scope)")
|
|
67
116
|
|
|
68
117
|
info = unique_sdk.Folder.get_info(
|
|
@@ -73,8 +122,26 @@ def _resolve_content_id(state: ShellState, name_or_id: str) -> tuple[str, str]:
|
|
|
73
122
|
scope_id = info.get("id")
|
|
74
123
|
if not scope_id:
|
|
75
124
|
raise ValueError(f"folder not found: {folder_path}")
|
|
76
|
-
elif
|
|
77
|
-
|
|
125
|
+
elif scope_id is None:
|
|
126
|
+
# A bare file name with no folder context resolves via an *unparented*
|
|
127
|
+
# Content.get_infos scan across the whole knowledge base.
|
|
128
|
+
if state.workspace_metadata_filter is not None:
|
|
129
|
+
# With a per-message filter active, a whole-KB filename scan would
|
|
130
|
+
# discover documents outside the task scope before the per-id gate
|
|
131
|
+
# runs — and the deny-vs-not-found distinction is an existence/title
|
|
132
|
+
# oracle across the task boundary. That is broader than the
|
|
133
|
+
# ls/read-at-root gating intent, so require a bounded context
|
|
134
|
+
# instead. The cont_ fast-path, an explicit in-scope path, or a
|
|
135
|
+
# ``cd`` into an in-scope folder all still work. See UN-21780.
|
|
136
|
+
raise ValueError(
|
|
137
|
+
f"permission denied: {name_or_id} can't be resolved by name at "
|
|
138
|
+
f"the knowledge-base root while a task scope is active "
|
|
139
|
+
f"({state.scope_denial_hint()}). Use 'unique-cli search', cite "
|
|
140
|
+
"by content id, or 'cd' into an in-scope folder."
|
|
141
|
+
)
|
|
142
|
+
if not state.is_within_workspace():
|
|
143
|
+
# No per-message filter: defer to the static scopeIds boundary.
|
|
144
|
+
raise ValueError("permission denied (outside workspace scope)")
|
|
78
145
|
|
|
79
146
|
params: dict[str, Any] = {}
|
|
80
147
|
if scope_id:
|
|
@@ -98,7 +165,22 @@ def _resolve_content_id(state: ShellState, name_or_id: str) -> tuple[str, str]:
|
|
|
98
165
|
title = info.get("title") or ""
|
|
99
166
|
key = info.get("key") or ""
|
|
100
167
|
if lookup_name in {title, key}:
|
|
101
|
-
|
|
168
|
+
resolved_id = info["id"]
|
|
169
|
+
# Gate the *resolved* id too: resolving by file name or path
|
|
170
|
+
# must not bypass the per-message metadata-filter scope that
|
|
171
|
+
# the cont_ fast-path above enforces, and must honour the same
|
|
172
|
+
# read-only chat-file exemption (so rm/mv by name can't reach
|
|
173
|
+
# an out-of-scope attachment either). See UN-21780.
|
|
174
|
+
if not state.is_content_within_workspace(
|
|
175
|
+
resolved_id, allow_chat_files=allow_chat_files
|
|
176
|
+
):
|
|
177
|
+
raise ValueError(
|
|
178
|
+
f"permission denied: {name_or_id} is outside your "
|
|
179
|
+
f"task scope ({state.scope_denial_hint()}). Use "
|
|
180
|
+
"'unique-cli search' or 'ls' within that scope "
|
|
181
|
+
"instead."
|
|
182
|
+
)
|
|
183
|
+
return resolved_id, title or key
|
|
102
184
|
|
|
103
185
|
skip += len(content_infos)
|
|
104
186
|
|
|
@@ -221,7 +303,14 @@ def cmd_upload(
|
|
|
221
303
|
upload file.pdf /abs/path/ -> into absolute path folder
|
|
222
304
|
"""
|
|
223
305
|
dest = destination or "."
|
|
224
|
-
|
|
306
|
+
# A per-message metaDataFilter *replaces* the static scopeIds (read/search/
|
|
307
|
+
# ls honour the filter, not scopeIds), so the static-scope check must not
|
|
308
|
+
# over-deny an in-filter destination. When a filter is active the resolved
|
|
309
|
+
# scope id is gated against it below instead. See UN-21780.
|
|
310
|
+
if (
|
|
311
|
+
state.workspace_metadata_filter is None
|
|
312
|
+
and not state.is_folder_target_within_workspace(dest)
|
|
313
|
+
):
|
|
225
314
|
return "upload: permission denied (outside workspace scope)"
|
|
226
315
|
try:
|
|
227
316
|
path = Path(local_path).expanduser().resolve()
|
|
@@ -234,6 +323,21 @@ def cmd_upload(
|
|
|
234
323
|
destination,
|
|
235
324
|
)
|
|
236
325
|
|
|
326
|
+
# is_folder_target_within_workspace only enforces the static
|
|
327
|
+
# scopeIds; when the runner supplies a per-message metaDataFilter
|
|
328
|
+
# without scopeIds it treats every folder as writable. Gate the
|
|
329
|
+
# resolved destination against the per-message filter too, so uploads
|
|
330
|
+
# can't escape a task scope that read/download/ls/search honor.
|
|
331
|
+
# See UN-21780.
|
|
332
|
+
if (
|
|
333
|
+
state.workspace_metadata_filter is not None
|
|
334
|
+
and not state.folder_allowed_by_metadata_filter(scope_id)
|
|
335
|
+
):
|
|
336
|
+
return (
|
|
337
|
+
"upload: permission denied: destination is outside your "
|
|
338
|
+
f"task scope ({state.scope_denial_hint()})."
|
|
339
|
+
)
|
|
340
|
+
|
|
237
341
|
mime_type = _detect_upload_mime_type(path)
|
|
238
342
|
|
|
239
343
|
result = upload_file(
|
|
@@ -286,6 +390,19 @@ def cmd_versions(
|
|
|
286
390
|
|
|
287
391
|
def cmd_restore_version(state: ShellState, content_version_id: str) -> str:
|
|
288
392
|
"""Restore a file from an archived content version ID."""
|
|
393
|
+
# A per-message metaDataFilter is a hard task boundary, but the restore
|
|
394
|
+
# API only resolves a contentVersionId to its content *after* mutating,
|
|
395
|
+
# so an out-of-scope version can't be screened beforehand. Deny while a
|
|
396
|
+
# filter is active rather than allow an unverifiable mutation; reads stay
|
|
397
|
+
# gated regardless. The filter replaces the static scope for the turn, so
|
|
398
|
+
# check it *before* the static is_within_workspace() fallback — otherwise
|
|
399
|
+
# an out-of-static-scope cwd would surface the static denial (no task-scope
|
|
400
|
+
# hint) even though the filter is authoritative. See UN-21780.
|
|
401
|
+
if state.workspace_metadata_filter is not None:
|
|
402
|
+
return (
|
|
403
|
+
"restore-version: permission denied: cannot verify the target is "
|
|
404
|
+
f"within your task scope ({state.scope_denial_hint()})."
|
|
405
|
+
)
|
|
289
406
|
if not state.is_within_workspace():
|
|
290
407
|
return "restore-version: permission denied (outside workspace scope)"
|
|
291
408
|
try:
|
|
@@ -338,13 +455,13 @@ def cmd_download(
|
|
|
338
455
|
|
|
339
456
|
def cmd_rm(state: ShellState, name_or_id: str) -> str:
|
|
340
457
|
"""Delete a file by name or content ID."""
|
|
341
|
-
if name_or_id.startswith("cont_"):
|
|
342
|
-
if not state.is_content_within_workspace(name_or_id):
|
|
343
|
-
return "rm: permission denied (outside workspace scope)"
|
|
344
|
-
elif not state.is_within_workspace():
|
|
345
|
-
return "rm: permission denied (outside workspace scope)"
|
|
346
458
|
try:
|
|
347
|
-
|
|
459
|
+
# Destructive: allow_chat_files=False so the chat-attachment read
|
|
460
|
+
# exemption can't be used to delete an out-of-scope file. Resolution
|
|
461
|
+
# also surfaces the task-scope hint on denial. See UN-21780.
|
|
462
|
+
content_id, display_name = _resolve_content_id(
|
|
463
|
+
state, name_or_id, allow_chat_files=False
|
|
464
|
+
)
|
|
348
465
|
unique_sdk.Content.delete(
|
|
349
466
|
user_id=state.config.user_id,
|
|
350
467
|
company_id=state.config.company_id,
|
|
@@ -357,13 +474,13 @@ def cmd_rm(state: ShellState, name_or_id: str) -> str:
|
|
|
357
474
|
|
|
358
475
|
def cmd_mv_file(state: ShellState, old_name: str, new_name: str) -> str:
|
|
359
476
|
"""Rename a file."""
|
|
360
|
-
if old_name.startswith("cont_"):
|
|
361
|
-
if not state.is_content_within_workspace(old_name):
|
|
362
|
-
return "mv: permission denied (outside workspace scope)"
|
|
363
|
-
elif not state.is_within_workspace():
|
|
364
|
-
return "mv: permission denied (outside workspace scope)"
|
|
365
477
|
try:
|
|
366
|
-
|
|
478
|
+
# Destructive: allow_chat_files=False so the chat-attachment read
|
|
479
|
+
# exemption can't be used to rename an out-of-scope file. Resolution
|
|
480
|
+
# also surfaces the task-scope hint on denial. See UN-21780.
|
|
481
|
+
content_id, display_name = _resolve_content_id(
|
|
482
|
+
state, old_name, allow_chat_files=False
|
|
483
|
+
)
|
|
367
484
|
result = unique_sdk.Content.update(
|
|
368
485
|
user_id=state.config.user_id,
|
|
369
486
|
company_id=state.config.company_id,
|
|
@@ -373,3 +490,19 @@ def cmd_mv_file(state: ShellState, old_name: str, new_name: str) -> str:
|
|
|
373
490
|
return f"Renamed: {display_name} -> {result.get('title', new_name)}\n{format_content_info(result)}"
|
|
374
491
|
except (ValueError, unique_sdk.APIError) as e:
|
|
375
492
|
return f"mv: {e}"
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def is_permission_denied_output(output: str) -> bool:
|
|
496
|
+
"""Return ``True`` when a file-op result is a permission/scope denial.
|
|
497
|
+
|
|
498
|
+
Lets the one-shot dispatcher exit non-zero so shell ``&&`` chains stop on
|
|
499
|
+
an out-of-scope content access instead of continuing as if it succeeded.
|
|
500
|
+
|
|
501
|
+
Matches the denial only when it is the *form of the result* — a
|
|
502
|
+
``<command>: permission denied`` line — rather than anywhere the substring
|
|
503
|
+
appears. Denials are emitted as ``"<cmd>: permission denied[ : …]"`` (e.g.
|
|
504
|
+
``"upload: permission denied: …"``); anchoring on that shape avoids a
|
|
505
|
+
false non-zero exit when a successful result happens to contain the phrase
|
|
506
|
+
(e.g. a filename or document text). See UN-21780.
|
|
507
|
+
"""
|
|
508
|
+
return bool(_PERMISSION_DENIED_RE.search(output))
|