nace-cli 0.2.0__tar.gz → 0.4.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.2.0
3
+ Version: 0.4.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.2,<1
19
+ Requires-Dist: nace-sdk>=0.6.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
@@ -114,7 +114,7 @@ nace schema-version sch_... 1
114
114
  ## Workspaces and search
115
115
 
116
116
  ```bash
117
- nace workspace-create fy25-audit --global-context "Entity: Acme Holdings Ltd."
117
+ nace workspace-create fy25-audit
118
118
  nace workspace-use <workspace_id> # later commands use it; --workspace ID or $NACE_WORKSPACE_ID win
119
119
  nace upload ./report.pdf --workspace <workspace_id> --path reports/2025/report.pdf
120
120
  nace upload ./fy25 --workspace <workspace_id> --ingest # every file under a folder, 8 at a time, then ingested
@@ -122,16 +122,29 @@ nace ingest --path-prefix reports/ # makes the files searchable
122
122
  nace workspace-files --status failed
123
123
  nace search "What were total liabilities at year end?"
124
124
  nace qa "What was total freight spend in FY2024?" # stderr: session <id>; follow up with --session
125
- nace kg-build
126
125
  ```
127
126
 
128
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`.
129
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
+
130
143
  ## Output and exit codes
131
144
 
132
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.
133
146
 
134
- 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` and `workspace-create` 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.
135
148
 
136
149
  Full guide: https://console.nace.ai/docs/developer-tools/cli (install, sign-in, `decide`, documents, uploads, jobs, schemas, workspaces, search, exit codes).
137
150
 
@@ -89,7 +89,7 @@ nace schema-version sch_... 1
89
89
  ## Workspaces and search
90
90
 
91
91
  ```bash
92
- nace workspace-create fy25-audit --global-context "Entity: Acme Holdings Ltd."
92
+ nace workspace-create fy25-audit
93
93
  nace workspace-use <workspace_id> # later commands use it; --workspace ID or $NACE_WORKSPACE_ID win
94
94
  nace upload ./report.pdf --workspace <workspace_id> --path reports/2025/report.pdf
95
95
  nace upload ./fy25 --workspace <workspace_id> --ingest # every file under a folder, 8 at a time, then ingested
@@ -97,16 +97,29 @@ nace ingest --path-prefix reports/ # makes the files searchable
97
97
  nace workspace-files --status failed
98
98
  nace search "What were total liabilities at year end?"
99
99
  nace qa "What was total freight spend in FY2024?" # stderr: session <id>; follow up with --session
100
- nace kg-build
101
100
  ```
102
101
 
103
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`.
104
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
+
105
118
  ## Output and exit codes
106
119
 
107
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.
108
121
 
109
- 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` and `workspace-create` 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.
110
123
 
111
124
  Full guide: https://console.nace.ai/docs/developer-tools/cli (install, sign-in, `decide`, documents, uploads, jobs, schemas, workspaces, search, exit codes).
112
125
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nace-cli"
3
- version = "0.2.0"
3
+ version = "0.4.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.2,<1",
27
+ "nace-sdk>=0.6.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.2.0"
3
+ version = "0.4.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.2,<1",
30
+ "nace-sdk>=0.6.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.2.0"
9
+ __version__ = "0.4.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,7 @@ 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, _search, _upload, _workspaces
19
+ from nace_cli import _browser, _decide, _docops, _domains, _jobs, _login, _schemas, _search, _storage, _upload, _workspaces
20
20
  from nace_cli._inputs import check_one_stdin_flag
21
21
  from nace_cli._output import CommandFailed
22
22
 
@@ -82,6 +82,14 @@ def _validation_lines(exc: NaceAPIError) -> list[dict[str, Any]]:
82
82
  """Where a request failed validation: ``error.issues``, else the document routes' ``error.detail.errors``."""
83
83
  if exc.issues:
84
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
+ ]
85
93
  errors = exc.detail.get("errors") if isinstance(exc.detail, dict) else None
86
94
  if not isinstance(errors, list):
87
95
  return []
