ndi-cli 0.8.0__tar.gz → 0.10.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ndi-cli
3
- Version: 0.8.0
3
+ Version: 0.10.0
4
4
  Summary: NDI platform CLI: document operations, jobs, workspaces, and the agent workspace tools
5
5
  Keywords: ndi,cli,document-intelligence,coding-agents
6
6
  Author: Nace AI
@@ -16,7 +16,7 @@ Classifier: Programming Language :: Python :: 3.13
16
16
  Classifier: Programming Language :: Python :: 3.14
17
17
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
18
  Classifier: Typing :: Typed
19
- Requires-Dist: ndi-sdk>=0.22,<1
19
+ Requires-Dist: ndi-sdk>=0.25,<1
20
20
  Requires-Dist: truststore>=0.10.4,<1
21
21
  Requires-Python: >=3.11
22
22
  Project-URL: Homepage, https://ndi-api.nace.ai
@@ -50,14 +50,15 @@ config location. Workspace-scoped verbs also take `--workspace ID`.
50
50
  | `ndi extract SOURCE -s SCHEMA` | Structured extract (`--validate` checks a schema with no job) |
51
51
  | `ndi split SOURCE --class id:label` | Logical sections |
52
52
  | `ndi classify SOURCE --class id:label` | Labels (refuses `jobid://`) |
53
- | `ndi ground SOURCE --target id=TEXT` | Locate quoted text |
53
+ | `ndi ground SOURCE --target id=TEXT` | Locate quoted text (standalone quotes only; after search, poll `grounding_job_ids`) |
54
54
  | `ndi job ID` / `ndi jobs` / `ndi cancel ID` | Inspect or cancel jobs |
55
55
  | `ndi workspace create\|list\|get\|stats\|delete\|use` | Workspace lifecycle |
56
- | `ndi files upload\|list\|get\|delete` | Workspace files (`--ingest` uploads then queues ingestion) |
56
+ | `ndi files upload\|list\|get\|delete` | Workspace files (a directory walks nested files; `--ingest` then queues ingestion) |
57
57
  | `ndi ingest` | Queue ingestion |
58
- | `ndi deep-search QUERY` / `ndi fact-search QUERY` | Agentic / single-shot search |
58
+ | `ndi deep-search QUERY` / `ndi fact-search QUERY` | Agentic / single-shot search; then `ndi job` on each printed `grounding_job_ids` entry |
59
59
  | `ndi filtered-search QUERY` | Corpus filter/rank/count over the metadata catalog (`--cursor` pages an earlier run) |
60
- | `ndi je-testing QUERY` | Journal-entry testing over a ledger package (`--path` to scope it, `--effort` for the level) |
60
+ | `ndi je-testing QUERY` | Journal-entry testing over a ledger package (`--path` to scope it, `--reasoning-effort` for the thinking budget) |
61
+ | `ndi ledger-understanding start\|get\|sources\|select\|selection` | Measure the ledger package, confirm sources, or read the latest report |
61
62
  | `ndi folder-metadata` / `file-metadata` / `read-file` / `ask-file` / `run-sql` / `hybrid-search` | Read-only workspace tools (the sandbox surface) |
62
63
 
63
64
  ## Sources
@@ -74,8 +75,19 @@ config location. Workspace-scoped verbs also take `--workspace ID`.
74
75
 
75
76
  ```bash
76
77
  ndi parse a.pdf -o id | ndi extract - -s schema.json
78
+ ndi files upload ./corpus --ingest
79
+ ndi ledger-understanding start # --force to re-scan; --selection-id after confirm
80
+ ndi ledger-understanding get
81
+ ndi ledger-understanding sources --review-reading-id READING
82
+ ndi ledger-understanding select --primary SRC --supporting SRC --revision REV
77
83
  ```
78
84
 
85
+ `ndi files upload DIR` keeps relative paths under `--path` (default: the
86
+ directory name) and uploads in parallel (`-j`, default 8). Unsupported
87
+ files (and `.DS_Store`) are skipped; a warning on stderr lists them when
88
+ the run finishes. `--ingest` then queues ingestion in batches of 1000
89
+ file ids. A single file still requires `--path`.
90
+
79
91
  ## Output
80
92
 
81
93
  Result content goes to **stdout**; status (`job <id> queued`, `saved …`) goes
@@ -94,6 +106,11 @@ URLs automatically.
94
106
  API failures exit 1. Usage / config errors exit 2. Schema-validation
95
107
  failures also show up to five field constraints, each limited to 500
96
108
  characters; request input and unrelated error-detail fields are omitted.
