nace-cli 0.1.0__tar.gz → 0.3.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: nace-cli
3
- Version: 0.1.0
3
+ Version: 0.3.0
4
4
  Summary: Nace CLI: calibrated decisions and document jobs from the shell
5
5
  Keywords: drex,nace,cli,document-intelligence
6
6
  Author: Nace AI
@@ -16,7 +16,7 @@ Classifier: Programming Language :: Python :: 3.12
16
16
  Classifier: Programming Language :: Python :: 3.13
17
17
  Classifier: Programming Language :: Python :: 3.14
18
18
  Classifier: Typing :: Typed
19
- Requires-Dist: nace-sdk>=0.1,<1
19
+ Requires-Dist: nace-sdk>=0.3.0,<1
20
20
  Requires-Dist: truststore>=0.10.4,<1
21
21
  Requires-Python: >=3.11
22
22
  Project-URL: Homepage, https://console.nace.ai
@@ -83,7 +83,7 @@ nace upload-grant --path inbox/scan.pdf --max-bytes 10000000 # a one-use uploa
83
83
  nace upload-session --path big.csv --total-size-bytes 734003200
84
84
  ```
85
85
 
86
- `upload-grant` and `upload-session` print JSON for another client to upload with: the CLI doesn't send parts or complete a session itself, and `nace upload` (or a local-file `SOURCE`) does every step for you.
86
+ `nace upload DIR` uploads every file under a folder, keeping its path inside the folder under `--path` (default: the folder's name), `-j` at a time (default 8). Hidden files and folders are left out, and a file the document service refuses as `unsupported_file_type` is skipped: stderr lists them when the run ends. `--ingest` (with `--workspace`) then ingests what was uploaded, 1000 files per ingestion. `upload-grant` and `upload-session` print JSON for another client to upload with: the CLI doesn't send parts or complete a session itself, and `nace upload` (or a local-file `SOURCE`) does every step for you.
87
87
 
88
88
  ## Jobs and their files
89
89
 
@@ -111,13 +111,42 @@ nace schema sch_... > invoice.schema.json # the JSON Schema itself; --json for t
111
111
  nace schema-version sch_... 1
112
112
  ```
113
113
 
114
+ ## Workspaces and search
115
+
116
+ ```bash
117
+ nace workspace-create fy25-audit
118
+ nace workspace-use <workspace_id> # later commands use it; --workspace ID or $NACE_WORKSPACE_ID win
119
+ nace upload ./report.pdf --workspace <workspace_id> --path reports/2025/report.pdf
120
+ nace upload ./fy25 --workspace <workspace_id> --ingest # every file under a folder, 8 at a time, then ingested
121
+ nace ingest --path-prefix reports/ # makes the files searchable
122
+ nace workspace-files --status failed
123
+ nace search "What were total liabilities at year end?"
124
+ nace qa "What was total freight spend in FY2024?" # stderr: session <id>; follow up with --session
125
+ ```
126
+
127
+ `search` and `qa` print the answer as the ndi CLI did: the answer, `evidences (N):` with each evidence as `1. path, page N` and its quote indented under it, then the Ground jobs to poll, the tables the answer refers to and any catalog rows; the route goes to stderr. Lists (`jobs`, `workspaces`, `workspace-files`) print a `<what> (N):` header, an indented line per item and `next: <cursor>` when more pages follow. Uploads go to a workspace only with `--workspace`; without it, to the server. `nace workspace-delete` takes the workspace only from `--workspace`.
128
+
129
+ ## Domains
130
+
131
+ A domain is the two-level taxonomy (group and subgroup classes) a workspace classifies its files against. Nace provides some; your account can author its own. A draft is edited in place and published once; a published or Nace-provided domain is frozen, so duplicate it and edit the copy.
132
+
133
+ ```bash
134
+ nace domains list --owner platform --status published # domains (N):, then <domain_id> <slug> v<n> <status> <owner> <display_name>
135
+ nace domains get --id <domain_id> # the domain's line, then its classes
136
+ nace domains create --slug leases --display-name "Lease files" --taxonomy leases.taxonomy.json # --taxonomy: JSON, @FILE or -
137
+ nace domains update --id <domain_id> --display-name "Leases"
138
+ nace domains publish --id <domain_id> # one-way; a taxonomy that fails validation exits 1 and lists each error
139
+ nace domains duplicate --id <domain_id> # a draft copy of any domain you see
140
+ nace workspace-create fy25-leases --domain leases # a published domain's slug; it can't be changed later
141
+ ```
142
+
114
143
  ## Output and exit codes
115
144
 
