ndi-cli 0.13.0__tar.gz → 0.16.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.13.0
3
+ Version: 0.16.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.28,<1
19
+ Requires-Dist: ndi-sdk>=0.31,<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
@@ -94,7 +94,14 @@ file ids. A single file still requires `--path`.
94
94
 
95
95
  Result content goes to **stdout**; status (`job <id> queued`, `saved …`) goes
96
96
  to **stderr**. `-o auto|md|json|payload|id` picks the shape. `--json` is an
97
- alias for `-o json`. `--save PATH` / `--out-dir DIR` write files. `--async`
97
+ alias for `-o json`.
98
+
99
+ The default (`auto`) prints the full JSON response. When that is over
100
+ `$NDI_OUTPUT_LIMIT` characters (default 40,000), the compact text rendering
101
+ prints instead and stderr carries `note: output shortened …` with the full
102
+ size. `--json` always prints it whole, and `--save` / `--out-dir` always write
103
+ it whole. Commands whose output is the content itself (`read-file`,
104
+ `ws context`, parse markdown) print it as before. `--save PATH` / `--out-dir DIR` write files. `--async`
98
105
  submits and prints the job id.
99
106
 
100
107
  When a Parse result has external complete content, `-o auto` and `-o md` print
@@ -70,7 +70,14 @@ file ids. A single file still requires `--path`.
70
70
 
71
71
  Result content goes to **stdout**; status (`job <id> queued`, `saved …`) goes
72
72
  to **stderr**. `-o auto|md|json|payload|id` picks the shape. `--json` is an
73
- alias for `-o json`. `--save PATH` / `--out-dir DIR` write files. `--async`
73
+ alias for `-o json`.
74
+
75
+ The default (`auto`) prints the full JSON response. When that is over
76
+ `$NDI_OUTPUT_LIMIT` characters (default 40,000), the compact text rendering
77
+ prints instead and stderr carries `note: output shortened …` with the full
78
+ size. `--json` always prints it whole, and `--save` / `--out-dir` always write
79
+ it whole. Commands whose output is the content itself (`read-file`,
80
+ `ws context`, parse markdown) print it as before. `--save PATH` / `--out-dir DIR` write files. `--async`
74
81
  submits and prints the job id.
75
82
 
76
83
  When a Parse result has external complete content, `-o auto` and `-o md` print
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ndi-cli"
3
- version = "0.13.0"
3
+ version = "0.16.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.28,<1",
27
+ "ndi-sdk>=0.31,<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.13.0"
3
+ version = "0.16.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.28,<1",
30
+ "ndi-sdk>=0.31,<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.13.0"
9
+ __version__ = "0.16.0"
@@ -17,6 +17,7 @@ from ndi_sdk.resources.tools import SyncTools
17
17
  from ndi_cli import _docops, _jobs, _login, _tools, _workspace
18
18
  from ndi_cli._config import ConfigError, resolve_settings
19
19
  from ndi_cli._local import KG_ENV, SOCKET_ENV, TOKEN_ENV, LocalTransport
20
+ from ndi_cli._output import full_json, output_limit, print_full_or_summary
20
21
  from ndi_cli._tools import GraphCommands, parse_pages
21
22
 
22
23
  # qa-file runs a durable workflow that legitimately takes minutes; the metadata,
@@ -39,6 +40,12 @@ def main(argv: list[str] | None = None) -> int:
39
40
  if args.command is None:
40
41
  parser.print_help()
41
42
  return 2
43
+ try:
44
+ # Checked before any request: a job is billed before its output prints.
45
+ output_limit()
46
+ except ValueError as exc:
47
+ print(f"error: {exc}", file=sys.stderr)
48
+ return 2
42
49
  family = getattr(args, "family", None)
43
50
  workspace_flag = getattr(args, "workspace", None)
44
51
  if workspace_flag is not None and not workspace_flag.strip():
@@ -80,7 +87,7 @@ def main(argv: list[str] | None = None) -> int:
80
87
  timeout = getattr(args, "timeout", DEFAULT_TIMEOUT_SECONDS)
81
88
  with NdiClient(api_key=settings.api_key, base_url=settings.base_url, timeout=timeout) as client:
82
89
  if family == "tool":