109
+ Unsupported inputs print `error: 422 [unsupported_file_type] ...` to stderr and
110
+ exit 1. `ndi upload` prints no handle, document operations print no job ID, and
111
+ `ndi files upload --ingest` creates no workspace file or ingestion job.
112
+ The server checks a bounded byte prefix, rejects conclusive filename/content
113
+ conflicts, and treats the multipart `Content-Type` as advisory.
97
114
 
98
115
  See the [CLI guide](https://docs.ndi.nace.ai/guides/cli) for the full command
99
116
  reference. `SKILL.md` is the agent-facing guide to the six workspace tools.
@@ -26,14 +26,15 @@ config location. Workspace-scoped verbs also take `--workspace ID`.
26
26
  | `ndi extract SOURCE -s SCHEMA` | Structured extract (`--validate` checks a schema with no job) |
27
27
  | `ndi split SOURCE --class id:label` | Logical sections |
28
28
  | `ndi classify SOURCE --class id:label` | Labels (refuses `jobid://`) |
29
- | `ndi ground SOURCE --target id=TEXT` | Locate quoted text |
29
+ | `ndi ground SOURCE --target id=TEXT` | Locate quoted text (standalone quotes only; after search, poll `grounding_job_ids`) |
30
30
  | `ndi job ID` / `ndi jobs` / `ndi cancel ID` | Inspect or cancel jobs |
31
31
  | `ndi workspace create\|list\|get\|stats\|delete\|use` | Workspace lifecycle |
32
- | `ndi files upload\|list\|get\|delete` | Workspace files (`--ingest` uploads then queues ingestion) |
32
+ | `ndi files upload\|list\|get\|delete` | Workspace files (a directory walks nested files; `--ingest` then queues ingestion) |
33
33
  | `ndi ingest` | Queue ingestion |
34
- | `ndi deep-search QUERY` / `ndi fact-search QUERY` | Agentic / single-shot search |
34
+ | `ndi deep-search QUERY` / `ndi fact-search QUERY` | Agentic / single-shot search; then `ndi job` on each printed `grounding_job_ids` entry |
35
35
  | `ndi filtered-search QUERY` | Corpus filter/rank/count over the metadata catalog (`--cursor` pages an earlier run) |
36
- | `ndi je-testing QUERY` | Journal-entry testing over a ledger package (`--path` to scope it, `--effort` for the level) |
36
+ | `ndi je-testing QUERY` | Journal-entry testing over a ledger package (`--path` to scope it, `--reasoning-effort` for the thinking budget) |
37
+ | `ndi ledger-understanding start\|get\|sources\|select\|selection` | Measure the ledger package, confirm sources, or read the latest report |
37
38
  | `ndi folder-metadata` / `file-metadata` / `read-file` / `ask-file` / `run-sql` / `hybrid-search` | Read-only workspace tools (the sandbox surface) |
38
39
 
39
40
  ## Sources
@@ -50,8 +51,19 @@ config location. Workspace-scoped verbs also take `--workspace ID`.
50
51
 
51
52
  ```bash
52
53
  ndi parse a.pdf -o id | ndi extract - -s schema.json
54
+ ndi files upload ./corpus --ingest
55
+ ndi ledger-understanding start # --force to re-scan; --selection-id after confirm
56
+ ndi ledger-understanding get
57
+ ndi ledger-understanding sources --review-reading-id READING
58
+ ndi ledger-understanding select --primary SRC --supporting SRC --revision REV
53
59
  ```
54
60
 
61
+ `ndi files upload DIR` keeps relative paths under `--path` (default: the
62
+ directory name) and uploads in parallel (`-j`, default 8). Unsupported
63
+ files (and `.DS_Store`) are skipped; a warning on stderr lists them when
64
+ the run finishes. `--ingest` then queues ingestion in batches of 1000
65
+ file ids. A single file still requires `--path`.
66
+
55
67
  ## Output
56
68
 
57
69
  Result content goes to **stdout**; status (`job <id> queued`, `saved …`) goes
@@ -70,6 +82,11 @@ URLs automatically.
70
82
  API failures exit 1. Usage / config errors exit 2. Schema-validation
71
83
  failures also show up to five field constraints, each limited to 500
72
84
  characters; request input and unrelated error-detail fields are omitted.
85
+ Unsupported inputs print `error: 422 [unsupported_file_type] ...` to stderr and
86
+ exit 1. `ndi upload` prints no handle, document operations print no job ID, and
87
+ `ndi files upload --ingest` creates no workspace file or ingestion job.
88
+ The server checks a bounded byte prefix, rejects conclusive filename/content
89
+ conflicts, and treats the multipart `Content-Type` as advisory.
73
90
 
74
91
  See the [CLI guide](https://docs.ndi.nace.ai/guides/cli) for the full command
75
92
  reference. `SKILL.md` is the agent-facing guide to the six workspace tools.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ndi-cli"
3
- version = "0.8.0"
3
+ version = "0.10.0"
4
4
  description = "NDI platform CLI: document operations, jobs, workspaces, and the agent workspace tools"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -24,7 +24,7 @@ classifiers = [
24
24
  "Typing :: Typed",
25
25
  ]
26
26
  dependencies = [
27
- "ndi-sdk>=0.22,<1",
27
+ "ndi-sdk>=0.25,<1",
28
28
  "truststore>=0.10.4,<1",
29
29
  ]
30
30
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ndi-cli"
3
- version = "0.8.0"
3
+ version = "0.10.0"
4
4
  description = "NDI platform CLI: document operations, jobs, workspaces, and the agent workspace tools"
5
5
  authors = [
6
6
  { name = "Nace AI", email = "engineering@nace.ai" }
@@ -27,7 +27,7 @@ classifiers = [
27
27
  "Typing :: Typed",
28
28
  ]
29
29
  dependencies = [
30
- "ndi-sdk>=0.22,<1",
30
+ "ndi-sdk>=0.25,<1",
31
31
  "truststore>=0.10.4,<1",
32
32
  ]
33
33
 
@@ -6,4 +6,4 @@ methods. The six workspace tools (``folder-metadata``, ``file-metadata``,
6
6
  in-sandbox surface.
7
7
  """
8
8
 
9
- __version__ = "0.8.0"
9
+ __version__ = "0.10.0"
@@ -16,7 +16,7 @@ from ndi_sdk.resources.tools import SyncTools
16
16
 
17
17
  from ndi_cli import _docops, _jobs, _login, _tools, _workspace
18
18
  from ndi_cli._config import ConfigError, resolve_settings
19
- from ndi_cli._local import SOCKET_ENV, TOKEN_ENV, LocalTransport
19
+ from ndi_cli._local import KG_ENV, SOCKET_ENV, TOKEN_ENV, LocalTransport
20
20
  from ndi_cli._tools import parse_pages
21
21
 
22
22
  # qa-file runs a durable workflow that legitimately takes minutes; the metadata,
@@ -27,16 +27,18 @@ _VALIDATION_ERROR_CHARS = 500
27
27
 
28
28
 
29
29
  def main(argv: list[str] | None = None) -> int:
30
- parser = _build_parser()
30
+ socket_path = os.environ.get(SOCKET_ENV)
31
+ # Presence, not truthiness: an empty $NDI_CLI_SOCKET still means "inside an
32
+ # agent run", and must not silently unlock the full platform CLI.
33
+ in_agent_run = socket_path is not None
34
+ # An agent run gets the graph commands only when the worker granted them; a
35
+ # run whose guide never mentions kg-* must not find them in --help either.
36
+ parser = _build_parser(graph=not in_agent_run or bool(os.environ.get(KG_ENV)))
31
37
  args = parser.parse_args(argv)
32
38
  if args.command is None:
33
39
  parser.print_help()
34
40
  return 2
35
41
  family = getattr(args, "family", None)
36
- socket_path = os.environ.get(SOCKET_ENV)
37
- # Presence, not truthiness: an empty $NDI_CLI_SOCKET still means "inside an
38
- # agent run", and must not silently unlock the full platform CLI.
39
- in_agent_run = socket_path is not None
40
42
  workspace_flag = getattr(args, "workspace", None)
41
43
  if workspace_flag is not None and not workspace_flag.strip():
42
44
  print("error: --workspace needs a workspace id", file=sys.stderr)
@@ -130,7 +132,7 @@ def _validation_error_lines(detail: object) -> list[str]:
130
132
  return lines
131
133
 
132
134
 
133
- def _build_parser() -> argparse.ArgumentParser:
135
+ def _build_parser(*, graph: bool = True) -> argparse.ArgumentParser:
134
136
  parser = argparse.ArgumentParser(
135
137
  prog="ndi",
136
138
  description=(
@@ -151,7 +153,7 @@ def _build_parser() -> argparse.ArgumentParser:
151
153
  )
152
154
  sub = parser.add_subparsers(dest="command")
153
155
  _login.add_parsers(sub)
154
- _tools.add_parsers(sub, common)
156
+ _tools.add_parsers(sub, common, graph=graph)
155
157
  _docops.add_parsers(sub, common)
156
158
  _jobs.add_parsers(sub, common)
157
159
  _workspace.add_parsers(sub, common)
@@ -36,18 +36,17 @@ def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser
36
36
  cancel.set_defaults(run=_run_cancel, family="job", needs_workspace=False, needs_client=True)
37
37
 
38
38
 
39
- def wait_and_report(
39
+ def wait_job(
40
40
  client: NdiClient,
41
41
  job: Job,
42
42
  args: argparse.Namespace,
43
43
  *,
44
- auto: Callable[[Job], str],
45
- stem: str,
46
- ) -> int:
44
+ print_timeout_id: bool = True,
45
+ ) -> tuple[Job | None, int]:
46
+ """Wait for ``job``; status lines go to stderr. Does not render the result."""
47
47
  print(f"job {job.job_id} {job.status}", file=sys.stderr)
48
48
  if getattr(args, "async_submit", False):
49
- print(job.job_id)
50
- return 0
49
+ return job, 0
51
50
  try:
52
51
  if job.is_terminal:
53
52
  check_terminal(job, raise_on_failure=True)
@@ -56,12 +55,30 @@ def wait_and_report(
56
55
  except JobFailedError as exc:
57
56
  code = f" [{exc.job.error.code}]" if exc.job.error and exc.job.error.code else ""
58
57
  print(f"error:{code} {exc}", file=sys.stderr)
59
- return 1
58
+ return exc.job, 1
60
59
  except JobTimeoutError as exc:
61
60
  print(f"error: {exc}", file=sys.stderr)
62
- print(exc.job.job_id)
63
- return 1
61
+ if print_timeout_id:
62
+ print(exc.job.job_id)
63
+ return exc.job, 1
64
64
  print(f"job {job.job_id} {job.status}", file=sys.stderr)
65
+ return job, 0
66
+
67
+
68
+ def wait_and_report(
69
+ client: NdiClient,
70
+ job: Job,
71
+ args: argparse.Namespace,
72
+ *,
73
+ auto: Callable[[Job], str],
74
+ stem: str,
75
+ ) -> int:
76
+ job, code = wait_job(client, job, args)
77
+ if code != 0 or job is None:
78
+ return code or 1
79
+ if getattr(args, "async_submit", False):
80
+ print(job.job_id)
81
+ return 0
65
82
  return render_job(job, args, auto=auto, stem=stem)
66
83
 
67
84
 
@@ -17,6 +17,8 @@ from ndi_sdk.errors import ErrorBody, NdiConnectionError, NdiTimeoutError, error
17
17
  from ndi_sdk.models.tools import (
18
18
  FileMetadataRequest,
19
19
  FolderMetadataRequest,
20
+ KgSearchRequest,
21
+ KgWalkRequest,
20
22
  QaFileRequest,
21
23
  ReadFileRequest,
22
24
  RunSqlRequest,
@@ -28,6 +30,9 @@ from ndi_cli.commands import WorkspaceCommand
28
30
 
29
31
  SOCKET_ENV = "NDI_CLI_SOCKET"
30
32
  TOKEN_ENV = "NDI_CLI_TOKEN"
33
+ #: Set (to any non-empty value) by the worker for an agent run that has the graph
34
+ #: surface; inside an agent run the ``kg-*`` commands are registered only when it is.
35
+ KG_ENV = "NDI_CLI_KG"
31
36
  MAX_MESSAGE_BYTES = 16 * 1024 * 1024
32
37
  ModelT = TypeVar("ModelT", bound=BaseModel)
33
38
 
@@ -62,12 +67,102 @@ class SearchCommand(BaseModel):
62
67
  body: SearchRequest
63
68
 
64
69
 
70
+ class KgInfoRequest(BaseModel):
71
+ """``kg-info``'s scope as a socket body.
72
+
73
+ Defined here rather than imported from ``ndi_sdk.models.tools`` like its
74
+ siblings: on the wire ``kg-info`` is a GET carrying query parameters, so it has
75
+ no request model to reuse. Only the scope crosses — the drawable sample is for
76
+ a client that renders a graph, and ``_kg_info_command`` refuses it.
77
+ """
78
+
79
+ path_prefix: str | None = None
80
+ hub_node_type: str | None = None
81
+
82
+
83
+ class KgInfoCommand(BaseModel):
84
+ command: Literal[WorkspaceCommand.KG_INFO] = WorkspaceCommand.KG_INFO
85
+ body: KgInfoRequest
86
+
87
+
88
+ class KgSearchCommand(BaseModel):
89
+ command: Literal[WorkspaceCommand.KG_SEARCH] = WorkspaceCommand.KG_SEARCH
90
+ body: KgSearchRequest
91
+
92
+
93
+ class KgWalkCommand(BaseModel):
94
+ command: Literal[WorkspaceCommand.KG_WALK] = WorkspaceCommand.KG_WALK
95
+ body: KgWalkRequest
96
+
97
+
98
+ class KgAnswerRequest(BaseModel):
99
+ """``kg-answer``'s question as a socket body.
100
+
101
+ The agent passes its verbatim question; the server runs the graph pass and
102
+ returns the rendered outcome (answered text + citations, or evidence + gap).
103
+ """
104
+
105
+ query: str
106
+
107
+
108
+ class KgAnswerResponse(BaseModel):
109
+ """The rendered outcome of one graph pass, ready to print or inject as a tool result."""
110
+
111
+ text: str
112
+
113
+
114
+ class KgAnswerCommand(BaseModel):
115
+ command: Literal[WorkspaceCommand.KG_ANSWER] = WorkspaceCommand.KG_ANSWER
116
+ body: KgAnswerRequest
117
+
118
+
65
119
  Command = Annotated[
66
- FolderCommand | FileCommand | ReadCommand | AskCommand | SqlCommand | SearchCommand, Field(discriminator="command")
120
+ FolderCommand
121
+ | FileCommand
122
+ | ReadCommand
123
+ | AskCommand
124
+ | SqlCommand
125
+ | SearchCommand
126
+ | KgInfoCommand
127
+ | KgSearchCommand
128
+ | KgWalkCommand
129
+ | KgAnswerCommand,
130
+ Field(discriminator="command"),
67
131
  ]
68
132
  COMMAND = TypeAdapter(Command)
69
133
 
70
134
 
135
+ def _kg_info_command(params: object) -> Command:
136
+ """Build the ``kg-info`` command from the SDK method's query parameters.
137
+
138
+ Refuses ``include_graph`` instead of dropping it: the sample is the whole point
139
+ of that flag, and answering without one would read as a graph with no structure.
140
+ """
141
+ if not isinstance(params, dict):
142
+ raise ValueError("The internal CLI accepts workspace commands only.")
143
+ if params.get("include_graph"):
144
+ raise ValueError("The internal CLI does not serve kg-info's drawable graph sample.")
145
+ prefix = params.get("path_prefix")
146
+ if prefix is not None and not isinstance(prefix, str):
147
+ raise ValueError("kg-info's path_prefix must be a string.")
148
+ hub_node_type = params.get("hub_node_type")
149
+ if hub_node_type is not None and not isinstance(hub_node_type, str):
150
+ raise ValueError("kg-info's hub_node_type must be a string.")
151
+ return KgInfoCommand(body=KgInfoRequest(path_prefix=prefix, hub_node_type=hub_node_type))
152
+
153
+
154
+ def _command(method: str, path: str, kwargs: dict[str, object]) -> Command:
155
+ """One socket command per SDK call, named by the endpoint the method addresses."""
156
+ name = path.rsplit("/", 1)[-1]
157
+ if method == "GET" and name == WorkspaceCommand.KG_INFO:
158
+ return _kg_info_command(kwargs.get("params"))
159
+ body = kwargs.get("body")
160
+ if method != "POST" or not isinstance(body, BaseModel):
161
+ raise ValueError("The internal CLI accepts workspace commands only.")
162
+ command = WorkspaceCommand.ASK_FILE if name == "qa-file" else WorkspaceCommand(name)
163
+ return COMMAND.validate_python({"command": command, "body": body.model_dump(exclude_none=True)})
164
+
165
+
71
166
  class CommandEnvelope(BaseModel):
72
167
  """One request line on the socket: the owning run's token plus its command."""
73
168
 
@@ -98,15 +193,7 @@ class LocalTransport:
98
193
  self.token = token
99
194
 
100
195
  def request_model(self, model: type[ModelT], method: str, path: str, **kwargs: object) -> ModelT:
101
- body = kwargs.get("body")
102
- if method != "POST" or not isinstance(body, BaseModel):
103
- raise ValueError("The internal CLI accepts workspace commands only.")
104
- name = path.rsplit("/", 1)[-1]
105
- command = WorkspaceCommand.ASK_FILE if name == "qa-file" else WorkspaceCommand(name)
106
- request = CommandEnvelope(
107
- token=self.token,
108
- command=COMMAND.validate_python({"command": command, "body": body.model_dump(exclude_none=True)}),
109
- )
196
+ request = CommandEnvelope(token=self.token, command=_command(method, path, kwargs))
110
197
  try:
111
198
  with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
112
199
  connection.settimeout(self.timeout)