116
- Stdout carries the payload and stderr the status, so `nace parse doc.pdf > doc.md` captures only the document. Exit `0` on success, `1` on an API or job failure (the message names the error type, code, request id and up to five fields that failed validation), `2` on a usage or configuration error.
145
+ Stdout carries the payload and stderr the status, so `nace parse doc.pdf > doc.md` captures only the document. A wait that runs out prints the job id on stdout. Exit `0` on success, `1` on an API or job failure (including an ingestion that failed a file, from `nace ingest`, `nace upload --ingest` or `nace job`) (the message names the error type, code, request id and up to five fields that failed validation), `2` on a usage or configuration error.
117
146
 
118
- Requests are retried up to twice on a `408`, `429`, `5xx`, dropped connection or timeout, but only while the retry can start within 30 s of the first attempt, so a request that fails after 30 s, such as one that hit `--timeout` (120 s) or a slow `529`, is not retried. `schema-create` and `schema-version-add` are never retried.
147
+ Requests are retried up to twice on a `408`, `429`, `5xx`, dropped connection or timeout, but only while the retry can start within 30 s of the first attempt, so a request that fails after 30 s, such as one that hit `--timeout` (120 s) or a slow `529`, is not retried. `schema-create`, `schema-version-add`, `workspace-create`, `domains create`, `domains publish` and `domains duplicate` are never retried.
119
148
 
120
- Full guide: https://console.nace.ai/docs/developer-tools/cli (install, sign-in, `decide`, documents, uploads, jobs, schemas, exit codes).
149
+ Full guide: https://console.nace.ai/docs/developer-tools/cli (install, sign-in, `decide`, documents, uploads, jobs, schemas, workspaces, search, exit codes).
121
150
 
122
151
  ## License
123
152
 
@@ -58,7 +58,7 @@ nace upload-grant --path inbox/scan.pdf --max-bytes 10000000 # a one-use uploa
58
58
  nace upload-session --path big.csv --total-size-bytes 734003200
59
59
  ```
60
60
 
61
- `upload-grant` and `upload-session` print JSON for another client to upload with: the CLI doesn't send parts or complete a session itself, and `nace upload` (or a local-file `SOURCE`) does every step for you.
61
+ `nace upload DIR` uploads every file under a folder, keeping its path inside the folder under `--path` (default: the folder's name), `-j` at a time (default 8). Hidden files and folders are left out, and a file the document service refuses as `unsupported_file_type` is skipped: stderr lists them when the run ends. `--ingest` (with `--workspace`) then ingests what was uploaded, 1000 files per ingestion. `upload-grant` and `upload-session` print JSON for another client to upload with: the CLI doesn't send parts or complete a session itself, and `nace upload` (or a local-file `SOURCE`) does every step for you.
62
62
 
63
63
  ## Jobs and their files
64
64
 
@@ -86,13 +86,42 @@ nace schema sch_... > invoice.schema.json # the JSON Schema itself; --json for t
86
86
  nace schema-version sch_... 1
87
87
  ```
88
88
 
89
+ ## Workspaces and search
90
+
91
+ ```bash
92
+ nace workspace-create fy25-audit
93
+ nace workspace-use <workspace_id> # later commands use it; --workspace ID or $NACE_WORKSPACE_ID win
94
+ nace upload ./report.pdf --workspace <workspace_id> --path reports/2025/report.pdf
95
+ nace upload ./fy25 --workspace <workspace_id> --ingest # every file under a folder, 8 at a time, then ingested
96
+ nace ingest --path-prefix reports/ # makes the files searchable
97
+ nace workspace-files --status failed
98
+ nace search "What were total liabilities at year end?"
99
+ nace qa "What was total freight spend in FY2024?" # stderr: session <id>; follow up with --session
100
+ ```
101
+
102
+ `search` and `qa` print the answer as the ndi CLI did: the answer, `evidences (N):` with each evidence as `1. path, page N` and its quote indented under it, then the Ground jobs to poll, the tables the answer refers to and any catalog rows; the route goes to stderr. Lists (`jobs`, `workspaces`, `workspace-files`) print a `<what> (N):` header, an indented line per item and `next: <cursor>` when more pages follow. Uploads go to a workspace only with `--workspace`; without it, to the server. `nace workspace-delete` takes the workspace only from `--workspace`.
103
+
104
+ ## Domains
105
+
106
+ A domain is the two-level taxonomy (group and subgroup classes) a workspace classifies its files against. Nace provides some; your account can author its own. A draft is edited in place and published once; a published or Nace-provided domain is frozen, so duplicate it and edit the copy.
107
+
108
+ ```bash
109
+ nace domains list --owner platform --status published # domains (N):, then <domain_id> <slug> v<n> <status> <owner> <display_name>
110
+ nace domains get --id <domain_id> # the domain's line, then its classes
111
+ nace domains create --slug leases --display-name "Lease files" --taxonomy leases.taxonomy.json # --taxonomy: JSON, @FILE or -
112
+ nace domains update --id <domain_id> --display-name "Leases"
113
+ nace domains publish --id <domain_id> # one-way; a taxonomy that fails validation exits 1 and lists each error
114
+ nace domains duplicate --id <domain_id> # a draft copy of any domain you see
115
+ nace workspace-create fy25-leases --domain leases # a published domain's slug; it can't be changed later
116
+ ```
117
+
89
118
  ## Output and exit codes