83
- return _print_tool(args, args.run(client.tools, settings.workspace_id, args))
90
+ return _print_tool_full(args, args.run(client.tools, settings.workspace_id, args))
84
91
  return args.run(client, settings.workspace_id, args)
85
92
  except ConfigError as exc:
86
93
  print(f"error: {exc}", file=sys.stderr)
@@ -100,13 +107,21 @@ def main(argv: list[str] | None = None) -> int:
100
107
 
101
108
 
102
109
  def _print_tool(args: argparse.Namespace, response) -> int:
110
+ """The text rendering. An agent run always gets it: the renderings are what its agents are evaluated against."""
103
111
  if args.json:
104
- print(response.model_dump_json(indent=2, exclude_none=True))
112
+ print(full_json(response))
105
113
  return 0
106
114
  print(args.render(response))
107
115
  return 0
108
116
 
109
117
 
118
+ def _print_tool_full(args: argparse.Namespace, response) -> int:
119
+ """The full JSON response by default, unless the command's rendering is the content itself."""
120
+ if args.json or getattr(args, "verbatim", False):
121
+ return _print_tool(args, response)
122
+ return print_full_or_summary(response, args, lambda: args.render(response))
123
+
124
+
110
125
  def _validation_error_lines(detail: object) -> list[str]:
111
126
  """Keep field constraints actionable without echoing request inputs or arbitrary detail."""
112
127
  errors = detail.get("errors") if isinstance(detail, dict) else None
@@ -143,7 +158,9 @@ def _build_parser(*, graph: GraphCommands = "all") -> argparse.ArgumentParser:
143
158
  ),
144
159
  )
145
160
  common = argparse.ArgumentParser(add_help=False)
146
- common.add_argument("--json", action="store_true", help="Print the raw response JSON instead of the text rendering.")
161
+ common.add_argument(
162
+ "--json", action="store_true", help="Always print the full response JSON, never the shortened text rendering."
163
+ )
147
164
  common.add_argument("--workspace", metavar="ID", default=None, help="Override $NDI_WORKSPACE_ID for this call.")
148
165
  common.add_argument(
149
166
  "--timeout",
@@ -48,7 +48,7 @@ def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser
48
48
  parse.add_argument("--format", dest="formats", default=None, help="Comma list: markdown,text,blocks.")
49
49
  parse.add_argument("--table-format", choices=("html", "markdown"), default="html")
50
50
  parse.add_argument("--chunk", choices=("none", "page", "section"), default="none")
51
- parse.add_argument("--figures", choices=("omit", "include", "describe"), default="describe")
51
+ parse.add_argument("--figures", choices=("omit", "include", "describe"), default="include")
52
52
  parse.add_argument("--sheets", default=None, help="Comma-separated workbook sheet names.")
53
53
  parse.add_argument("--password", default=None, help="PDF password. Never stored.")
54
54
  parse.set_defaults(run=_run_parse, family="docops", needs_workspace=False, needs_client=True)
@@ -13,7 +13,7 @@ from ndi_sdk.models.jobs import Job
13
13
  from ndi_sdk.resources.jobs import check_terminal
14
14
 
15
15
  from ndi_cli import _render
16
- from ndi_cli._output import add_job_output_args, emit_text, output_mode, render_job
16
+ from ndi_cli._output import add_job_output_args, emit_text, full_json, output_mode, render_job, shortened
17
17
 
18
18
 
19
19
  def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser) -> None:
@@ -107,15 +107,16 @@ def _run_list(client: NdiClient, workspace_id: str, args: argparse.Namespace) ->
107
107
  limit=args.limit,
108
108
  )
109
109
  mode = output_mode(args)
110
- if mode == "json":
111
- return emit_text(page.model_dump_json(indent=2, exclude_none=True), args, default_stem="jobs", suffix=".json")
112
110
  if mode in ("md", "payload", "id"):
113
111
  # A listing is not a job, so these shapes have nothing to render. Say so
114
112
  # instead of silently printing the default listing.
115
113
  print(f"error: -o {mode} is not available for `jobs`; use auto or json", file=sys.stderr)
116
114
  return 2
117
115
  # Route through emit_text so --save / --out-dir work here as they do elsewhere.