@@ -101,6 +109,11 @@ _WORKSPACE_HINTS = {
101
109
  "session_busy": "The conversation's last turn is still running: wait for it with `nace job <id> --wait`, then ask again.",
102
110
  "rate_limited": "Automatic searches and QA turns share a limit of 60 per account every 10 minutes.",
103
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
+ "storage_connection_unavailable": (
113
+ "`nace storage list` lists your account's connections. A deployment without customer storage answers this to every call."
114
+ ),
115
+ "storage_connection_not_ready": "Validate the connection first: `nace storage validate --id ID --wait`.",
116
+ "storage_credentials_refused": "Check the role ARN, or the Entra tenant, client id and secret, then run `nace storage credentials` again.",
104
117
  }
105
118
 
106
119
 
@@ -144,7 +157,7 @@ def build_parser() -> argparse.ArgumentParser:
144
157
  parser = argparse.ArgumentParser(
145
158
  prog="nace",
146
159
  description=(
147
- "Nace CLI: calibrated decisions, document jobs, workspaces and search. Credentials come from "
160
+ "Nace CLI: calibrated decisions, document jobs, workspaces, storage connections, search and browser runs. Credentials come from "
148
161
  "$NACE_API_KEY / $NACE_BASE_URL or ~/.nace/config.toml (`nace login`). "
149
162
  "Set $NACE_LOG_LEVEL=debug to log each request to stderr."
150
163
  ),
@@ -167,7 +180,10 @@ def build_parser() -> argparse.ArgumentParser:
167
180
  _jobs.add_parsers(sub, common)
168
181
  _schemas.add_parsers(sub, common)
169
182
  _workspaces.add_parsers(sub, common)
183
+ _domains.add_parsers(sub, common)
184
+ _storage.add_parsers(sub, common)
170
185
  _search.add_parsers(sub, common)
186
+ _browser.add_parsers(sub, common)
171
187
  return parser
172
188
 
173
189
 
@@ -0,0 +1,178 @@
1
+ """Domains: the taxonomies a workspace classifies its files against.
2
+
3
+ ``nace domains`` takes an action: ``list`` lists them as ``nace workspaces`` lists workspaces, ``get``
4
+ reads one with its classes, and ``create``, ``update``, ``publish`` and ``duplicate`` write them. Every
5
+ input is a flag (``--id``, ``--slug``, ...). A draft is edited in place and published once, and a
6
+ published or platform domain is frozen, so ``nace domains duplicate`` copies it into a draft.
7
+ ``nace workspace-create --domain SLUG`` pins a published one.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import sys
14
+ from typing import Any
15
+
16
+ from nace_sdk import Domain, DomainSummary, DomainWrite, NaceClient
17
+ from nace_sdk._core.endpoints import DOMAIN_MAX_PAGE_SIZE
18
+
19
+ from nace_cli._inputs import read_json
20
+ from nace_cli._output import CommandFailed
21
+ from nace_cli._schemas import _add_page_args
22
+ from nace_cli._workspaces import _emit_listing, _total
23
+
24
+ #: The two owners a domain has, for ``nace domains list --owner``.
25
+ OWNERS = ("platform", "tenant")
26
+ #: The two statuses a domain version has, for ``nace domains list --status``.
27
+ STATUSES = ("draft", "published")
28
+
29
+
30
+ def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser) -> None:
31
+ domains = sub.add_parser("domains", help="List, read, create, update, publish and duplicate domains.")
32
+ actions = domains.add_subparsers(dest="domains_action", metavar="{list,get,create,update,publish,duplicate}", required=True)
33
+
34
+ listing = actions.add_parser("list", parents=[common], help="List the domains your account sees: Nace's and its own.")
35
+ listing.add_argument("--owner", default=None, choices=OWNERS, help="Only the platform's domains, or only the account's own.")
36
+ listing.add_argument("--status", default=None, choices=STATUSES, help="Only drafts, or only published versions.")
37
+ listing.add_argument("--slug", default=None, metavar="SLUG", help="Only the versions of this slug.")
38
+ _add_page_args(listing, "Domains", default=50, maximum=DOMAIN_MAX_PAGE_SIZE)
39
+ listing.set_defaults(run=run_list, family="client")
40
+
41
+ show = actions.add_parser("get", parents=[common], help="Print a domain with its taxonomy classes.")
42
+ show.add_argument("--id", dest="domain_id", required=True, metavar="DOMAIN_ID", help="The domain's id, as `nace domains list` lists it.")
43
+ show.set_defaults(run=run_get, family="client")
44
+
45
+ create = actions.add_parser("create", parents=[common], help="Create a draft domain owned by your account.")
46
+ create.add_argument(
47
+ "--slug", required=True, metavar="SLUG", help="The name a workspace is created against with --domain. It can't be changed later."
48
+ )
49
+ create.add_argument("--display-name", required=True, metavar="NAME", help="The name people see.")
50
+ create.add_argument("--taxonomy", default=None, metavar="JSON", help='The classes, as {"classes": [...]}: inline JSON, @FILE, a path, or -.')
51
+ create.set_defaults(run=run_create, family="client")
52
+
53
+ update = actions.add_parser("update", parents=[common], help="Rename a draft domain or replace its taxonomy.")
54
+ update.add_argument("--id", dest="domain_id", required=True, metavar="DOMAIN_ID", help="The draft's id.")
55
+ update.add_argument("--display-name", default=None, metavar="NAME", help="The new name people see.")
56
+ update.add_argument("--taxonomy", default=None, metavar="JSON", help="The whole new taxonomy: inline JSON, @FILE, a path, or -.")
57
+ update.set_defaults(run=run_update, family="client")
58
+
59
+ publish = actions.add_parser("publish", parents=[common], help="Publish a draft domain so workspaces can use it. It can't be undone.")
60
+ publish.add_argument("--id", dest="domain_id", required=True, metavar="DOMAIN_ID", help="The draft's id.")
61
+ publish.set_defaults(run=run_publish, family="client")
62
+
63
+ duplicate = actions.add_parser("duplicate", parents=[common], help="Copy any domain you can see into a new draft.")
64
+ duplicate.add_argument("--id", dest="domain_id", required=True, metavar="DOMAIN_ID", help="The id of the domain to copy.")
65
+ duplicate.set_defaults(run=run_duplicate, family="client")
66
+
67
+
68
+ # --- Rendering ----------------------------------------------------------------------------------
69
+
70
+
71
+ def _domain_line(item: DomainSummary) -> str:
72
+ return f"{item.domain_id} {item.slug} v{item.version} {item.status} {item.owner} {item.display_name}"
73
+
74
+
75
+ def _written_line(item: DomainSummary) -> str:
76
+ return f"{item.domain_id} {item.slug} v{item.version} {item.status}"
77
+
78
+
79
+ def _one_line(text: Any) -> str:
80
+ return " ".join(str(text or "").split())
81
+
82
+
83
+ def _domain_detail(item: Domain) -> str:
84
+ """The domain's line, ``inherits:`` when it has a parent, then ``classes (N):`` with each class and its aliases."""
85
+ lines = [_domain_line(item)]
86
+ if item.parent_slug:
87
+ lines.append(f"inherits: {item.parent_slug}")
88
+ classes = [entry for entry in item.taxonomy.get("classes") or [] if isinstance(entry, dict)]
89
+ lines.append(f"classes ({len(classes)}):")
90
+ for entry in classes:
91
+ description = _one_line(entry.get("description"))
92
+ lines.append(f" {entry.get('group')} / {entry.get('subgroup')}{f': {description}' if description else ''}")
93
+ aliases = [str(alias) for alias in entry.get("aliases") or []]
94
+ if aliases:
95
+ lines.append(f" aliases: {', '.join(aliases)}")
96
+ return "\n".join(lines)
97
+
98
+
99
+ def _validation_errors(written: DomainWrite) -> list[dict[str, Any]]:
100
+ return [dict(error) for error in written.validation.errors]
101
+
102
+
103
+ def _error_line(error: dict[str, Any]) -> str:
104
+ where = f"{error['path']}: " if error.get("path") else ""
105
+ return f" - {where}{error.get('message', '')} ({error.get('code', 'invalid')})"
106
+
107
+
108
+ def _emit_write(written: DomainWrite, args: argparse.Namespace) -> int:
109
+ """The saved domain's line, or ``{domain, validation}`` with ``--json``; what validation found goes to stderr."""
110
+ if args.json:
111
+ print(written.model_dump_json(indent=2, by_alias=True, exclude_none=True))
112
+ return 0
113
+ print(_written_line(written.domain))
114
+ errors = _validation_errors(written)
115
+ if errors:
116
+ print(f"the taxonomy has {len(errors)} validation errors:", file=sys.stderr)
117
+ for error in errors:
118
+ print(_error_line(error), file=sys.stderr)
119
+ return 0
120
+
121
+
122
+ def _taxonomy(value: str | None) -> dict[str, Any] | None:
123
+ if value is None:
124
+ return None
125
+ loaded = read_json(value, what="--taxonomy")
126
+ if not isinstance(loaded, dict):
127
+ raise ValueError("--taxonomy must be a JSON object")
128
+ return loaded
129
+
130
+
131
+ # --- Commands -----------------------------------------------------------------------------------
132
+
133
+
134
+ def run_list(client: NaceClient, args: argparse.Namespace) -> int:
135
+ domains = client.domains
136
+ filters = {"owner": args.owner, "status": args.status, "slug": args.slug}
137
+ return _emit_listing(
138
+ args,
139
+ "domains",
140
+ lambda: domains.list(limit=args.limit, cursor=args.cursor, **filters),
141
+ lambda: domains.iter(**filters),
142
+ line=_domain_line,
143
+ extra=lambda page, items: {"total_count": _total(page, items)},
144
+ )
145
+
146
+
147
+ def run_get(client: NaceClient, args: argparse.Namespace) -> int:
148
+ item = client.domains.get(args.domain_id.strip())
149
+ print(item.model_dump_json(indent=2, by_alias=True, exclude_none=True) if args.json else _domain_detail(item))
150
+ return 0
151
+
152
+
153
+ def run_create(client: NaceClient, args: argparse.Namespace) -> int:
154
+ if not args.slug.strip():
155
+ raise ValueError("--slug must not be blank")
156
+ if not args.display_name.strip():
157
+ raise ValueError("--display-name must not be blank")
158
+ written = client.domains.create(slug=args.slug.strip(), display_name=args.display_name, taxonomy=_taxonomy(args.taxonomy))
159
+ return _emit_write(written, args)
160
+
161
+
162
+ def run_update(client: NaceClient, args: argparse.Namespace) -> int:
163
+ if args.display_name is None and args.taxonomy is None:
164
+ raise ValueError("pass at least one change: --display-name or --taxonomy")
165
+ written = client.domains.update(args.domain_id.strip(), display_name=args.display_name, taxonomy=_taxonomy(args.taxonomy))
166
+ return _emit_write(written, args)
167
+
168
+
169
+ def run_publish(client: NaceClient, args: argparse.Namespace) -> int:
170
+ written = client.domains.publish(args.domain_id.strip())
171
+ code = _emit_write(written, args)
172
+ if not written.validation.valid:
173
+ raise CommandFailed("the domain was not published: fix the taxonomy errors above")
174
+ return code
175
+
176
+
177
+ def run_duplicate(client: NaceClient, args: argparse.Namespace) -> int:
178
+ return _emit_write(client.domains.duplicate(args.domain_id.strip()), args)
@@ -22,10 +22,11 @@ STDIN_FLAGS = {
22
22
  "targets_file": "--targets",
23
23
  "password": "--password",
24
24
  "access": "--access",
25
- "retention_policy": "--retention-policy",
26
- "knowledge_graph": "--knowledge-graph",
25
+ "taxonomy": "--taxonomy",
27
26
  "output_schema": "--output-schema",
28
27
  "clarification_answers": "--clarification-answers",
28
+ "answer_schema": "--answer-schema",
29
+ "client_secret_file": "--client-secret-file",
29
30
  }
30
31
 
31
32