90
119
 
91
- Stdout carries the payload and stderr the status, so `nace parse doc.pdf > doc.md` captures only the document. Exit `0` on success, `1` on an API or job failure (the message names the error type, code, request id and up to five fields that failed validation), `2` on a usage or configuration error.
120
+ Stdout carries the payload and stderr the status, so `nace parse doc.pdf > doc.md` captures only the document. A wait that runs out prints the job id on stdout. Exit `0` on success, `1` on an API or job failure (including an ingestion that failed a file, from `nace ingest`, `nace upload --ingest` or `nace job`) (the message names the error type, code, request id and up to five fields that failed validation), `2` on a usage or configuration error.
92
121
 
93
- Requests are retried up to twice on a `408`, `429`, `5xx`, dropped connection or timeout, but only while the retry can start within 30 s of the first attempt, so a request that fails after 30 s, such as one that hit `--timeout` (120 s) or a slow `529`, is not retried. `schema-create` and `schema-version-add` are never retried.
122
+ Requests are retried up to twice on a `408`, `429`, `5xx`, dropped connection or timeout, but only while the retry can start within 30 s of the first attempt, so a request that fails after 30 s, such as one that hit `--timeout` (120 s) or a slow `529`, is not retried. `schema-create`, `schema-version-add`, `workspace-create`, `domains create`, `domains publish` and `domains duplicate` are never retried.
94
123
 
95
- Full guide: https://console.nace.ai/docs/developer-tools/cli (install, sign-in, `decide`, documents, uploads, jobs, schemas, exit codes).
124
+ Full guide: https://console.nace.ai/docs/developer-tools/cli (install, sign-in, `decide`, documents, uploads, jobs, schemas, workspaces, search, exit codes).
96
125
 
97
126
  ## License
98
127
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nace-cli"
3
- version = "0.1.0"
3
+ version = "0.3.0"
4
4
  description = "Nace CLI: calibrated decisions and document jobs from the shell"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -24,7 +24,7 @@ classifiers = [
24
24
  "Typing :: Typed",
25
25
  ]
26
26
  dependencies = [
27
- "nace-sdk>=0.1,<1",
27
+ "nace-sdk>=0.3.0,<1",
28
28
  "truststore>=0.10.4,<1",
29
29
  ]
30
30
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nace-cli"
3
- version = "0.1.0"
3
+ version = "0.3.0"
4
4
  description = "Nace CLI: calibrated decisions and document jobs from the shell"
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
- "nace-sdk>=0.1,<1",
30
+ "nace-sdk>=0.3.0,<1",
31
31
  "truststore>=0.10.4,<1",
32
32
  ]
33
33
 
@@ -6,4 +6,4 @@ over ``nace-sdk``. Credentials come from
6
6
  ``$NACE_API_KEY`` / ``$NACE_BASE_URL`` or ``~/.nace/config.toml``.