118
- return emit_text(_render.job_page(page), args, default_stem="jobs", suffix=".txt")
116
+ full = full_json(page)
117
+ if mode == "auto" and shortened(full, args):
118
+ return emit_text(_render.job_page(page), args, default_stem="jobs", suffix=".txt")
119
+ return emit_text(full, args, default_stem="jobs", suffix=".json")
119
120
 
120
121
 
121
122
  def _run_cancel(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
@@ -2,14 +2,22 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import os
5
6
  import sys
6
7
  from collections.abc import Callable
7
8
  from pathlib import Path
8
9
 
9
10
  from ndi_sdk.models.jobs import IngestionResult, JetResult, Job, ParseResult
11
+ from pydantic import BaseModel
10
12
 
11
13
  OUTPUT_CHOICES = ("auto", "md", "json", "payload", "id")
12
14
 
15
+ #: Default output is the full JSON response while it stays within this many
16
+ #: characters on stdout. Past it the compact text rendering stands in, so one
17
+ #: command cannot flood a reading agent's context; a stderr note says so.
18
+ FULL_OUTPUT_LIMIT = 40_000
19
+ OUTPUT_LIMIT_ENV = "NDI_OUTPUT_LIMIT"
20
+
13
21
 
14
22
  def add_job_output_args(parser) -> None:
15
23
  parser.add_argument(
@@ -17,7 +25,10 @@ def add_job_output_args(parser) -> None:
17
25
  "--output",
18
26
  choices=OUTPUT_CHOICES,
19
27
  default="auto",
20
- help="Result shape (default auto). --json is an alias for json.",
28
+ help=(
29
+ f"Result shape. auto (default) prints the full JSON response, or the text rendering when that is over "
30
+ f"${OUTPUT_LIMIT_ENV} chars (default {FULL_OUTPUT_LIMIT:,}). --json is an alias for json."
31
+ ),
21
32
  )
22
33
  parser.add_argument("--save", default=None, metavar="PATH", help="Write one result to this file.")
23
34
  parser.add_argument("--out-dir", default=None, metavar="DIR", help="Write one file per input.")
@@ -30,6 +41,49 @@ def output_mode(args) -> str:
30
41
  return getattr(args, "output", None) or "auto"
31
42
 
32
43
 
44
+ def full_json(model: BaseModel) -> str:
45
+ return model.model_dump_json(indent=2, exclude_none=True)
46
+
47
+
48
+ def shortened(full: str, args, *, get_full: str = "rerun with --json") -> bool:
49
+ """Whether ``full`` is too long for stdout; if so, tell stderr how to get it.
50
+
51
+ A file target is never shortened — the limit protects a reader's context,
52
+ and ``--save`` / ``--out-dir`` asked for the whole thing. ``get_full`` names
53
+ the command that prints it whole.
54
+ """
55
+ if getattr(args, "save", None) or getattr(args, "out_dir", None):
56
+ return False
57
+ limit = output_limit()
58
+ if len(full) <= limit:
59
+ return False
60
+ print(
61
+ f"note: output shortened — the full response is {len(full):,} chars, over the {limit:,} limit "
62
+ f"(${OUTPUT_LIMIT_ENV}); {get_full} for all of it",
63
+ file=sys.stderr,
64
+ )
65
+ return True
66
+
67
+
68
+ def print_full_or_summary(model: BaseModel, args, summary: Callable[[], str]) -> int:
69
+ full = full_json(model)
70
+ print(summary() if shortened(full, args) else full)
71
+ return 0
72
+
73
+
74
+ def output_limit() -> int:
75
+ raw = os.environ.get(OUTPUT_LIMIT_ENV)
76
+ if raw is None or not raw.strip():
77
+ return FULL_OUTPUT_LIMIT
78
+ try:
79
+ limit = int(raw)
80
+ except ValueError:
81
+ limit = -1
82
+ if limit < 0:
83
+ raise ValueError(f"bad ${OUTPUT_LIMIT_ENV} {raw!r}: use a character count (0 always prints the text rendering)")
84
+ return limit
85
+
86
+
33
87
  def emit_text(text: str, args, *, default_stem: str, suffix: str) -> int:
34
88
  save = getattr(args, "save", None)
35
89
  out_dir = getattr(args, "out_dir", None)
@@ -77,20 +131,27 @@ def render_job(job: Job, args, *, auto: Callable[[Job], str], stem: str) -> int:
77
131
  print(job.job_id)
78
132
  return _content_failure(job)
79
133
  if mode == "json":
80
- code = emit_text(job.model_dump_json(indent=2, exclude_none=True), args, default_stem=stem, suffix=".json")
134
+ code = emit_text(full_json(job), args, default_stem=stem, suffix=".json")
81
135
  elif mode == "payload":
82
136
  if job.result is None:
83
137
  print("error: job has no result payload", file=sys.stderr)
84
138
  return 1
85
- code = emit_text(job.result.model_dump_json(indent=2, exclude_none=True), args, default_stem=stem, suffix=".json")
139
+ code = emit_text(full_json(job.result), args, default_stem=stem, suffix=".json")
86
140
  elif mode == "md":
87
141
  markdown = _parse_markdown(job)
88
142
  if markdown is None:
89
143
  print("error: this job has no markdown; use -o auto|json|payload|id", file=sys.stderr)
90
144
  return 1
91
145
  code = emit_text(markdown, args, default_stem=stem, suffix=".md")
92
- else:
146
+ elif isinstance(job.result, ParseResult):
147
+ # A parse renders the document itself, not a summary of the response,
148
+ # so it is never swapped for JSON.
93
149
  code = emit_text(auto(job), args, default_stem=stem, suffix=_auto_suffix(job, auto))
150
+ # Rerunning a job command submits (and bills) a new job; reading this one does not.
151
+ elif shortened(full := full_json(job), args, get_full=f"`ndi job {job.job_id} --json`"):
152
+ code = emit_text(auto(job), args, default_stem=stem, suffix=".txt")
153
+ else:
154
+ code = emit_text(full, args, default_stem=stem, suffix=".json")
94
155
  return code or _content_failure(job)
95
156
 
96
157
 
@@ -3,7 +3,8 @@
3
3
  The audience is a coding agent reading terminal output: every fact the
4
4
  response carries is either shown or explicitly counted, long free text is
5
5
  kept whole (summaries and answers are the payload), and nothing is styled.
6
- ``--json`` on any command bypasses all of this with the raw response.
6
+ Outside an agent run they print only when the full JSON response is over the
7
+ output limit (``_output.FULL_OUTPUT_LIMIT``); ``--json`` always prints it whole.
7
8
  """
8
9
 
9
10
  from __future__ import annotations
@@ -931,12 +932,15 @@ def search_job(job: Job) -> str:
931
932
 
932
933
 
933
934
  def je_testing_job(job: Job) -> str:
934
- """The answer, then the numbers behind it — a JET result read at a glance.
935
-
936
- Unlike :func:`search_job` this leads with ``figures``: the answer is prose a
937
- person reads, and the figures are the same content a reviewer can check.
938
- Each one names the receipt its numbers are readable out of, so
939
- ``ndi job <id> -o json`` is the next step rather than the only one.
935
+ """Whether the requested tests ran, then the answer and the numbers behind it.
936
+
937
+ A run that did not perform every requested test says so first, with each
938
+ test's outcome and blocker: its prose answer can read like a finding when
939
+ nothing was measured. Unlike :func:`search_job` it then leads with
940
+ ``figures``: the answer is prose a person reads, and the figures are the
941
+ same content a reviewer can check. Each one names the receipt its numbers
942
+ are readable out of, so ``ndi job <id> -o json`` is the next step rather
943
+ than the only one.
940
944
  """
941
945
  result = job.result
942
946
  if not isinstance(result, JetResult):
@@ -944,6 +948,13 @@ def je_testing_job(job: Job) -> str:
944
948
  lines: list[str] = []
945
949
  if result.degraded:
946
950
  lines.append(f"degraded: {result.degraded}")
951
+ if result.execution_status and result.execution_status != "performed":
952
+ lines.append(f"execution: {result.execution_status}")
953
+ for criterion in result.criteria:
954
+ outcome = criterion.status
955
+ if criterion.status == "incomplete":
956
+ outcome += f" ({criterion.blocker or criterion.incomplete_reason or 'no reason given'})"
957
+ lines.append(f" {criterion.description or criterion.criterion_id}: {outcome}")
947
958
  if result.answer:
948
959
  lines.append(result.answer.strip())
949
960
  if result.population_statement:
@@ -964,8 +975,6 @@ def je_testing_job(job: Job) -> str:
964
975
  for discrepancy in result.discrepancies:
965
976
  lines.append(f"discrepancy: {discrepancy}")
966
977
  lines.append(f"evidences ({len(result.evidences)}) | receipts ({len(result.sql_receipts)})")
967
- if result.execution_status and result.execution_status != "performed":
968
- lines.append(f"execution: {result.execution_status}")
969
978
  if result.quality and result.quality.status != "clean":
970
979
  lines.append(f"quality: {result.quality.status}")
971
980
  if result.exhausted:
@@ -103,7 +103,9 @@ def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser
103
103
  read.add_argument("--component", default=None, help="One tab or section, named as file-metadata lists it.")
104
104
  read.add_argument("--pages", default=None, metavar="SPEC", help="1-based pages, e.g. 3 or 1-5,8. Not for spreadsheets.")
105
105
  read.add_argument("--max-chars", type=int, default=None, help="Character budget for the returned text.")
106
- read.set_defaults(run=_run_read_file, render=_render_read_file, family="tool", needs_workspace=True, needs_client=True)
106
+ read.set_defaults(
107
+ run=_run_read_file, render=_render_read_file, verbatim=True, family="tool", needs_workspace=True, needs_client=True
108
+ )
107
109
 
108
110
  ask_file = sub.add_parser(
109
111
  WorkspaceCommand.ASK_FILE,
@@ -264,7 +266,9 @@ def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser
264
266
  ),
265
267
  )
