ndi-cli 0.4.0__py3-none-any.whl
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.
- ndi_cli/__init__.py +9 -0
- ndi_cli/_cli.py +166 -0
- ndi_cli/_config.py +94 -0
- ndi_cli/_docops.py +443 -0
- ndi_cli/_jobs.py +106 -0
- ndi_cli/_local.py +130 -0
- ndi_cli/_login.py +47 -0
- ndi_cli/_output.py +128 -0
- ndi_cli/_render.py +679 -0
- ndi_cli/_sources.py +154 -0
- ndi_cli/_tools.py +210 -0
- ndi_cli/_workspace.py +226 -0
- ndi_cli/commands.py +23 -0
- ndi_cli-0.4.0.dist-info/METADATA +86 -0
- ndi_cli-0.4.0.dist-info/RECORD +18 -0
- ndi_cli-0.4.0.dist-info/WHEEL +4 -0
- ndi_cli-0.4.0.dist-info/entry_points.txt +3 -0
- ndi_cli-0.4.0.dist-info/licenses/LICENSE +21 -0
ndi_cli/_sources.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Turn a CLI source spec into a typed ``DocumentSource``.
|
|
2
|
+
|
|
3
|
+
A spec is a local path, a directory of supported files, an HTTPS URL, a staged
|
|
4
|
+
upload handle, a prior parse job, a workspace file, or ``-`` (stdin).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
import uuid
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from ndi_sdk.client import NdiClient
|
|
15
|
+
from ndi_sdk.models.common import DocumentSource, ParseResultSource, UploadSource, UrlSource, WorkspaceFileSource
|
|
16
|
+
|
|
17
|
+
JOBID_PREFIX = "jobid://"
|
|
18
|
+
UPLOAD_PREFIX = "ndi://upload/"
|
|
19
|
+
WS_PREFIX = "ws://"
|
|
20
|
+
_UUID = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
|
|
21
|
+
SUPPORTED_SUFFIXES = frozenset(
|
|
22
|
+
{
|
|
23
|
+
".pdf",
|
|
24
|
+
".png",
|
|
25
|
+
".jpg",
|
|
26
|
+
".jpeg",
|
|
27
|
+
".tif",
|
|
28
|
+
".tiff",
|
|
29
|
+
".webp",
|
|
30
|
+
".doc",
|
|
31
|
+
".docx",
|
|
32
|
+
".ppt",
|
|
33
|
+
".pptx",
|
|
34
|
+
".xls",
|
|
35
|
+
".xlsx",
|
|
36
|
+
".xlsm",
|
|
37
|
+
".xlsb",
|
|
38
|
+
".ods",
|
|
39
|
+
".csv",
|
|
40
|
+
".tsv",
|
|
41
|
+
".parquet",
|
|
42
|
+
".jsonl",
|
|
43
|
+
".ndjson",
|
|
44
|
+
".json",
|
|
45
|
+
".wav",
|
|
46
|
+
".mp3",
|
|
47
|
+
".m4a",
|
|
48
|
+
".mp4",
|
|
49
|
+
".mov",
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SourceError(ValueError):
|
|
55
|
+
"""The source spec could not be resolved."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def collect_specs(spec: str) -> list[str]:
|
|
59
|
+
"""Expand a directory into supported files; leave every other spec alone."""
|
|
60
|
+
if _is_handle(spec) or spec == "-":
|
|
61
|
+
return [spec]
|
|
62
|
+
# An empty spec is Path("") -> ".", which would recursively sweep and upload
|
|
63
|
+
# the whole working directory.
|
|
64
|
+
if not spec.strip():
|
|
65
|
+
raise SourceError("SOURCE is empty; pass a file, directory, URL, ndi://, jobid://, ws://, or -")
|
|
66
|
+
path = Path(spec)
|
|
67
|
+
if path.is_dir():
|
|
68
|
+
children = [child for child in path.rglob("*") if child.is_file()]
|
|
69
|
+
found = sorted(str(child) for child in children if child.suffix.lower() in SUPPORTED_SUFFIXES)
|
|
70
|
+
if not found:
|
|
71
|
+
raise SourceError(f"no supported files under {spec}")
|
|
72
|
+
# Silence here reads as "everything was processed"; say what was left out.
|
|
73
|
+
if skipped := len(children) - len(found):
|
|
74
|
+
print(f"note: skipped {skipped} unsupported file(s) under {spec}", file=sys.stderr)
|
|
75
|
+
return found
|
|
76
|
+
return [spec]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def resolve_source(
|
|
80
|
+
spec: str,
|
|
81
|
+
*,
|
|
82
|
+
client: NdiClient,
|
|
83
|
+
workspace_id: str | None = None,
|
|
84
|
+
file_name: str | None = None,
|
|
85
|
+
allow_parse_result: bool = True,
|
|
86
|
+
stdin=None,
|
|
87
|
+
) -> DocumentSource:
|
|
88
|
+
"""Map one spec onto the document-operation source union."""
|
|
89
|
+
if spec.startswith(JOBID_PREFIX) or _UUID.match(spec or ""):
|
|
90
|
+
if not allow_parse_result:
|
|
91
|
+
raise SourceError("classify cannot reuse a parse job; pass a file, URL, upload, or workspace file")
|
|
92
|
+
raw = spec.removeprefix(JOBID_PREFIX)
|
|
93
|
+
return ParseResultSource(job_id=_uuid(raw, "jobid://"))
|
|
94
|
+
if spec.startswith(UPLOAD_PREFIX):
|
|
95
|
+
return UploadSource(upload_id=_uuid(spec.removeprefix(UPLOAD_PREFIX), "ndi://upload/"))
|
|
96
|
+
if spec.startswith(WS_PREFIX):
|
|
97
|
+
if not workspace_id:
|
|
98
|
+
raise SourceError("ws:// sources need a workspace: set $NDI_WORKSPACE_ID or pass --workspace")
|
|
99
|
+
return WorkspaceFileSource(
|
|
100
|
+
workspace_id=_uuid(workspace_id, "workspace"), file_id=_uuid(spec.removeprefix(WS_PREFIX), "ws://")
|
|
101
|
+
)
|
|
102
|
+
if spec.startswith(("https://", "http://")):
|
|
103
|
+
name = file_name or Path(spec.split("?", 1)[0]).name or "document"
|
|
104
|
+
return UrlSource(url=spec, file_name=name)
|
|
105
|
+
if spec == "-":
|
|
106
|
+
return _from_stdin(client, file_name=file_name, allow_parse_result=allow_parse_result, stdin=stdin)
|
|
107
|
+
path = Path(spec)
|
|
108
|
+
if not path.is_file():
|
|
109
|
+
raise SourceError(f"file not found: {spec}")
|
|
110
|
+
try:
|
|
111
|
+
upload = client.documents.create_upload(path, file_name=file_name or path.name)
|
|
112
|
+
except OSError as exc:
|
|
113
|
+
# Unreadable / vanished local files are usage errors, not crashes.
|
|
114
|
+
raise SourceError(f"cannot read {spec}: {exc.strerror or exc}") from None
|
|
115
|
+
return UploadSource(upload_id=upload.upload_id)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _from_stdin(client: NdiClient, *, file_name: str | None, allow_parse_result: bool, stdin) -> DocumentSource:
|
|
119
|
+
stream = stdin if stdin is not None else sys.stdin.buffer
|
|
120
|
+
raw = stream.read()
|
|
121
|
+
if not raw:
|
|
122
|
+
raise SourceError("stdin is empty; pipe a job id or file bytes")
|
|
123
|
+
text = _as_handle_line(raw)
|
|
124
|
+
if text is not None:
|
|
125
|
+
return resolve_source(text, client=client, allow_parse_result=allow_parse_result, file_name=file_name)
|
|
126
|
+
if not file_name:
|
|
127
|
+
raise SourceError("stdin bytes need --file-name (or pipe a jobid:// / uuid line)")
|
|
128
|
+
upload = client.documents.create_upload(raw, file_name=file_name)
|
|
129
|
+
return UploadSource(upload_id=upload.upload_id)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _as_handle_line(raw: bytes) -> str | None:
|
|
133
|
+
if b"\0" in raw:
|
|
134
|
+
return None
|
|
135
|
+
try:
|
|
136
|
+
text = raw.decode().strip()
|
|
137
|
+
except UnicodeDecodeError:
|
|
138
|
+
return None
|
|
139
|
+
if "\n" in text:
|
|
140
|
+
return None
|
|
141
|
+
if text.startswith(JOBID_PREFIX) or text.startswith(UPLOAD_PREFIX) or text.startswith(WS_PREFIX) or _UUID.match(text):
|
|
142
|
+
return text
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _is_handle(spec: str) -> bool:
|
|
147
|
+
return spec.startswith(("https://", "http://", JOBID_PREFIX, UPLOAD_PREFIX, WS_PREFIX)) or bool(_UUID.match(spec))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _uuid(value: str, kind: str) -> uuid.UUID:
|
|
151
|
+
try:
|
|
152
|
+
return uuid.UUID(value)
|
|
153
|
+
except ValueError as exc:
|
|
154
|
+
raise SourceError(f"bad {kind} id: {value!r}") from exc
|
ndi_cli/_tools.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""The six workspace-tool verbs. Shared with the in-sandbox agent CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from ndi_sdk.resources.tools import SyncTools
|
|
9
|
+
|
|
10
|
+
from ndi_cli import _render
|
|
11
|
+
from ndi_cli.commands import WorkspaceCommand
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser) -> None:
|
|
15
|
+
folder = sub.add_parser(
|
|
16
|
+
WorkspaceCommand.FOLDER_METADATA,
|
|
17
|
+
parents=[common],
|
|
18
|
+
help="Orient in one directory: counts, category mix, one page of files.",
|
|
19
|
+
description=(
|
|
20
|
+
"Progressive exploration: call on / first (non-recursive), read the subdirectory summaries, then descend "
|
|
21
|
+
"or narrow. Any narrow implies recursion over the subtree unless --recursive/--no-recursive is explicit."
|
|
22
|
+
),
|
|
23
|
+
)
|
|
24
|
+
folder.add_argument("directory", nargs="?", default="/", help="Directory to describe (default /).")
|
|
25
|
+
recursion = folder.add_mutually_exclusive_group()
|
|
26
|
+
recursion.add_argument("--recursive", dest="recursive", action="store_true", default=None, help="Whole-subtree census.")
|
|
27
|
+
recursion.add_argument(
|
|
28
|
+
"--no-recursive", dest="recursive", action="store_false", help="This directory only, even when narrowed."
|
|
29
|
+
)
|
|
30
|
+
folder.add_argument("--category", default=None, help="Exact category name from a previous response.")
|
|
31
|
+
folder.add_argument("--status", default=None, metavar="STATUS", help="Ingestion status narrow (e.g. failed).")
|
|
32
|
+
folder.add_argument("--kind", default=None, help="Ingestion lane narrow: table, document, or media.")
|
|
33
|
+
folder.add_argument("--name-contains", default=None, metavar="TEXT", help="Case-insensitive filename/path substring.")
|
|
34
|
+
folder.add_argument("--page-size", type=int, default=100, help="Listing page size (default 100).")
|
|
35
|
+
folder.add_argument("--start-after", default=None, metavar="PATH", help="Resume the listing after this path.")
|
|
36
|
+
folder.add_argument(
|
|
37
|
+
"--tables",
|
|
38
|
+
dest="include_tables",
|
|
39
|
+
action="store_true",
|
|
40
|
+
help=(
|
|
41
|
+
"Add the table census: every queryable tab in scope (rows x columns, sql table name) plus "
|
|
42
|
+
"shared-column join hints (unverified). Scope with a directory or name filter; implies --recursive. "
|
|
43
|
+
"Read file-metadata for column schemas before writing SQL."
|
|
44
|
+
),
|
|
45
|
+
)
|
|
46
|
+
folder.set_defaults(
|
|
47
|
+
run=_run_folder_metadata, render=_render.folder_metadata, family="tool", needs_workspace=True, needs_client=True
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
file_meta = sub.add_parser(
|
|
51
|
+
WorkspaceCommand.FILE_METADATA,
|
|
52
|
+
parents=[common],
|
|
53
|
+
help="What is inside one uploaded file, without opening it.",
|
|
54
|
+
description=(
|
|
55
|
+
"Components come back with sizes, extents, summaries, readable/queryable flags saying which content "
|
|
56
|
+
"tool applies (readable suits read-file; queryable tabs suit run-sql), and each queryable tab's "
|
|
57
|
+
"sql table name and column schema (names, SQL types, statistics) for writing run-sql directly."
|
|
58
|
+
),
|
|
59
|
+
)
|
|
60
|
+
file_meta.add_argument("path", help="Uploaded source path (never a derivative).")
|
|
61
|
+
file_meta.add_argument(
|
|
62
|
+
"--component", default=None, help="Describe only this tab or section, using its public component name."
|
|
63
|
+
)
|
|
64
|
+
file_meta.set_defaults(
|
|
65
|
+
run=_run_file_metadata, render=_render.file_metadata, family="tool", needs_workspace=True, needs_client=True
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
read = sub.add_parser(
|
|
69
|
+
WorkspaceCommand.READ_FILE,
|
|
70
|
+
parents=[common],
|
|
71
|
+
help="Print the text of one uploaded file.",
|
|
72
|
+
description=(
|
|
73
|
+
"Content goes to stdout; a one-line footer (pages, components returned/omitted, truncation) goes to "
|
|
74
|
+
"stderr. Scope with --component (names from file-metadata) or --pages; images are refused — use ask-file."
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
read.add_argument("path", help="Uploaded source path.")
|
|
78
|
+
read.add_argument("--component", default=None, help="One tab or section, named as file-metadata lists it.")
|
|
79
|
+
read.add_argument("--pages", default=None, metavar="SPEC", help="1-based pages, e.g. 3 or 1-5,8. Not for spreadsheets.")
|
|
80
|
+
read.add_argument("--max-chars", type=int, default=None, help="Character budget for the returned text.")
|
|
81
|
+
read.set_defaults(run=_run_read_file, render=_render_read_file, family="tool", needs_workspace=True, needs_client=True)
|
|
82
|
+
|
|
83
|
+
ask_file = sub.add_parser(
|
|
84
|
+
WorkspaceCommand.ASK_FILE,
|
|
85
|
+
parents=[common],
|
|
86
|
+
help="Ask a question of one document, media, or image file.",
|
|
87
|
+
description=(
|
|
88
|
+
"Slow but cited: the engine follows what the file is (vision for images, a recursive reader for long "
|
|
89
|
+
"text, a QA agent for multi-part files). A whole table file is refused — query its rows with "
|
|
90
|
+
"run-sql; to ask about one tab's rendered text, pass --component <tab>."
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
ask_file.add_argument("path", help="Uploaded source path.")
|
|
94
|
+
ask_file.add_argument("query", help="The question, in natural language.")
|
|
95
|
+
ask_file.add_argument("--component", default=None, help="Scope to one section, named as file-metadata lists it.")
|
|
96
|
+
ask_file.add_argument("--no-citations", dest="citations", action="store_false", help="Skip citations in the answer.")
|
|
97
|
+
ask_file.set_defaults(run=_run_ask_file, render=_render.qa_file, family="tool", needs_workspace=True, needs_client=True)
|
|
98
|
+
|
|
99
|
+
run_sql = sub.add_parser(
|
|
100
|
+
WorkspaceCommand.RUN_SQL,
|
|
101
|
+
parents=[common],
|
|
102
|
+
help="Run one read-only SELECT over the workspace's tables.",
|
|
103
|
+
description=(
|
|
104
|
+
"Fast, no agent in the loop: your SQL runs directly in the guarded engine. Table names come from "
|
|
105
|
+
"file-metadata (each queryable tab's 'sql table') or 'folder-metadata --tables'; joins across files "
|
|
106
|
+
"are ordinary SQL joins. The full result is retained as parquet (download link in the reply) and its "
|
|
107
|
+
"res_... handle is a registered table in your next statement — join it, aggregate it, or page it "
|
|
108
|
+
"with LIMIT/OFFSET. Non-SELECT statements and unregistered tables are refused with the rule named."
|
|
109
|
+
),
|
|
110
|
+
)
|
|
111
|
+
run_sql.add_argument("sql", help="One SELECT statement (DuckDB dialect).")
|
|
112
|
+
run_sql.set_defaults(run=_run_run_sql, render=_render.run_sql, family="tool", needs_workspace=True, needs_client=True)
|
|
113
|
+
|
|
114
|
+
search = sub.add_parser(
|
|
115
|
+
WorkspaceCommand.HYBRID_SEARCH,
|
|
116
|
+
parents=[common],
|
|
117
|
+
help="Search the workspace: BM25 + dense vectors fused with RRF.",
|
|
118
|
+
description=(
|
|
119
|
+
"The workspace's one search method — exact identifiers and paraphrases in the same call, no retrieval "
|
|
120
|
+
"knobs. Hits name source files and components; follow up with file-metadata / read-file / ask-file."
|
|
121
|
+
),
|
|
122
|
+
)
|
|
123
|
+
search.add_argument("query", help="What to look for.")
|
|
124
|
+
search.add_argument("-k", type=int, default=10, help="Number of hits (default 10).")
|
|
125
|
+
search.add_argument("--path-prefix", default=None, help="Only files under this directory prefix.")
|
|
126
|
+
search.add_argument("--category", action="append", default=None, dest="categories", help="Category filter; repeatable.")
|
|
127
|
+
search.set_defaults(run=_run_search, render=_render.search, family="tool", needs_workspace=True, needs_client=True)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _run_folder_metadata(tools: SyncTools, workspace_id: str, args: argparse.Namespace):
|
|
131
|
+
return tools.folder_metadata(
|
|
132
|
+
workspace_id,
|
|
133
|
+
directory=args.directory,
|
|
134
|
+
recursive=args.recursive,
|
|
135
|
+
category=args.category,
|
|
136
|
+
ingestion_status=args.status,
|
|
137
|
+
kind=args.kind,
|
|
138
|
+
name_contains=args.name_contains,
|
|
139
|
+
page_size=args.page_size,
|
|
140
|
+
start_after=args.start_after,
|
|
141
|
+
include_tables=args.include_tables,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _run_file_metadata(tools: SyncTools, workspace_id: str, args: argparse.Namespace):
|
|
146
|
+
return tools.file_metadata(workspace_id, path=args.path, component=args.component)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def parse_pages(spec: str | None) -> list[int] | None:
|
|
150
|
+
if spec is None:
|
|
151
|
+
return None
|
|
152
|
+
pages: list[int] = []
|
|
153
|
+
for part in spec.split(","):
|
|
154
|
+
part = part.strip()
|
|
155
|
+
if not part:
|
|
156
|
+
continue
|
|
157
|
+
start, dash, end = part.partition("-")
|
|
158
|
+
try:
|
|
159
|
+
if dash:
|
|
160
|
+
first, last = int(start), int(end)
|
|
161
|
+
if first < 1 or last < first:
|
|
162
|
+
raise ValueError
|
|
163
|
+
pages.extend(range(first, last + 1))
|
|
164
|
+
else:
|
|
165
|
+
page = int(part)
|
|
166
|
+
if page < 1:
|
|
167
|
+
raise ValueError
|
|
168
|
+
pages.append(page)
|
|
169
|
+
except ValueError:
|
|
170
|
+
raise ValueError(f"bad --pages {spec!r}: use forms like 3 or 1-5,8 (pages are 1-based)") from None
|
|
171
|
+
return pages or None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _run_read_file(tools: SyncTools, workspace_id: str, args: argparse.Namespace):
|
|
175
|
+
return tools.read_file(
|
|
176
|
+
workspace_id,
|
|
177
|
+
path=args.path,
|
|
178
|
+
pages=parse_pages(args.pages),
|
|
179
|
+
component=args.component,
|
|
180
|
+
max_chars=args.max_chars,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _render_read_file(response) -> str:
|
|
185
|
+
print(_render.read_file_footer(response), file=sys.stderr)
|
|
186
|
+
return response.content
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _run_ask_file(tools: SyncTools, workspace_id: str, args: argparse.Namespace):
|
|
190
|
+
return tools.qa_file(
|
|
191
|
+
workspace_id,
|
|
192
|
+
path=args.path,
|
|
193
|
+
query=args.query,
|
|
194
|
+
include_citations=args.citations,
|
|
195
|
+
component=args.component,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _run_run_sql(tools: SyncTools, workspace_id: str, args: argparse.Namespace):
|
|
200
|
+
return tools.run_sql(workspace_id, sql=args.sql)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _run_search(tools: SyncTools, workspace_id: str, args: argparse.Namespace):
|
|
204
|
+
return tools.hybrid_search(
|
|
205
|
+
workspace_id,
|
|
206
|
+
query=args.query,
|
|
207
|
+
k=args.k,
|
|
208
|
+
path_prefix=args.path_prefix,
|
|
209
|
+
categories=args.categories,
|
|
210
|
+
)
|
ndi_cli/_workspace.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Workspace lifecycle, files, ingest, and search jobs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ndi_sdk.client import NdiClient
|
|
10
|
+
from ndi_sdk.credentials import write_config
|
|
11
|
+
from ndi_sdk.models.jobs import Job
|
|
12
|
+
|
|
13
|
+
from ndi_cli import _render
|
|
14
|
+
from ndi_cli._config import ConfigError, config_path, read_config_values, resolve_settings
|
|
15
|
+
from ndi_cli._jobs import wait_and_report
|
|
16
|
+
from ndi_cli._output import add_job_output_args, emit_text, output_mode
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser) -> None:
|
|
20
|
+
workspace = sub.add_parser("workspace", help="Create, list, inspect, or select a workspace.")
|
|
21
|
+
ws = workspace.add_subparsers(dest="workspace_command", required=True)
|
|
22
|
+
|
|
23
|
+
create = ws.add_parser("create", parents=[common], help="Provision a workspace.")
|
|
24
|
+
create.add_argument("name")
|
|
25
|
+
create.add_argument("--domain", default="generic", dest="domain_slug")
|
|
26
|
+
create.set_defaults(run=_run_ws_create, family="workspace", needs_workspace=False, needs_client=True)
|
|
27
|
+
|
|
28
|
+
listing = ws.add_parser("list", parents=[common], help="List workspaces.")
|
|
29
|
+
listing.add_argument("--name-contains", default=None)
|
|
30
|
+
listing.set_defaults(run=_run_ws_list, family="workspace", needs_workspace=False, needs_client=True)
|
|
31
|
+
|
|
32
|
+
get = ws.add_parser("get", parents=[common], help="Read one workspace.")
|
|
33
|
+
get.add_argument("workspace_id", nargs="?", default=None)
|
|
34
|
+
get.set_defaults(run=_run_ws_get, family="workspace", needs_workspace=False, needs_client=True)
|
|
35
|
+
|
|
36
|
+
stats = ws.add_parser("stats", parents=[common], help="Content summary for a workspace.")
|
|
37
|
+
stats.add_argument("workspace_id", nargs="?", default=None)
|
|
38
|
+
stats.set_defaults(run=_run_ws_stats, family="workspace", needs_workspace=False, needs_client=True)
|
|
39
|
+
|
|
40
|
+
delete = ws.add_parser("delete", parents=[common], help="Delete a workspace.")
|
|
41
|
+
delete.add_argument("workspace_id")
|
|
42
|
+
delete.add_argument("--confirm-name", required=True)
|
|
43
|
+
add_job_output_args(delete)
|
|
44
|
+
delete.set_defaults(run=_run_ws_delete, family="workspace", needs_workspace=False, needs_client=True)
|
|
45
|
+
|
|
46
|
+
use = ws.add_parser("use", help="Persist a workspace id in ~/.ndi/config.toml.")
|
|
47
|
+
use.add_argument("workspace_id")
|
|
48
|
+
use.set_defaults(run=_run_ws_use, family="standalone", needs_workspace=False, needs_client=False)
|
|
49
|
+
|
|
50
|
+
files = sub.add_parser("files", help="Upload, list, inspect, or delete workspace files.")
|
|
51
|
+
fs = files.add_subparsers(dest="files_command", required=True)
|
|
52
|
+
|
|
53
|
+
f_upload = fs.add_parser("upload", parents=[common], help="Upload a file into a workspace.")
|
|
54
|
+
f_upload.add_argument("file")
|
|
55
|
+
f_upload.add_argument("--path", required=True, help="Workspace destination path.")
|
|
56
|
+
f_upload.add_argument("--on-conflict", choices=("reject", "new_version"), default="reject")
|
|
57
|
+
f_upload.add_argument("--ingest", action="store_true", help="Queue ingestion after the upload.")
|
|
58
|
+
add_job_output_args(f_upload)
|
|
59
|
+
f_upload.set_defaults(run=_run_files_upload, family="workspace", needs_workspace=True, needs_client=True)
|
|
60
|
+
|
|
61
|
+
f_list = fs.add_parser("list", parents=[common], help="List workspace files.")
|
|
62
|
+
f_list.add_argument("--prefix", default=None, dest="path_prefix")
|
|
63
|
+
f_list.add_argument("--status", action="append", dest="statuses", default=None)
|
|
64
|
+
f_list.set_defaults(run=_run_files_list, family="workspace", needs_workspace=True, needs_client=True)
|
|
65
|
+
|
|
66
|
+
f_get = fs.add_parser("get", parents=[common], help="Read one file.")
|
|
67
|
+
f_get.add_argument("file_id")
|
|
68
|
+
f_get.set_defaults(run=_run_files_get, family="workspace", needs_workspace=True, needs_client=True)
|
|
69
|
+
|
|
70
|
+
f_delete = fs.add_parser("delete", parents=[common], help="Delete a file.")
|
|
71
|
+
f_delete.add_argument("file_id")
|
|
72
|
+
add_job_output_args(f_delete)
|
|
73
|
+
f_delete.set_defaults(run=_run_files_delete, family="workspace", needs_workspace=True, needs_client=True)
|
|
74
|
+
|
|
75
|
+
ingest = sub.add_parser("ingest", parents=[common], help="Queue ingestion over files or a folder.")
|
|
76
|
+
ingest.add_argument("--prefix", default=None, dest="path_prefix")
|
|
77
|
+
ingest.add_argument("--file-id", action="append", dest="file_ids", default=None)
|
|
78
|
+
ingest.add_argument("--stale-only", action="store_true")
|
|
79
|
+
add_job_output_args(ingest)
|
|
80
|
+
ingest.set_defaults(run=_run_ingest, family="workspace", needs_workspace=True, needs_client=True)
|
|
81
|
+
|
|
82
|
+
deep = sub.add_parser("deep-search", parents=[common], help="Run an agentic workspace search.")
|
|
83
|
+
deep.add_argument("query")
|
|
84
|
+
deep.add_argument("--effort", choices=("low", "medium", "high"), default=None)
|
|
85
|
+
deep.add_argument("--prefix", default=None, dest="path_prefix")
|
|
86
|
+
deep.add_argument("--context", default=None)
|
|
87
|
+
add_job_output_args(deep)
|
|
88
|
+
deep.set_defaults(run=_run_deep_search, family="workspace", needs_workspace=True, needs_client=True)
|
|
89
|
+
|
|
90
|
+
fact = sub.add_parser("fact-search", parents=[common], help="Run the fixed single-shot search pipeline.")
|
|
91
|
+
fact.add_argument("query")
|
|
92
|
+
fact.add_argument("--prefix", default=None, dest="path_prefix")
|
|
93
|
+
fact.add_argument("--top-k", type=int, default=None)
|
|
94
|
+
fact.add_argument("--context", default=None)
|
|
95
|
+
add_job_output_args(fact)
|
|
96
|
+
fact.set_defaults(run=_run_fact_search, family="workspace", needs_workspace=True, needs_client=True)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _workspace_id(args: argparse.Namespace, fallback: str) -> str:
|
|
100
|
+
value = getattr(args, "workspace_id", None) or args.workspace or fallback
|
|
101
|
+
if not value:
|
|
102
|
+
raise ConfigError("no workspace: set $NDI_WORKSPACE_ID or pass an id")
|
|
103
|
+
return value
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _dump_or_text(model, args, *, text: str, stem: str) -> int:
|
|
107
|
+
if output_mode(args) == "json":
|
|
108
|
+
return emit_text(model.model_dump_json(indent=2, exclude_none=True), args, default_stem=stem, suffix=".json")
|
|
109
|
+
print(text)
|
|
110
|
+
return 0
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _finish_job(client: NdiClient, job: Job, args: argparse.Namespace, auto, stem: str) -> int:
|
|
114
|
+
return wait_and_report(client, job, args, auto=auto, stem=stem)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _run_ws_create(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
|
|
118
|
+
workspace = client.workspaces.create(name=args.name, domain_slug=args.domain_slug)
|
|
119
|
+
return _dump_or_text(workspace, args, text=_render.workspace(workspace), stem="workspace")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _run_ws_list(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
|
|
123
|
+
page = client.workspaces.list(name_contains=args.name_contains)
|
|
124
|
+
return _dump_or_text(page, args, text=_render.workspace_page(page), stem="workspaces")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _run_ws_get(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
|
|
128
|
+
workspace = client.workspaces.get(_workspace_id(args, workspace_id))
|
|
129
|
+
return _dump_or_text(workspace, args, text=_render.workspace(workspace), stem="workspace")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _run_ws_stats(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
|
|
133
|
+
stats = client.workspaces.stats(_workspace_id(args, workspace_id))
|
|
134
|
+
return _dump_or_text(stats, args, text=_render.workspace_stats(stats), stem="stats")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _run_ws_delete(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
|
|
138
|
+
job = client.workspaces.delete(args.workspace_id, confirm_name=args.confirm_name)
|
|
139
|
+
return _finish_job(client, job, args, _render.job_line, "workspace-delete")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _run_ws_use(args: argparse.Namespace) -> int:
|
|
143
|
+
if not args.workspace_id.strip():
|
|
144
|
+
raise ValueError("workspace use needs a workspace id")
|
|
145
|
+
target = config_path()
|
|
146
|
+
# Selecting a workspace must not quietly persist whatever credentials happen
|
|
147
|
+
# to be in the environment: keep the file's own key/url when it has them.
|
|
148
|
+
stored = read_config_values(target)
|
|
149
|
+
settings = resolve_settings(require_workspace=False)
|
|
150
|
+
path = write_config(
|
|
151
|
+
api_key=stored.get("api_key") or settings.api_key,
|
|
152
|
+
base_url=stored.get("base_url") or settings.base_url,
|
|
153
|
+
workspace_id=args.workspace_id.strip(),
|
|
154
|
+
path=target,
|
|
155
|
+
)
|
|
156
|
+
print(f"using workspace {args.workspace_id.strip()} ({path})", file=sys.stderr)
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _run_files_upload(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
|
|
161
|
+
path = Path(args.file)
|
|
162
|
+
if not path.is_file():
|
|
163
|
+
print(f"error: file not found: {args.file}", file=sys.stderr)
|
|
164
|
+
return 2
|
|
165
|
+
dest = args.path.lstrip("/")
|
|
166
|
+
if args.ingest:
|
|
167
|
+
result = client.upload_and_ingest(
|
|
168
|
+
workspace_id,
|
|
169
|
+
path,
|
|
170
|
+
path=dest,
|
|
171
|
+
on_conflict=args.on_conflict,
|
|
172
|
+
)
|
|
173
|
+
print(f"file {result.file.file_id} {result.file.path}", file=sys.stderr)
|
|
174
|
+
return _finish_job(client, result.ingestion_job, args, _render.ingestion_job, path.stem)
|
|
175
|
+
job = client.files.upload(workspace_id, path, path=dest, on_conflict=args.on_conflict)
|
|
176
|
+
return _finish_job(client, job, args, _render.job_line, path.stem)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _run_files_list(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
|
|
180
|
+
page = client.files.list(workspace_id, path_prefix=args.path_prefix, ingestion_status=args.statuses)
|
|
181
|
+
return _dump_or_text(page, args, text=_render.file_page(page), stem="files")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _run_files_get(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
|
|
185
|
+
detail = client.files.get(workspace_id, args.file_id)
|
|
186
|
+
return _dump_or_text(detail, args, text=_render.file_detail(detail), stem="file")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _run_files_delete(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
|
|
190
|
+
job = client.files.delete(workspace_id, args.file_id)
|
|
191
|
+
return _finish_job(client, job, args, _render.job_line, "file-delete")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _run_ingest(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
|
|
195
|
+
if args.file_ids and args.path_prefix:
|
|
196
|
+
print("error: pass --prefix or --file-id, not both", file=sys.stderr)
|
|
197
|
+
return 2
|
|
198
|
+
job = client.ingestion.ingest(
|
|
199
|
+
workspace_id,
|
|
200
|
+
file_ids=args.file_ids,
|
|
201
|
+
path_prefix=args.path_prefix,
|
|
202
|
+
stale_only=args.stale_only,
|
|
203
|
+
)
|
|
204
|
+
return _finish_job(client, job, args, _render.ingestion_job, "ingest")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _run_deep_search(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
|
|
208
|
+
job = client.search.deep(
|
|
209
|
+
workspace_id,
|
|
210
|
+
query=args.query,
|
|
211
|
+
context=args.context,
|
|
212
|
+
effort=args.effort,
|
|
213
|
+
path_prefix=args.path_prefix,
|
|
214
|
+
)
|
|
215
|
+
return _finish_job(client, job, args, _render.search_job, "deep-search")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _run_fact_search(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
|
|
219
|
+
job = client.search.fact(
|
|
220
|
+
workspace_id,
|
|
221
|
+
query=args.query,
|
|
222
|
+
context=args.context,
|
|
223
|
+
path_prefix=args.path_prefix,
|
|
224
|
+
top_k=args.top_k,
|
|
225
|
+
)
|
|
226
|
+
return _finish_job(client, job, args, _render.search_job, "fact-search")
|
ndi_cli/commands.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Workspace command identities and their human-facing activity labels."""
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class WorkspaceCommand(StrEnum):
|
|
7
|
+
FOLDER_METADATA = "folder-metadata"
|
|
8
|
+
FILE_METADATA = "file-metadata"
|
|
9
|
+
READ_FILE = "read-file"
|
|
10
|
+
ASK_FILE = "ask-file"
|
|
11
|
+
RUN_SQL = "run-sql"
|
|
12
|
+
HYBRID_SEARCH = "hybrid-search"
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def activity_label(self) -> str:
|
|
16
|
+
return {
|
|
17
|
+
self.FOLDER_METADATA: "Exploring workspace folders",
|
|
18
|
+
self.FILE_METADATA: "Inspecting file metadata",
|
|
19
|
+
self.READ_FILE: "Reading file contents",
|
|
20
|
+
self.ASK_FILE: "Asking about a file",
|
|
21
|
+
self.RUN_SQL: "Querying tables with SQL",
|
|
22
|
+
self.HYBRID_SEARCH: "Searching workspace files",
|
|
23
|
+
}[self]
|