7
7
  """
8
8
 
9
- __version__ = "0.1.0"
9
+ __version__ = "0.3.0"
@@ -0,0 +1,241 @@
1
+ """``nace browser run|get|list|cancel|artifacts``: a cloud browser agent that does a task on the sites you allow.
2
+
3
+ A run is billed once, when it ends: its Drex Max and LLM tokens and its browser seconds, never more
4
+ than ``--max-cost``. Reading and listing runs is free;
5
+ a run that fails, times out, or is cancelled before completion is not charged.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import sys
12
+ from typing import Any
13
+ from urllib.parse import urlsplit
14
+
15
+ from nace_sdk import BROWSER_RUN_STATUSES, BrowserRun, BrowserRunRef, NaceBrowserRunTimeoutError, NaceClient
16
+ from nace_sdk._core.endpoints import BROWSER_RUN_MAX_PAGE_SIZE, DEFAULT_BROWSER_WAIT_SECONDS
17
+
18
+ from nace_cli._inputs import read_json
19
+ from nace_cli._output import to_json
20
+ from nace_cli._schemas import _add_page_args, _emit_page
21
+
22
+ #: What `--max-cost`, `--max-seconds` and `--max-llm-calls` take, as Drex accepts them.
23
+ MAX_COST_RANGE = (0.01, 1.0)
24
+ MAX_SECONDS_RANGE = (1, 1800)
25
+ MAX_LLM_CALLS_RANGE = (1, 100)
26
+ #: How much of a run's task a `nace browser list` line shows.
27
+ _TASK_CHARS = 60
28
+
29
+
30
+ def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser) -> None:
31
+ browser = sub.add_parser(
32
+ "browser",
33
+ help="Browser runs: a cloud browser agent does a task on the sites you allow and returns an answer.",
34
+ description="Browser runs: a cloud browser agent does a task on the sites you allow and returns an answer.",
35
+ )
36
+ commands = browser.add_subparsers(dest="browser_command", metavar="{run,get,list,cancel,artifacts}", required=True)
37
+
38
+ run = commands.add_parser("run", parents=[common], help="Start a run and wait for it; print its answer.")
39
+ run.add_argument("task", help="What the agent should do, in plain language: 1 to 4000 characters.")
40
+ run.add_argument("--start-url", required=True, metavar="URL", help="The page to open first: an http:// or https:// URL.")
41
+ run.add_argument(
42
+ "--allow",
43
+ dest="allowed_origins",
44
+ action="append",
45
+ default=None,
46
+ metavar="ORIGIN",
47
+ help="An origin the browser may load, such as https://shop.example.com. Repeatable. Without it, only the start URL's origin.",
48
+ )
49
+ run.add_argument(
50
+ "--answer-schema", default=None, metavar="SCHEMA", help="JSON Schema for a structured answer: a file path, @FILE, inline JSON, or -."
51
+ )
52
+ run.add_argument(
53
+ "--max-cost", type=float, default=None, metavar="USD", help="The most the run may be charged, in US dollars, 0.01 up to 1 (default 0.04)."
54
+ )
55
+ run.add_argument("--max-seconds", type=int, default=None, metavar="N", help="How long the run may browse, 1 to 1800 seconds (default 600).")
56
+ run.add_argument(
57
+ "--max-llm-calls", type=int, default=None, metavar="N", help="How many planning and reading calls it may make, 1 to 100 (default 20)."
58
+ )
59
+ run.add_argument(
60
+ "--metadata", action="append", default=None, metavar="KEY=VALUE", help="A string of your own, returned with the run. Repeatable."
61
+ )
62
+ run.add_argument("--idempotency-key", default=None, metavar="KEY", help="The same key returns the same run instead of starting another.")
63
+ run.add_argument("--no-wait", action="store_true", help="Print the run id and return at once.")
64
+ run.add_argument(
65
+ "--wait-timeout",
66
+ type=float,
67
+ default=DEFAULT_BROWSER_WAIT_SECONDS,
68
+ metavar="SECONDS",
69
+ help=f"How long to wait for the run (default {DEFAULT_BROWSER_WAIT_SECONDS:.0f}s). It keeps going server-side after.",
70
+ )
71
+ run.set_defaults(run=run_run, family="client")
72
+
73
+ get = commands.add_parser("get", parents=[common], help="Read one run: its status, answer and cost.")
74
+ get.add_argument("run_id", help="The run's id (brun_...).")
75
+ get.set_defaults(run=run_get, family="client")
76
+
77
+ listing = commands.add_parser("list", parents=[common], help="List your runs, newest first.")
78
+ listing.add_argument("--status", choices=BROWSER_RUN_STATUSES, default=None, help="Only runs in this status.")
79
+ _add_page_args(listing, "Runs", maximum=BROWSER_RUN_MAX_PAGE_SIZE)
80
+ listing.set_defaults(run=run_list, family="client")
81
+
82
+ cancel = commands.add_parser("cancel", parents=[common], help="Cancel a queued or running run.")
83
+ cancel.add_argument("run_id", help="The run's id (brun_...).")
84
+ cancel.set_defaults(run=run_cancel, family="client")
85
+
86
+ artifacts = commands.add_parser("artifacts", parents=[common], help="List a run's artifacts, such as screenshots, with their links.")
87
+ artifacts.add_argument("run_id", help="The run's id (brun_...).")
88
+ artifacts.set_defaults(run=run_artifacts, family="client")
89
+
90
+
91
+ def start_origin(start_url: str) -> str:
92
+ """The origin of ``start_url`` (``https://shop.example.com``), which a run without ``--allow`` may load.
93
+
94
+ Raises:
95
+ ValueError: Not an ``http://`` or ``https://`` URL with a host.
96
+ """
97
+ parts = urlsplit(start_url.strip())
98
+ host = parts.netloc.rpartition("@")[2].lower()
99
+ if parts.scheme.lower() not in ("http", "https") or not host:
100
+ raise ValueError(f"--start-url must be an http:// or https:// URL, got {start_url!r}")
101
+ return f"{parts.scheme.lower()}://{host}"
102
+
103
+
104
+ def _in_range(flag: str, value: float | None, bounds: tuple[float, float]) -> None:
105
+ low, high = bounds
106
+ if value is not None and not low <= value <= high:
107
+ raise ValueError(f"{flag} must be between {low:g} and {high:g}, got {value:g}")
108
+
109
+
110
+ def _limits(args: argparse.Namespace) -> dict[str, Any] | None:
111
+ """The limits named on the command line; one left out takes Drex's default."""
112
+ _in_range("--max-cost", args.max_cost, MAX_COST_RANGE)
113
+ _in_range("--max-seconds", args.max_seconds, MAX_SECONDS_RANGE)
114
+ _in_range("--max-llm-calls", args.max_llm_calls, MAX_LLM_CALLS_RANGE)
115
+ limits = {"max_cost_usd": args.max_cost, "max_seconds": args.max_seconds, "max_llm_calls": args.max_llm_calls}
116
+ named = {key: value for key, value in limits.items() if value is not None}
117
+ return named or None
118
+
119
+
120
+ def _metadata(pairs: list[str] | None) -> dict[str, str] | None:
121
+ if not pairs:
122
+ return None
123
+ metadata: dict[str, str] = {}
124
+ for pair in pairs:
125
+ key, sep, value = pair.partition("=")
126
+ if not sep or not key:
127
+ raise ValueError(f"--metadata needs KEY=VALUE, got {pair!r}")
128
+ metadata[key] = value
129
+ return metadata
130
+
131
+
132
+ def _status_line(run: BrowserRun | BrowserRunRef) -> str:
133
+ kind = run.outcome.kind if isinstance(run, BrowserRun) and run.outcome is not None else None
134
+ return f"browser run {run.id} {run.status}" + (f" ({kind})" if kind and kind != run.status else "")
135
+
136
+
137
+ def _run_json(run: BrowserRun | BrowserRunRef) -> str:
138
+ return run.model_dump_json(indent=2, exclude_none=True)
139
+
140
+
141
+ def _print_run(run: BrowserRun, args: argparse.Namespace) -> None:
142
+ """The run whole with ``--json``; otherwise its answer on stdout and its status, reason and cost on stderr."""
143
+ if args.json:
144
+ print(_run_json(run))
145
+ return
146
+ print(_status_line(run), file=sys.stderr)
147
+ if run.outcome is not None and run.outcome.reason:
148
+ print(f" {run.outcome.reason}", file=sys.stderr)
149
+ if run.final_url:
150
+ print(f" final url: {run.final_url}", file=sys.stderr)
151
+ if run.usage is not None and run.usage.cost_usd is not None:
152
+ print(f" cost: ${run.usage.cost_usd:.4f}", file=sys.stderr)
153
+ if run.answer is not None:
154
+ print(to_json(run.answer))
155
+ elif run.answer_text:
156
+ print(run.answer_text)
157
+
158
+
159
+ def run_run(client: NaceClient, args: argparse.Namespace) -> int:
160
+ """Start a run, then wait for it unless ``--no-wait``. A run that ended other than ``succeeded`` exits ``1``."""
161
+ origin = start_origin(args.start_url)
162
+ origins = args.allowed_origins or [origin]
163
+ limits = _limits(args)
164
+ metadata = _metadata(args.metadata)
165
+ answer_schema = None
166
+ if args.answer_schema is not None:
167
+ answer_schema = read_json(args.answer_schema, what="--answer-schema")
168
+ if not isinstance(answer_schema, dict):
169
+ raise ValueError("--answer-schema must be a JSON object")
170
+ if not args.wait_timeout >= 0:
171
+ raise ValueError("--wait-timeout must be 0 or more seconds")
172
+ runs = client.browser_runs
173
+ created = runs.create(
174
+ task=args.task,
175
+ start_url=args.start_url,
176
+ allowed_origins=origins,
177
+ answer_schema=answer_schema,
178
+ limits=limits,
179
+ metadata=metadata,
180
+ idempotency_key=args.idempotency_key,
181
+ )
182
+ print(_status_line(created), file=sys.stderr)
183
+ if args.no_wait:
184
+ print(_run_json(created) if args.json else created.id)
185
+ return 0
186
+ try:
187
+ run = runs.wait(created.id, timeout=args.wait_timeout)
188
+ except NaceBrowserRunTimeoutError as exc:
189
+ print(f"error: {exc}; read it later with `nace browser get {exc.run.id}`", file=sys.stderr)
190
+ print(_run_json(exc.run) if args.json else exc.run.id)
191
+ return 1
192
+ _print_run(run, args)
193
+ if run.status == "succeeded":
194
+ return 0
195
+ kind = f" [{run.outcome.kind}]" if run.outcome is not None else ""
196
+ print(f"error: browser run {run.id} ended {run.status}{kind}", file=sys.stderr)
197
+ return 1
198
+
199
+
200
+ def run_get(client: NaceClient, args: argparse.Namespace) -> int:
201
+ _print_run(client.browser_runs.get(args.run_id), args)
202
+ return 0
203
+
204
+
205
+ def _list_line(run: BrowserRun) -> str:
206
+ task = run.task if len(run.task) <= _TASK_CHARS else f"{run.task[: _TASK_CHARS - 1]}…"
207
+ cost = f"${run.usage.cost_usd:.4f}" if run.usage is not None and run.usage.cost_usd is not None else "-"
208
+ return f"{run.id} {run.status} {cost} {run.created_at} {task}".rstrip()
209
+
210
+
211
+ def run_list(client: NaceClient, args: argparse.Namespace) -> int:
212
+ runs = client.browser_runs
213
+ return _emit_page(
214
+ args,
215
+ "runs",
216
+ lambda: runs.list(limit=args.limit, status=args.status, cursor=args.cursor),
217
+ lambda: runs.iter_all(status=args.status),
218
+ summary=_list_line,
219
+ )
220
+
221
+
222
+ def run_cancel(client: NaceClient, args: argparse.Namespace) -> int:
223
+ ref = client.browser_runs.cancel(args.run_id)
224
+ if args.json:
225
+ print(_run_json(ref))
226
+ else:
227
+ print(_status_line(ref), file=sys.stderr)
228
+ return 0
229
+
230
+
231
+ def run_artifacts(client: NaceClient, args: argparse.Namespace) -> int:
232
+ listed = client.browser_runs.artifacts(args.run_id)
233
+ if args.json:
234
+ print(to_json({"items": [item.model_dump(mode="json", exclude_none=True) for item in listed.items]}))
235
+ return 0
236
+ for item in listed.items:
237
+ step = f"step {item.step}" if item.step is not None else "-"
238
+ print(f"{item.kind} {step} {item.url or '-'}")
239
+ if not listed.items:
240
+ print("no artifacts", file=sys.stderr)
241
+ return 0
@@ -16,7 +16,8 @@ from nace_sdk import NaceAPIConnectionError, NaceAPIError, NaceClient, NaceError
16
16
  from nace_sdk.constants import LOG_LEVEL_ENV, read_env