266
268
  kg_answer.add_argument("query", help="The question, verbatim.")
267
- kg_answer.set_defaults(run=_run_kg_answer, render=_render_kg_answer, family="tool", needs_workspace=True, needs_client=True)
269
+ kg_answer.set_defaults(
270
+ run=_run_kg_answer, render=_render_kg_answer, verbatim=True, family="tool", needs_workspace=True, needs_client=True
271
+ )
268
272
 
269
273
 
270
274
  def _run_folder_metadata(tools: SyncTools, workspace_id: str, args: argparse.Namespace):
@@ -18,7 +18,7 @@ from ndi_sdk.resources.jobs import check_terminal
18
18
  from ndi_cli import _render
19
19
  from ndi_cli._config import ConfigError, config_path, read_config_values, resolve_settings
20
20
  from ndi_cli._jobs import wait_and_report, wait_job
21
- from ndi_cli._output import add_job_output_args, emit_text, output_mode, _content_failure
21
+ from ndi_cli._output import _content_failure, add_job_output_args, emit_text, full_json, output_mode, print_full_or_summary
22
22
  from ndi_cli._sources import collect_workspace_uploads, folder_upload_prefix, workspace_dest_path
23
23
 
24
24
  DEFAULT_UPLOAD_PARALLEL = 8
