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/_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]