17
17
  from nace_sdk.credentials import CredentialsError, resolve_credentials
18
18
 
19
- from nace_cli import _decide, _docops, _jobs, _login, _schemas
19
+ from nace_cli import _browser, _decide, _docops, _domains, _jobs, _login, _schemas, _search, _upload, _workspaces
20
+ from nace_cli._inputs import check_one_stdin_flag
20
21
  from nace_cli._output import CommandFailed
21
22
 
22
23
  #: Long enough for a decision (Drex holds one up to 60 s) and for a document
@@ -46,6 +47,7 @@ def main(argv: list[str] | None = None) -> int:
46
47
  return 2
47
48
  _log_to_stderr()
48
49
  try:
50
+ check_one_stdin_flag(args)
49
51
  if args.family == "standalone":
50
52
  return args.run(args)
51
53
  credentials = resolve_credentials()
@@ -80,6 +82,14 @@ def _validation_lines(exc: NaceAPIError) -> list[dict[str, Any]]:
80
82
  """Where a request failed validation: ``error.issues``, else the document routes' ``error.detail.errors``."""
81
83
  if exc.issues:
82
84
  return exc.issues
85
+ if isinstance(exc.detail, dict) and isinstance(exc.detail.get("validation"), dict):
86
+ # A refused domain publish: `detail.validation.errors` is each `{code, path, message}` to fix.
87
+ found = exc.detail["validation"].get("errors")
88
+ return [
89
+ {"path": entry.get("path") or "", "message": f"{entry.get('message') or ''} ({entry.get('code') or 'invalid'})"}
90
+ for entry in found or []
91
+ if isinstance(entry, dict)
92
+ ]
83
93
  errors = exc.detail.get("errors") if isinstance(exc.detail, dict) else None