@@ -236,9 +236,8 @@ def _workspace_id(args: argparse.Namespace, fallback: str) -> str:
236
236
 
237
237
  def _dump_or_text(model, args, *, text: str, stem: str) -> int:
238
238
  if output_mode(args) == "json":
239
- return emit_text(model.model_dump_json(indent=2, exclude_none=True), args, default_stem=stem, suffix=".json")
240
- print(text)
241
- return 0
239
+ return emit_text(full_json(model), args, default_stem=stem, suffix=".json")
240
+ return print_full_or_summary(model, args, lambda: text)
242
241
 
243
242
 
244
243
  def _finish_job(client: NdiClient, job: Job, args: argparse.Namespace, auto, stem: str) -> int:
@@ -270,7 +269,11 @@ def _run_ws_global_context(client: NdiClient, workspace_id: str, args: argparse.
270
269
  workspace = client.workspaces.update(target, global_context=args.text)
271
270
  else:
272
271
  workspace = client.workspaces.get(target)
273
- return _dump_or_text(workspace, args, text=workspace.global_context or "(no global context)", stem="workspace")
272
+ # The context text is the content asked for, not a summary of the workspace.
273
+ if output_mode(args) == "json":
274
+ return emit_text(full_json(workspace), args, default_stem="workspace", suffix=".json")
275
+ print(workspace.global_context or "(no global context)")
276
+ return 0
274
277
 
275
278
 
276
279
  def _run_ws_stats(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
File without changes
File without changes
File without changes