84
94
  if not isinstance(errors, list):
85
95
  return []
@@ -93,6 +103,15 @@ def _validation_lines(exc: NaceAPIError) -> list[dict[str, Any]]:
93
103
  return lines
94
104
 
95
105
 
106
+ #: What to do next for the workspace and search errors, by code.
107
+ _WORKSPACE_HINTS = {
108
+ "workspace_not_found": "`nace workspaces` lists the workspaces your key can use; `nace workspace-use ID` saves one.",
109
+ "session_busy": "The conversation's last turn is still running: wait for it with `nace job <id> --wait`, then ask again.",
110
+ "rate_limited": "Automatic searches and QA turns share a limit of 60 per account every 10 minutes.",
111
+ "access_denied": "Your key can use the workspace but not do this: ask an account owner or admin, or whoever created the workspace.",
112
+ }
113
+
114
+
96
115
  def report_status_error(exc: NaceAPIError) -> None:
97
116
  """One line a person can act on, plus where a request failed validation."""
98
117
  label = "/".join(str(part) for part in (exc.type, exc.code) if part)
@@ -112,6 +131,16 @@ def report_status_error(exc: NaceAPIError) -> None:
112
131
  print(f" Pay the unpaid invoice at {billing}", file=sys.stderr)
113
132
  else:
114
133
  print(f" Top up or add a card at {billing}", file=sys.stderr)
134
+ # An older nace-sdk (>=0.1) has no `idempotency_key` on its errors.
135
+ resume_key = getattr(exc, "idempotency_key", None)
136
+ if exc.code == "job_pending" and resume_key:
137
+ # The job may have started and be billed; the same key and body resume it instead of creating another.
138
+ print(f" The job may still be running. Rerun the same command with --idempotency-key {resume_key} to resume it.", file=sys.stderr)
139
+ hint = _WORKSPACE_HINTS.get(exc.code or "")
140
+ if hint:
141
+ print(f" {hint}", file=sys.stderr)
142
+ if exc.code == "rate_limited" and (wait := exc.headers.get("retry-after")):
143
+ print(f" Run it again in {wait}s.", file=sys.stderr)
115
144
  if exc.code == "path_conflict":
116
145
  print(
117
146
  " Upload to another path (--upload-path, or --path on `nace upload`), or pass --on-conflict new_version to add a version of that file.",
@@ -123,7 +152,7 @@ def build_parser() -> argparse.ArgumentParser:
123
152
  parser = argparse.ArgumentParser(
124
153
  prog="nace",
125
154
  description=(
126
- "Nace CLI: calibrated decisions and document jobs. Credentials come from "
155
+ "Nace CLI: calibrated decisions, document jobs, workspaces, search and browser runs. Credentials come from "
127
156
  "$NACE_API_KEY / $NACE_BASE_URL or ~/.nace/config.toml (`nace login`). "
128
157
  "Set $NACE_LOG_LEVEL=debug to log each request to stderr."
129
158
  ),
@@ -141,9 +170,14 @@ def build_parser() -> argparse.ArgumentParser:
141
170
  sub = parser.add_subparsers(dest="command")
142
171
  _login.add_parsers(sub)
143
172
  _decide.add_parsers(sub, common)
173
+ _upload.add_parsers(sub, common)
144
174
  _docops.add_parsers(sub, common)
145
175
  _jobs.add_parsers(sub, common)
146
176
  _schemas.add_parsers(sub, common)
177
+ _workspaces.add_parsers(sub, common)
178
+ _domains.add_parsers(sub, common)
179
+ _search.add_parsers(sub, common)
180
+ _browser.add_parsers(sub, common)
147
181
  return parser
148
182
 
149
183
 
@@ -3,14 +3,27 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import argparse
6
+ import hashlib
6
7
  import sys
8
+ from pathlib import Path
7
9
  from typing import Any
8
10
 
9
- from nace_sdk import Choice, ChoiceAnswer, ListModelsResponse, NaceClient, Noul, NoulAnswer, Score, ScoreAnswer, SystemOneResponse
11
+ from nace_sdk import Choice, ChoiceAnswer, ListModelsResponse, NaceClient, Noul, NoulAnswer, Score, ScoreAnswer, SystemOneResponse, image_input
12
+ from nace_sdk.images import _media_type
10
13
 
14
+ from nace_cli._docops import parse_ws_source, upload_file, workspace_source
11
15
  from nace_cli._inputs import read_json, read_text
12
16
  from nace_cli._output import to_json
13
17
 
18
+ #: The only model that reads images, and the one `--image` picks when no model is named.
19
+ IMAGE_MODEL = "drex-max"
20
+ #: Drex Max reads at most this many images per request.
21
+ MAX_IMAGES = 8
22
+ #: Local images up to this size are sent inline; larger ones are uploaded first (8 inline fit the 4 MiB body).
23
+ INLINE_IMAGE_BYTES = 256 * 1024
24
+ #: Where `--image` uploads larger images, named by their content.
25
+ IMAGE_UPLOAD_FOLDER = "drex-max-images"
26
+
14
27
 
15
28
  def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser) -> None:
16
29
  decide = sub.add_parser(
@@ -31,6 +44,18 @@ def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser
31
44
  decide.add_argument("--score", action="append", default=[], metavar="NAME=LOW,...,HIGH[: INSTRUCTIONS]", help="Ordered levels.")
32
45
  decide.add_argument("--questions", default=None, metavar="JSON", help="Questions object: inline JSON, @FILE or a path.")
33
46
  decide.add_argument("--body", default=None, metavar="JSON", help="A whole request body; other flags override its fields.")
47
+ decide.add_argument(
48
+ "--image",
49
+ dest="images",
50
+ action="append",
51
+ default=[],
52
+ metavar="IMAGE",
53
+ help=(
54
+ f"An image for Drex Max (PNG, JPEG or WebP); repeatable, up to {MAX_IMAGES}, read in order as Image 1, Image 2... "
55
+ f"A local file up to {INLINE_IMAGE_BYTES // 1024} KiB is sent inline; a larger one is uploaded first. "
56
+ f"Sets --model {IMAGE_MODEL} unless you name one."
57
+ ),
58
+ )
34
59
  decide.add_argument("-m", "--model", default=None, help="Model id (default drex-latest). See `nace models`.")
35
60
  decide.set_defaults(run=run_decide, family="client")
36
61
 
@@ -66,10 +91,52 @@ def build_request(args: argparse.Namespace) -> dict[str, Any]:
66
91
  raise ValueError("--state is required (text, @FILE, or - for stdin)")
67
92
  if args.model:
68
93
  body["model"] = args.model
94
+ elif args.images and "model" not in body:
95
+ body["model"] = IMAGE_MODEL
96
+ if len(args.images) > MAX_IMAGES:
97
+ raise ValueError(f"at most {MAX_IMAGES} --image per request, got {len(args.images)}")
98
+ for spec in args.images:
99
+ _check_image(spec)
69
100
  body["questions"] = questions
70
101
  return body
71
102
 
72
103
 
104
+ def _check_image(spec: str) -> None:
105
+ """Refuse a bad ``--image`` before anything is uploaded or sent."""
106
+ if spec.startswith("ws:"):
107
+ parse_ws_source(spec)
108
+ return
109
+ path = Path(spec)
110
+ if not path.is_file():
111
+ raise ValueError(f"--image: no such file, and not ws:<workspace>/<file>: {spec}")
112
+ with path.open("rb") as handle:
113
+ head = handle.read(12)
114
+ if _media_type(head) is None:
115
+ raise ValueError(f"--image: {spec} is not a PNG, JPEG or WebP image")
116
+
117
+
118
+ def resolve_images(client: NaceClient, specs: list[str]) -> list[Any]:
119
+ """Each ``--image`` as an ``images`` entry: ws: as a file reference, a small file inline, a larger one uploaded."""
120
+ images: list[Any] = []
121
+ for spec in specs:
122
+ if spec.startswith("ws:"):
123
+ workspace_id, file_id = parse_ws_source(spec)
124
+ images.append({"type": "workspace_file", "workspace_id": workspace_id, "file_id": file_id})
125
+ continue
126
+ path = Path(spec)
127
+ data = path.read_bytes()
128
+ if len(data) <= INLINE_IMAGE_BYTES:
129
+ images.append(image_input(data))
130
+ continue
131
+ # Named by content, so the same image again returns the file already there.
132
+ target = f"{IMAGE_UPLOAD_FOLDER}/{hashlib.sha256(data).hexdigest()[:16]}{path.suffix.lower()}"
133
+ print(f"uploading {path.name}", file=sys.stderr)
134
+ uploaded = upload_file(client, path, path=target, on_conflict=None)
135
+ print(f"uploaded {workspace_source(uploaded)}", file=sys.stderr)
136
+ images.append(image_input(uploaded))
137
+ return images
138
+
139
+
73
140
  def _state(text: str) -> Any:
74
141
  """A state that parses as a JSON object or array is sent as JSON; anything else as text."""
75
142
  stripped = text.strip()
@@ -99,14 +166,17 @@ def _labelled(spec: str, flag: str) -> tuple[str, list[str], str | None]:
99
166
 
100
167
  def run_decide(client: NaceClient, args: argparse.Namespace) -> int:
101
168
  body = build_request(args)
102
- extra = {key: value for key, value in body.items() if key not in ("state", "questions", "model")}
103
- response = client.system_one(body["state"], body["questions"], model=body.get("model"), extra_body=extra or None)
169
+ if args.images:
170
+ body["images"] = resolve_images(client, args.images)
171
+ extra = {key: value for key, value in body.items() if key not in ("state", "questions", "model", "images")}
172
+ response = client.system_one(body["state"], body["questions"], model=body.get("model"), images=body.get("images"), extra_body=extra or None)
104
173
  if args.json:
105
174
  print(response_json(response))
106
175
  return 0
107
176
  print(render_answers(response))
108
177
  timing = f" · {response.evaluation_time_ms:.0f} ms" if response.evaluation_time_ms is not None else ""
109
- print(f"{response.model} · {response.usage.input_tokens} input tokens{timing} · {response.request_id}", file=sys.stderr)
178
+ image_tokens = f" ({response.usage.image_tokens} image)" if response.usage.image_tokens else ""
179
+ print(f"{response.model} · {response.usage.input_tokens} input tokens{image_tokens}{timing} · {response.request_id}", file=sys.stderr)
110
180
  return 0
111
181
 
112
182