PCLI2
Releases
2.0.1 - 2026-09-06
Documentation
- The command reference and alias tables now cover every command - Every
pcli2 ...example in the README and the documentation site was checked against the binary's own help: all commands and flags exist. Twelve commands were missing from the README reference (asset similarity,config get path,config validate,user list,user get,cache clear,doctorand the environment commands), the alias tables lacked a dozen aliases, the feature lists still described resume as downloads-only and omitted the scripting flags and diagnostics, andpcli2 config get(a command group) was shown whereconfig get pathwas meant. No code changes.
2.0.0 - 2026-09-05
Removed
- The old spellings of the input and output flags -
--file(onasset createandconfig import),--files,--csv-file,--local-path,--fileonasset thumbnail, and the bare positional output path onasset downloadwere kept as hidden aliases by 1.27. They are gone. Each is still recognised so a script that uses one stops with exit 64 and a message naming the replacement (--input, or-o/--output), rather than clap's "unexpected argument". The README has the full mapping under "Upgrading to 2.0". This is the only change since 1.29.0, and the reason for the major version.
1.29.0 - 2026-09-05
Added
- Contract tests against Physna's OpenAPI specification - A snapshot of the spec now lives in
tests/fixtures/physna-openapi.json. For each of the 22 endpoints the client reads, a response body is generated from the spec's schema (once with only the required properties, once with all of them) and must deserialize into the model type the client uses, so a field Physna renames, drops or retypes fails in CI instead of as a deserialization error in a user's terminal. The asset states, metadata field types, dependency statuses and tenant roles the code hard-codes are compared with the spec's enumerations, the page sizes the client sends are checked against the spec's maximums, and every URL the client builds must exist. A weeklyspec-driftworkflow fetches the live spec and fails when any endpoint or schema the client depends on has changed, with the diff in the log and instructions for refreshing the snapshot.
Fixed
- Two things the contract tests found on their first run - A tenant without a display or short name (both optional in the spec) failed the whole tenant listing; both now default to empty. An asset listing without
pageData(optional in the spec, absent in cursor mode) was rejected; it now reads as a single page.
1.28.0 - 2026-09-05
Added
--safe-csv(global, orPCLI2_SAFE_CSV=1) - Guards every CSV cell this process writes against spreadsheet formula injection: a text cell starting with=,+,-,@, tab or carriage return is prefixed with a single quote, which Excel, LibreOffice and Google Sheets show as text. Numbers such as-5are left alone. Off by default because the quote is visible to other consumers of the file. Excel workbooks were already safe.
Fixed
PCLI2_NO_COLOR=0disabled colour in one place and enabled it in another - The terminal module treated any non-empty value as "disable" while clap parsed it as false. Both now follow one rule for pcli2 boolean variables: empty,0,false,noandoffmean off, anything else means on.NO_COLORkeeps the no-color.org rule (any non-empty value disables). The same rule is pinned onPCLI2_NO_INPUTandPCLI2_SAFE_CSV.--no-colorgiven as a value disabled colour - The terminal module scanned the raw command line, sotext-match --text --no-colorturned colour off. After parsing, the parsed flag is used; the raw scan only decides how clap colours its own help and error output before parsing.
1.27.0 - 2026-09-05
Changed
- One name per concept:
-i/--inputand-o/--output- Every command that reads a local file, directory or glob now takes--input:asset create(was--file),asset create-batch(was--files),asset metadata create-batch(was--csv-file),folder upload(was--local-path),config import(was--file). Every command that writes takes-o/--output:asset download(was a bare positional argument),asset thumbnail(was--file),folder downloadandfolder thumbnails(gained the-oshort form). The old spellings still work as hidden aliases, andasset downloadstill accepts the positional path, so no script breaks; only the help text and documentation changed. -qis now--quiet, and--forcehas no short form --fmeant--formaton about forty commands but--forceonfolder delete;-qwas--textontext-matchwhile--quiethad no short form.folder delete -fandtext-match -qno longer parse; use--forceand--text. These are the only two breaking changes in this release.- A flag that can change nothing now says so -
--prettywith CSV,--headerswith JSON or tree,--metadataonasset similarity, and format flags ontenant useused to be accepted in silence. They still are, but with a warning naming the flag and why it has no effect.
1.26.0 - 2026-09-05
Added
--error-format json(global, orPCLI2_ERROR_FORMAT=json) - Everything on stderr becomes one JSON object per line: errors with their hint and, for API errors, the HTTP status; warnings and log lines; the--statssummary. The final error object carries the process exit code and its class (usage,not_found,auth,api, ...), so a script reads one line instead of parsing prose. Usage errors clap reports before the command runs are JSON too.--no-input(global, orPCLI2_NO_INPUT=1) - Never prompt. A confirmation that would otherwise be asked fails with exit 64 and names--yes;tenant useandenv usewithout--namefail instead of opening a menu;auth loginfalls back to its missing-argument error. The same now happens without the flag whenever stdin is not a terminal.
Fixed
cache clearwithout a terminal exited 0 as "cancelled" - It read end-of-file as "no" and reported success to the calling script. It now refuses with exit 64 and asks for--yes.folder delete,asset deleteand the environment commands already refused; all five prompts now go through one function and give the same message.
1.25.0 - 2026-09-05
Added
--checkpoint FILEonfolder geometric-match,part-matchandvisual-match- Each asset's completed search is appended to FILE the moment it finishes, so an interrupted multi-hour run is no longer lost. Re-running the same command with the same file reuses the recorded results and searches only the remaining assets;stderrsays how many were reused. The file is fingerprinted with the search type, tenant, folders, threshold,--recursive,--exclusiveand--limit, and a file from a different run is refused instead of being mixed in. Only successful searches are recorded, so a run that stopped on expired credentials finishes afterpcli2 auth login. The file is deleted once the report has been written; it stays if the report fails, so a too-tall Excel workbook can be re-rendered as CSV without searching again.asset create-batch --skip-existing- Skips files whose name is already in the target folder, the same wayfolder upload --skip-existingdoes, so an interrupted batch upload can be re-run without creating duplicates.- Token renewal is now covered by tests against a mock API and auth server - A 401 renews once and the retry carries the new token; a burst of eight concurrent 401s costs one renewal; a client with credentials but no token authenticates before its first request; a rejected credential reports the auth server's reason and keeps the old token; and without credentials no renewal is attempted.
- Every formatter is checked for output shape - Each printable type is formatted in every output format it supports and checked for the properties scripts rely on: CSV parses with no ragged rows,
--headersadds exactly one line, nothing ends with a line break, compact JSON is one line, and an unsupported format is a clean error. This is what found the three fixes below.
Fixed
- Four tree outputs printed a trailing empty line -
tenant list,tenant metadata list,asset healthandasset dependency-diffin--format treeended with a line break thatprintln!doubled. - Four CSV outputs had no final line break at all -
auth token --format csv,asset metadata --format csv,asset visual-match --format csvandasset text-match --format csvwere printed withprint!after the 1.23.2 change made CSV text stop carrying its own line break, so the last line ran into the shell prompt andwc -lunder-counted by one. - An empty listing in CSV without headers printed one blank line - Every command now prints formatted output through one helper that prints nothing for an empty result, so
asset list --folder-path /Empty --format csv | wc -lis 0. - Text-match CSV rows had six fields under an eight-column header - The
TextMatchPairformatter wrote rows by hand that did not match its own header; the CSV writer rejected the combination outright. Rows now come from the same producer as the header. - The credentials file ignored
PCLI2_CONFIG_DIR- The documentation has said the variable holds bothconfig.ymland the credentials file; onlyconfig.ymlfollowed it, so a second profile shared the default profile's login. Both now live in the directory the variable names. Anyone who setPCLI2_CONFIG_DIRand logged in before this release will need to log in once more.
1.24.0 - 2026-09-04
Added
pcli2 doctor- One screen with everything support would otherwise ask for: which binary is running and whether others are on the PATH, whether the configuration loads, the active environment and its URLs, where credentials are stored (with the file's permissions), whether a usable token exists and when it expires, the active tenant, cache ages, whether the API and the auth server answer and how fast, and whether a newer release exists.--format jsonfor scripts. Exits 78 for a local problem, 68 when a server is unreachable, 0 otherwise.--statson every command prints one line on stderr at exit with the number of API requests, retries and token renewals and the elapsed time, so a run that hammers the server is visible to the person running it. The same line always goes to the debug log.
1.23.3 - 2026-09-04
Documentation
- Homebrew 6 requires a one-time
brew trust jchultarsky101/pcli2- Homebrew 6.0 refuses to load formulae from any tap that has not been trusted, sobrew installandbrew upgrade pcli2stop with "Refusing to load formula ... from untrusted tap" until the tap is trusted once per machine and user. The README and install guide now say so, including theHOMEBREW_NO_REQUIRE_TAP_TRUST=1escape hatch for CI. The installers, MSI andpcli2-updateare unaffected.
1.23.2 - 2026-09-04
Fixed
asset list --format csv(and asset, dependency and tenant-status CSV) no longer ends with an empty line - The 1.23.1 fix missed the formatters that name their buffer differently, which included the one every asset listing uses;asset list --folder-path X --format csv | wc -lstill over-counted by one. Every CSV and tree formatter now goes through the same trimming, and the two environment commands that printed without a final line break gained one. Checked at the byte level for thirty command and format combinations against a live tenant: each ends in exactly one line break and has no empty lines.
1.23.1 - 2026-09-04
Fixed
- CSV output no longer ends with an empty line - Every CSV-producing command (asset, folder, tenant, user, metadata, environment and configuration listings, match and dependency reports) terminated its output with two line breaks, so
asset list --format csv | wc -lreported one row more than there were assets and a spreadsheet opened the file with an empty last row. Verified across every listing command against a live tenant: line counts now equal row counts, and no command prints anything before or after its data.
1.23.0 - 2026-09-04
Changed
- Listings use the API's maximum page size - Folders, folder contents, assets, metadata fields, dependencies and users were fetched 200 at a time (searches 100) although the API accepts 1000 per page; a 10,000-folder tenant is now 10 requests instead of 50, and a folder of 20,000 assets 20 instead of 100. Search pages are 500.
--exclusiveis filtered by the server - Folder matches with--exclusivesent every search tenant-wide and discarded the out-of-scope rows client-side, which on a small folder in a large tenant meant downloading pages of results only to throw them away. The resolved folder UUIDs are now passed as the search'sfolderIdsfilter (which the API applies to the folders and their subfolders); the client-side check still decides the exact set, so output is unchanged.folder delete --forcehelp says what it does - The server deletes a folder recursively;--forceskips pcli2's own "not empty" refusal.
1.22.1 - 2026-09-04
Fixed
- A cycle in the server's folder data can no longer hang pcli2 - Every walk over the folder hierarchy (path building,
folder list --format tree, subtree filtering) now remembers where it has been, so a folder whose parent chain loops back on itself (or lists itself as its parent) ends the walk with a warning instead of looping forever or overflowing the stack. The "did you mean" suggestions build the path of every folder, so one bad record used to hang every not-found message.
1.22.0 - 2026-09-04
Fixed
- The new-version check no longer delays exit - It ran after the command finished and could hold the prompt for up to three seconds on a stale cache. It now starts before the command and is only reported at exit; a lookup still in flight is abandoned rather than waited for.
- Match percentages are formatted the same way in every CSV - Single-asset match CSV printed
100.0where the folder report and the--metadataCSV printed100. folder visual-matchsays when an asset hit--limit- The per-asset cap (default 100) truncated silently in folder mode; text match already warned.folder createprints the new folder's UUID - It printed nothing, so a script had to resolve the path it had just created.
Changed
- Root
--help(and the man page) list every environment variable pcli2 reads; the man page previously mentioned onlyPCLI2_NO_COLOR. - Folder match commands document their row order in
--help: pair order for CSV and JSON, match percentage for Excel. - Help text: "Folder UUID" instead of "Resource's folder UUID"; the
--outputhelp onfolder geometric-matchno longer contradicts itself.
1.21.0 - 2026-09-04
Fixed
-
The active tenant follows the environment - One tenant selection was shared by every environment, so
env use stagingleft production's tenant UUID active and every command failed with "Tenant not found" untiltenant usewas run again. Each environment now remembers its own tenant (older versions still read the top-level value). Folder and metadata caches are keyed by environment too, so a staging tenant cloned from production with the same UUID can no longer be served production's folder tree. -
Per-asset failure messages survive
--progress- During a folder match the reasons for individual failures, and the "stopping after repeated authentication failures" explanation, were written underneath the redrawing progress display and painted over (the same defect fixed for the scan spinner in 1.18.3). They are now printed with the display lifted. -
env reset,env removeand overwriting withenv addask first -env resetwiped every environment with no prompt;env addsilently replaced an existing one. All three honour the global--yes, and refuse rather than assume when no prompt can be shown. -
env addrejects a URL that is not one ---api-url 'not a url'was saved and surfaced later as an opaque request error. -
env remove --name nopesays the environment does not exist (exit 67) instead of "missing value for property". -
--format JSONworks - The value is matched case-insensitively, asPCLI2_FORMATalready was. -
asset list --folder-uuid- Every other folder command accepted a UUID; this one only took a path. -
user listshows itslsalias in help like every other list command. -
--verboseshows pcli2's own debug output without the HTTP stack's;RUST_LOGstill opens everything. -
Folder trees and lists sort case-insensitively ("alpha" before "Zeta").
-
Configuration and formatting errors show the cause as a sentence rather than a Rust debug dump.
-
asset deleteandfolder deletework again - The request-path rewrite in this release cycle sent the relative API path as the whole URL for DELETE; caught before release, with a regression test. -
asset metadata create-batchfetches the field registry once and lists each parent folder once - Both happened per row: a 5,000-row batch into a 10,000-asset folder was about a quarter of a million requests. A registry fetch that fails is now an error rather than "no fields exist", which used to turn every value into a new text field and report the API's rejection as a type conflict in the user's CSV. -
Cache files are written atomically and carry a schema version - Two pcli2 processes (a script under
xargs -P) could read a half-written cache and refetch the whole hierarchy; a cache written by another version is now discarded instead of decoded wrongly. A cache timestamp from the future no longer panics a debug build. -
Asset listing stops if the server echoes a stale page number - The page loop had no guard against being asked for the same page forever.
-
Listings that hit the safety page cap say so - The truncation of very large user and asset listings was logged at debug level only.
-
--files a,b,crejects a path that does not exist - It was dropped silently, so a typo uploaded one file fewer. Directories matched by a glob are skipped and counted instead of becoming per-file failures. -
asset metadata create --type numberno longer saturates large values -1e20was stored as 9223372036854775807.--type booleanwith a value that is not a boolean is sent as-is so the API rejects it, instead of silently writingfalse. -
asset metadata getprints{}(or CSV headers) for an asset without metadata - stdout used to be empty for a successful command, and JSON output was wrapped in an internal{"meta": ...}object. -
folder download --folder-uuidandfolder thumbnail --folder-uuidno longer write into a directory nameduntitled- The folder's name is used. -
folder uploadno longer copies every file through the temp directory - The copy used a non-unique name, so two uploads of files calledpart.stlfrom different directories could clobber each other mid-upload. -
asset create --overridetreats only "absent" as absent - A network or session failure while checking for the existing asset used to be read as "does not exist". -
A batch row whose folder does not exist is counted as missing, not as a permission problem.
-
A configured API URL with a trailing slash no longer produces
//tenantsURLs; user IDs are URL-encoded. -
tenant useinteractive selection no longer panics on a tenant name containing a colon. -
Excel report sorting parses each cell once instead of once per comparison (tens of millions of parses on a million-row report).
-
--tenantaccepts a tenant UUID, as its help text promised - Only the short name worked. A tenant missing from the cached list is also looked up once more from the API before it is reported as not found, so a tenant granted since the cache was written is usable withoutcache clear. -
PCLI2_FORMATis honoured byenv list,env getandconfig get---formathas a default value, so the branch that consulted the variable was unreachable. -
In-app advice no longer refers to a
contextcommand that does not exist - "Run 'pcli2 context set tenant'" is now "Run 'pcli2 tenant use'". -
Uploads go to the folder that was asked for - The upload endpoint places a file by the path string it is sent and creates any folder it does not know, and three callers sent it the wrong string:
folder upload --folder-uuidsent a placeholder/(every file landed at the tenant root),asset create --folder-uuidfell back to the bare folder name when the hierarchy fetch failed (a nested folderA/B/Cbecame a new top-levelC), and--folder-pathwas sent as typed, so/Home/Partscould create a literalHomefolder and/partsa second folder besideParts. The destination is now the folder's canonical path from the hierarchy, whichever way the folder was named. -
Uploading to the tenant root works -
asset create --folder-path /,asset create-batch --folder-path /andfolder upload --folder-path /failed with "Folder '/' not found" after fetching the whole hierarchy to suggest alternatives. The root has no UUID and needs none. -
Typed metadata compares equal to itself in match reports - A numeric or boolean field was rendered as text on the reference side and blanked on the candidate side, so the Excel diff painted every such field as "missing on one side" and CSV rows showed
42against an empty cell. Both sides now render values the same way (JSON text for non-strings, empty for null). -
Folder match JSON no longer invents reference asset fields -
referenceAssetreportedstate: "active"(not a Physna state),type: "asset",isAssembly: falseand empty timestamps regardless of the real asset. The real values are used. -
Single-asset text match CSV no longer doubles the asset URL - The stored URL was already an asset URL, and the CSV writer appended
/asset/<uuid>to it again. -
Excel reports stop at the worksheet hyperlink limit - Excel allows 65,530 hyperlinks per sheet; a report between that and the 1,048,574-row cap produced a workbook Excel "repairs" by stripping every link. Comparison URLs past the limit are written as plain text, with one warning.
-
Comma-separated values work where the help text promised them -
--name a,bonasset metadata deleteandasset metadata inference, and--folder-path a,bonfolder geometric-match,part-match,visual-matchandasset dependencies, were sent as one value containing a comma; a delete ofMaterial,Weightdeleted nothing and exited 0. -
Metadata batch CSV: the classic header is checked and short rows are reported - A file with columns in another order was applied positionally (paths and names swapped);
metadata get --format csvoutput (NAME,VALUE) was accepted and silently did nothing because every row was too short. The header must now readASSET_PATH,NAME,VALUE[,TYPE]; rows with fewer than three columns are counted and reported with line numbers; an empty NAME is an error. -
Downloaded names cannot escape the output directory - A folder or asset named
.., or a name containing a path separator, is refused instead of written outside the directory the user chose. -
One missing field no longer fails a whole page of assets -
type,createdAt,updatedAt,state,isAssemblyandmetadataare optional when deserializing, as are the page index fields. -
Ambiguous folder paths are reported - Two sibling folders that differ only by case (or not at all) both match a path; the first was used silently. A warning now names both UUIDs.
-
Batch commands exit non-zero when any item failed -
asset create-batchlogged failed files at debug level only and printed the successes with exit 0;asset metadata create-batch --continue-on-errorexited 0 with every row failed;folder upload,folder downloadandfolder thumbnailprinted "Operation completed successfully!" whatever had happened, and a missing thumbnail counted as a success. Every failure is now named on stderr and the command exits 69 (temporary failure) with a one-line count.--continue-on-errorstill decides whether the rest of the batch is attempted; it no longer decides whether the exit code tells the truth. -
folder downloadwithout--continue-on-errorstops at the first failure - It used to attempt every remaining asset anyway (each with its own 5-10 second retry sleep) and only then report an error; an expired session on file 1 of 5,000 cost 5,000 failing attempts. Remaining downloads are now cancelled and counted as "not attempted". -
Concurrent commands share one API client -
folder download,folder thumbnail,folder uploadandasset create-batchbuilt a fresh client inside every task: one credential-file read, one connection pool and one private token slot per asset, so a token expiring mid-run was renewed once per remaining asset (up to 22,000 auth-server calls in the largest known run) instead of once. Tasks now clone the caller's client, which shares the token, the renewal lock and the connection pool. -
folder uploadlists the destination folder once instead of once per file - Each task listed the entire folder (every page) to check whether its file already existed: 2,000 files into a folder of 10,000 assets was about 100,000 requests before the first upload. A failed listing was also treated as "does not exist", so--skip-existingcould re-upload on a transient error; it is now a hard error. -
folder downloadandfolder thumbnailno longer fetch every subfolder a second time - The subfolder listing already carries the name the walk needed; one request per subfolder is gone. -
Network and session failures while loading the folder hierarchy are no longer reported as "Folder not found" - The lookup swallowed them and every caller turned the empty answer into a path problem, sending users to check a path that was right. They now surface as what they are, and
--recursiveno longer suggestscache clearfor a failure that clearing the cache cannot fix. -
asset listandfolder listuse the folder cache - Both rebuilt the tenant's whole hierarchy from the API on every call (asset list --recursivetwice), andfolder list --reloadrefreshed nothing because the listing bypassed the cache. A path missing from the cached hierarchy triggers one refresh before it is reported absent, so a folder created since the cache was written is still found. -
Downloads are streamed to disk through a temporary file -
asset downloadandfolder downloadheld the whole file in memory (ten concurrent multi-gigabyte assemblies meant ten files in RAM), wrote directly to the final name so an interrupted transfer left a truncated file that looked complete, and accepted an empty body as success. The file is now streamed into<name>.partand renamed into place only when whole; an empty response is an error. The blind "sleep and retry any error, including 404" wrappers are gone: the client retries transient failures itself. -
Folder match runs count every failed task - A task that failed outside the search itself was dropped from the tally, so "Searched X of Y" and the 10% threshold used a smaller denominator. A run stopped by repeated authentication failures now always exits non-zero, even when the stop happened past the 90% mark.
-
Match runs read the configuration once - The UI base URL was loaded and parsed from disk for every match row: 1.29 million times on the largest known report.
-
"No assets to download" is a warning, not an error with exit 0 -
asset download-folderandfolder downloadprinted a red error and exited 0 for an empty folder. -
A server outage mid-run no longer produces a "complete" report with exit 0 - Every HTTP status other than 401/403/404 was wrapped in the same
Conflicterror the search endpoint uses for an asset that is not indexed, so a 500, 503 or 429 during a folder match was counted as "not searchable" - a property of the tenant, excluded from the failure threshold - and the run finished with a short report and a clean exit. Errors now carry their status: only a real 409 is "not searchable", and everything else counts as a failure that can fail the run. -
Transient failures are retried on every request, not only on DELETE - The API client built its requests on the raw HTTP client and skipped the retry helper entirely, so
PCLI2_MAX_RETRIES, the backoff, andRetry-Afterapplied to nothing the user cared about; one 503 in a 5,000-page match run aborted it. Every request now goes through one path with proactive token renewal, transient retry (connection errors, 408/429/502/503/504,Retry-Afterin seconds or HTTP-date form), and a single renew-and-retry on 401/403. Uploads re-open the file for each attempt; timed-out non-idempotent requests are still never resent. -
Exit codes follow the documented contract - Authentication failures exit 100, network failures 101, API errors 102, not-found 67, missing input files 66, an incomplete report 69, and usage errors from the argument parser 64 (previously 2). Every one of these used to exit 70 "internal software error" (or 0, see below), which made the
case $?examples in the README impossible to hit. -
Failures that exited 0 now exit non-zero -
auth getandauth expirationwith no token,env usewith no selection,env getwith no active or an unknown environment,tenant usewith no or an unknown tenant,config validatewhen validation fails, andconfig import, which was an unimplemented stub that reported success having done nothing and now says so. -
Error messages are no longer rewritten by substring - A folder named
401-serieswas reported as an authentication failure, an unknown environment name was replaced by "Resource not found", a file calledconnection-bracket.stlbecame a network error. Messages are now printed as the code produced them, with one hint chosen from what the error actually is (a login hint for authentication failures, a role hint for a 403 that survived token renewal, a connectivity hint for network errors). The OAuth error codes from the auth server keep their explanations, appended rather than substituted. -
Errors are printed once - Login and token failures were reported with remediation steps and then printed a second time on exit as a bare "Security error"; the incomplete-report error was printed twice with different counts.
-
A 404 no longer triggers a token renewal - Every not-found answer (a deleted asset, a wrong UUID) cost an auth-server call, a credential-file write and a retried request, and with a token-only login was then reported as an expired token. A 404 is now a not-found.
-
Automatic re-authentication failures say why - A rotated secret, a rate-limited auth endpoint and a DNS failure all printed the same "log in again"; the cause is now included.
-
Concurrent 401s collapse into one renewal - The check for "someone else already renewed" compared against the token current at the moment of the check rather than the token the failed request had used, so a task that read the shared slot after another task's renewal renewed again anyway.
-
Timeouts - A stalled connection used to wait out the full 30-minute request timeout even for a small JSON call, and the auth client had no timeout at all while holding the renewal lock, which hung every concurrent task with it. Connections now time out after 15 seconds and a read after 5 minutes of silence; the total timeout (
PCLI2_TIMEOUT, still 30 minutes by default) only bounds genuine long transfers. -
Every request identifies itself - The
User-Agentheader (nowPCLI2/<version>) was only sent on DELETE. -
Unsupported file types are no longer retried five times by
--override- The "Invalid path extension" upload rejection was classified as a conflict, which the override path treats as "the previous asset may still be deleting" and retries with backoff. It is now an invalid-parameter error, reported the same way whether or not the first attempt also hit an expired token. -
Thumbnail downloads on a Viewer account no longer make three auth-server calls per asset - The per-asset retry helper renewed the token up to three more times on any 403; the client's own renew-and-retry is enough.
Documentation
- The install guide had pointed at the wrong repository and listed a Rust toolchain as a prerequisite for the binary install; it now covers the shell and PowerShell installers, the Homebrew tap, the MSI,
pcli2-update, and says plainly that the crate is not on crates.io. The README no longer claims credentials are kept in the OS keychain: they live indev_credentials.jsonunder the configuration directory, owner-only on macOS and Linux.pcli2 config environment ...becamepcli2 env ...some releases ago and the README and quick start now say so;asset geometric-match-folderisfolder geometric-match; thecontextcommand istenant use/get/clear;--pathonasset listandfolder downloadis--folder-path; examples use/Home/..., the name Physna shows for the root, instead of/Root/.... Three environment variables (PCLI2_API_BASE_URL,PCLI2_UI_BASE_URL,PCLI2_AUTH_BASE_URL) that the code never read are gone from the docs, and every variable that is read is now listed in one table (README and Cross-Platform Configuration). The exit-code tables drop the code that is never emitted (68) and explain 64 for parser errors and 69 for partial batches. The documented multi-level inference propagation and theconfig importexample are removed with the features that did not exist.
Removed
- Dead dependencies -
bincode,exitcode,url,mime,tokio-utilandasync-recursion(recursion now usesBox::pin, stable since Rust 1.77);tempfilemoves to the test dependencies andtokio-testis dropped. Two integration test files that compiled to no tests (one was a single line of escaped text; the other asserted nothing) are removed and the test README describes what actually exists. The committed mdBook output underdocs/book/and the stale top-leveldocs/*.mdcopies are removed; the published site builds fromdocs/src/. The site no longer advertisescargo install pcli2.
Changed
-
CI runs
cargo auditand a minimum-supported-Rust check (1.88, set by transitive dependencies);rust-versionis declared inCargo.toml.Cargo.lockis no longer listed in.gitignore(it was tracked all along). -
asset metadata inference --recursive- The flag was accepted and never read; the documented multi-level propagation did not exist.
Changed
- Batch status lines go to stderr - The statistics reports of
folder upload,folder downloadandfolder thumbnail, and the per-file "Successfully uploaded" / "Skipping existing asset" lines, no longer share stdout with command output.
1.20.0 - 2026-09-04
Fixed
asset create --overrideno longer deletes the existing asset before checking the local file - The existing asset was deleted first and the local file was only validated by the upload that followed, so a mistyped filename (or an unsupported extension, a 413, a dropped connection) destroyed the customer's asset and its metadata with no way back. The local file is now checked before anything destructive happens, and if the re-upload still fails after the delete, the deleted asset's UUID and any metadata captured for--restore-metadataare printed so they can be recovered by hand.folder list --folder-uuidnow lists that folder - The argument was accepted by the parser and never read, so the command silently listed the tenant root instead.- The banner no longer corrupts command output - It was printed to stdout whenever any argument equalled
help,-hor--help, including values:env list --name helporasset text-match --text helpemitted ASCII art ahead of their JSON. The banner is now shown only above genuine help output, as decided by the argument parser. --thresholdis validated as a percentage - Values outside 0-100 are rejected at parse time on every matching command (--threshold 500used to go straight to the API;--threshold -5failed with a misleading "unexpected argument"). A value below 1, almost always0.85typed for 85%, is still accepted but produces a warning saying what it actually asks for.--concurrentis validated on every command, so--concurrent 0no longer hangs -asset create-batchsized its semaphore from the flag with no range check, and zero permits made every upload wait forever with no output. The folder match commands only rejected an out-of-range value after resolving the tenant. All concurrent commands now share one parser that accepts 1-10.- The install page links to the right repository -
docs/src/installation.mdpointed the installer script, the release page, the clone URL and the issues link atgithub.com/physna/pcli2, which is not this project.
Changed
- CI is green again on the current stable toolchain - clippy 1.98 flags every
Result<_, CliError>asresult_large_errbecause the CSV writer error embedded in the error enums was several hundred bytes wide. That variant is now boxed in bothFormattingErrorandCliActionError; nothing user-visible changes. - Dependency lockfile refreshed - Clears the
h2advisory (RUSTSEC-2026-0258, reachable through reqwest in the shipped binary) and therandandevent-listenerunsoundness advisories reported bycargo audit.
v1.19.0
Changed
- The new-version hint now also follows a failed command,
--version, and a rejected argument - It previously appeared only after a successful run, which is when it matters least. The motivating case is the opposite one: a user on a build predating the flag they are passing seesunexpected argument, concludes the feature is broken, and has nothing pointing at their own install. That is not hypothetical — it is the--recursivereport described in the note below, which cost a support round trip and a release spent chasing a defect that user never hit. Argument parsing no longer lets clap exit on pcli2's behalf, so a usage error or a--versioncheck can still be told the binary is out of date, and a failed command prints the hint after the error so it is the last thing read.--helpis excluded: its output is long enough that a trailing line would scroll past unread. Every existing restraint still applies — terminal sessions only, at most one check per 24 hours, never in CI, andPCLI2_NO_UPDATE_CHECKstill disables it entirely.
Notes
- Correction to the v1.18.3 entry below. That fix was attributed to a field report of
--recursive"not working". The report turned out to have a different cause — an oldpcli2earlier on the user'sPATH, in a shell where--recursivedid not yet exist — so the silent-fallback defect it describes was not what that user hit. The defect is real and the fix stands: a recursive scan really could degrade to listing only a folder's direct children, with the explanatory warning painted over by the--progressspinner. But it was found by reasoning backwards from the symptom rather than by reproducing it, and there is no evidence it has ever triggered in the field. Worth knowing before treating a low recursive count as that bug: checkpcli2 --versionin the shell actually being used first.
v1.18.3
Fixed
--recursiveno longer degrades silently into a non-recursive scan - When the folder hierarchy could not be loaded, a recursive folder match fell back to listing only the assets sitting directly in the folder. The warning that was supposed to explain it went to stderr underneath the--progressspinner, which redraws every 100ms and painted straight over it — so the run looked like a success and simply reported far too few assets. Reported from the field as "--recursive is not working": a folder holding 22,378 assets across 511 subfolders reported 1, the count of assets directly inside it. A recursive scan that cannot enumerate subfolders now fails with an explanation and a suggestion to runpcli2 cache clear, rather than quietly answering a different question. The same applies when the folder resolves against the API but is missing from the cached hierarchy, which previously produced a silent empty result. Scan errors also clear the spinner before printing, so the message survives.--continue-on-errornow takes precedence over the permission stop - A403that survives a token renewal stops a metadata batch, since without per-asset permissions every remaining write would fail the same way.--continue-on-errormeans what it says, so it now wins: the account-level explanation is given once and the run continues, with the usual terse per-asset skip lines.
v1.18.2
Fixed
- A batch that fails because the account lacks the Author role now says so - Physna has no per-asset permissions: an account is an Author, who may write any asset, or a Viewer, who may write none. A Viewer running
asset metadata batchtherefore fails on every write, but the run reported an authentication failure and told the user to re-authenticate — a session that was working perfectly.pcli2now distinguishes authenticated but not permitted from credentials not working: a403that survives a token renewal is reported as a role problem, with guidance to ask a tenant administrator for the Author role, and stops the run immediately rather than repeating the same failure for every remaining asset. A401that survives renewal still means the credentials themselves are the problem and still asks the user to log in again.
v1.18.1
Fixed
- A transient authentication blip no longer aborts an entire metadata batch (#93) -
asset metadata batchstopped the whole run and told the user to re-authenticate a session that was working, so following the advice changed nothing and the next attempt failed identically. A 401/403 causes the client to renew the token and retry; the resulting error text always embeds the original 401/403, and the authentication check searched the whole message — so it matched every retried failure unconditionally, whatever the retry actually returned. A stale-token 403 whose retry then failed for an unrelated reason (the asset not being indexed, a metadata type conflict, a 5xx) was reported as an authentication problem. The check now inspects the retry's own status rather than the original or the response body, and the decision to abandon a run uses a narrower test that fires only when the credential renewal itself failed. Everything else is treated as a per-asset failure: counted, reported with relevant guidance, and skipped under--continue-on-error. This also affects the concurrent folder match commands, where the same check feeds the consecutive-failure counter — three assets in a row failing this way would have tripped a spurious stop. Asset lookups that fail for a reason other than the asset being absent are also no longer reported as "asset not found".
v1.18.0
Fixed
- Token renewal is now shared between concurrent tasks (#86) - The API client is cloned once per task by the concurrent commands, and the access token was a plain field, so a token renewed inside one task was invisible to every other task — each of which still held the original and renewed again for itself. On a folder tree of ~22,400 assets, one expiry produced up to ~22,000 separate token requests where one would do, each an opportunity to fail. The token is now shared by a client and all of its clones, and concurrent renewals are collapsed: the first task to notice renews, and the rest pick up its result instead of queueing to repeat it. Tokens are also renewed shortly before they expire rather than after a request has already been rejected, which avoids the failed-request-then-retry round trip entirely; that check is best-effort and can only avoid work, never fail a request that would otherwise have succeeded.
v1.17.0
Fixed
- Folder match output is now reproducible (#82) - Two identical runs over unchanged data produced reports that differed byte-for-byte. Rows were collected in whatever order the concurrent searches finished, and for a symmetric pair the surviving row was whichever direction arrived first — so which asset appeared as the reference varied between runs. Matches are now deduplicated into an ordered map keyed on the unordered pair, so both the row order and the choice of orientation are properties of the data rather than of the run's scheduling. When a pair is searched from both ends the record whose reference sorts first is kept; a pair searched from only one end keeps that direction, and no record is ever rewritten to look canonical, since the transformation matrix and comparison URL describe one direction and flipping them would corrupt both. Two runs over unchanged data now produce identical bytes, so reports can be diffed and version-controlled.
- A folder match run now stops after repeated authentication failures, instead of issuing thousands of doomed requests (#78) - When the token expired partway through a long run and automatic re-authentication failed, every remaining asset failed identically: 21,068 of them on one run, each logging its own copy of the same error, taking the full ~25 minutes and ~21,000 pointless API calls to finish. A run now stops after three consecutive authentication failures, and the reason is reported once rather than once per asset. Consecutive, not the first: the client renews the token and retries automatically — which is what lets a long run survive its credentials expiring halfway through — and a failed renewal is reported the same way whether the credential was rejected or the auth endpoint merely hiccuped, so stopping on the first would abandon a long run over something the next request would have recovered from. Any successful search resets the count, so scattered blips are absorbed while a genuinely dead credential trips it almost immediately. Assets that were never searched are counted separately from ones that were tried and failed (
Searched 1,259 of 22,378 asset(s): 1 failed, 21,067 not attempted, 51 not searchable). - Folder match commands no longer report success when most of their searches failed (#77) - A failed per-asset search contributed nothing and was otherwise indistinguishable from an asset that genuinely had no matches, so nothing counted failures and nothing could report them. A run that lost 21,119 of 22,378 searches to an expired token still exited
0and printed a normal completion summary, handing over a report missing 94% of its rows. Failures are now counted and reported whenever any occur (Searched 1,259 of 22,378 asset(s): 21,068 failed, 51 not searchable), and the command exits non-zero when operational failures exceed 10% of the assets. Failures are classified: an asset that cannot be searched in its current state (not indexed, no 3D data, indexing failed - reported by the API as a409) is a property of the tenant and never fails the run, while authentication, network, and 5xx failures do. The check runs immediately after matching, so an incomplete run stops before spending minutes building a report that should not be trusted. Applies tofolder geometric-match,folder part-match, andfolder visual-match.
v1.16.0
Changed
folder geometric-match --format xlsnow refuses a report too tall for a worksheet, instead of failing deep inside the workbook writer - An Excel worksheet holds 1,048,576 rows; this report spends two of them on its header band, leaving 1,048,574 for data. A real run produced 1,293,068 rows, which cannot be represented. The command now fails immediately after matching withthe report has 1293068 rows, more than the 1048574 an Excel worksheet can hold - use '--format csv' for the complete report, before building a single row. Truncating to fit was considered and rejected: a workbook silently missing a quarter of its rows looks complete to whoever opens it. Use--format csv, which has no such limit.
Security
- The stored-credentials file is now owner-only (
0600) - With the defaultdev-keyringfeature, the client secret and access token are kept in plain text indev_credentials.json. Written under the process umask it landed at0644, readable by every other account and every unprivileged process on the machine. The mode is now set explicitly on every write, and an existing file is tightened the first time it is read, so credentials written by an earlier version are repaired rather than left exposed until the next login. Unix only; Windows has no equivalent mode bits and inherits directory ACLs.
Fixed
auth loginnow prompts when the stored credentials are empty, instead of failing withinvalid_client- An empty stored client ID or secret was treated as a usable credential and sent to the auth server, which rejected it with an error pointing at the user's credentials when the actual problem was local state. Empty is now treated as absent, falling through to the interactive prompt — matching what the prompt path itself already did.- Report building no longer materializes an intermediate copy of every match -
folder geometric-matchbuilt aVec<GeometricMatchPair>before building rows, cloning each reference asset — metadataHashMapincluded — once per row, only ever to read a path and a UUID back out of it. On a 1.29M-row report that intermediate took seconds to build and a further nine seconds to drop, with the progress display parked at 99% for the whole teardown. Both passes now iterate borrowed data. Output is unchanged. - The progress bar no longer flashes
0/0 (0%)when a counted phase starts - The steady-tick thread could redraw in the window between the counter style being applied and the row count being set. Length is now set first.
v1.15.0
Changed
- JSON match reports now stream to
stdouttoo - Completes what v1.14.0 started for CSV.folder geometric-match,folder part-match, andfolder visual-matchbuilt the entire pretty-printed JSON document as oneStringbefore printing it; on a large report that is a second full copy of the data — pretty-printed JSON is bulkier than the rows it came from — and nothing reached the terminal until the last byte was ready. Serialization now goes straight to a buffered, lockedstdout. The bytes written are unchanged, trailing newline included. This supersedes the note in the v1.14.0 entry below that JSON would keep buffering: the atomicity trade is real and is harsher here than for CSV, because a truncated JSON document does not parse at all where a truncated CSV still does. A mid-write failure still reports on stderr with a non-zero exit code, which is what separates it from a silently short file. The single-assetasset match visualandasset match textreports are small and still buffer.
v1.14.0
Changed
- CSV match reports now stream to
stdoutinstead of being buffered whole -folder geometric-match,folder part-match, andfolder visual-matchused to serialize the entire report into a byte buffer, copy that into aString, and print it in one go, holding the report in memory three times over before emitting a single byte. Rows now go straight to a buffered, lockedstdoutas they are produced: output starts appearing immediately, two of the three full copies are gone, and peak memory no longer scales with a second and third copy of the report. The bytes written are unchanged. Two consequences worth knowing: a failure partway through now leaves a truncated report onstdoutrather than printing nothing at all (the error still goes to stderr and the exit code is still non-zero), and closing the pipe early —pcli2 … | head— now ends with a clean error instead of afailed printing to stdoutpanic. JSON output deliberately still buffers, because half a JSON document is not a document.
Added
--progressnow covers report building as well as matching - On a large result set, folder match reports spend minutes turning matches into a table and serializing them, and that work happens after the match progress bar has already reached 100% — so the command looked wedged (one run sat silent for several minutes at 22,378 assets). With--progress, each phase now reports itself with a running elapsed time, and the phases whose size is known up front (flattening matches, collecting metadata columns, building rows, formatting CSV, writing worksheet cells) show a percentage bar and an ETA rather than an indeterminate spinner. Serializing JSON, sorting the workbook, sizing its columns, and saving it are named as they happen, so a long wait can be attributed to a specific step. A summary line closes the report (Built report of 1,743,201 row(s) in 2 minutes). Applies tofolder geometric-match,folder part-match, andfolder visual-match, in every output format including--format xls. All of it goes to stderr, so pipedstdoutis unaffected; without--progressthe phase stays silent.--progressnow covers the recursive folder scan - With--recursive, folder match reports spend time walking the folder tree (one API call per folder) before any matching starts, which looked like a hang on a deep tree. When--progressis passed, that phase now reports live (Scanning /Creo Files: 46/312 folders, 1174 assets found) and prints a summary before matching begins (Scanned 1 folder path(s), found 3182 asset(s) to match). All of it goes to stderr, so pipedstdoutis unaffected; without--progressthe scan stays silent.
v1.13.0
Added
--recursivefor folder match reports -folder geometric-match,folder part-match, andfolder visual-matchaccept--recursive(-R) to include the assets in every subfolder, not just those sitting directly in the named folder. The default is unchanged, so existing commands keep their current scope and runtime; recursion is opt-in because it can widen the work dramatically (a folder with a single asset of its own may have thousands underneath it, and each one costs a search).
Fixed
-
Folder match reports no longer look like a broken path on container folders - A folder that holds nothing but subfolders has no assets of its own, so a match report over it found nothing and failed with
No assets found in the specified folder(s), whose first suggestion was to verify the folder path. The path had in fact resolved correctly, which sent users hunting for a typo that was never there. The message now reports how many subfolders the folder actually contains and points at--recursive:❌ Error: No assets found directly in the specified folder(s) 🔧 To resolve this issue, try the following: 1. The folder(s) contain 8 subfolder(s) - pass --recursive to include the assets in them
v1.12.0
Changed
asset text-matchno longer silently drops results - Text search ranks name matches above metadata-only matches, so the previous default--limitof 100 could silently omit assets whose only match is in a metadata value. The default limit is now 1000 (the limit remains as a guard against excessive queries), and when the limit does cut the result set short a warning is printed to stderr (⚠️ Result limit reached: showing the first N matching assets; increase --limit to retrieve more). The warning goes to stderr only, so JSON/CSV data piped from stdout is unaffected.
v1.11.0
Added
tenant metadata listcommand - Lists every metadata field registered in the tenant along with its data type (text,number,boolean,url). Supports--format json|csv|tree(aliasls). The CSV output uses the same column header as the classicasset metadata create-batchinput (ASSET_PATH,NAME,VALUE,TYPE), withNAMEandTYPEfilled from the registry andASSET_PATH/VALUEleft blank — so a listing can be saved and turned directly into a batch-upload template.- Optional
TYPEcolumn forasset metadata create-batch- The classic CSV layout accepts an optional fourthTYPEcolumn (text,number,boolean, orurl; defaulttext), applied per row. It sets the type used when registering a new metadata field; for a field that already exists, its registered type stays authoritative. Theurltype is now also accepted by singleasset metadata create --type.
Fixed
- Batch metadata upload no longer fails on non-text fields -
asset metadata create-batchsent every CSV value as a string, sonumber,boolean, andurlfields were rejected with a "type mismatch" error. Values are now automatically coerced to each field's registered type (e.g.18for a number field,truefor a boolean, a URL string for a url field), so existing typed fields populate without any extra configuration. A value that cannot be represented as the field's type (e.g.N/Afor a number field) is reported as a type conflict. /Homeasset paths in batch uploads resolve to the root - A leading/Home(the name Physna shows for the root folder) in anASSET_PATHis normalized to the root, so/Home/NX/part.prtandNX/part.prtrefer to the same asset. Rows that spell the same asset differently now merge onto one asset instead of splitting into separate lookups.
Changed
asset metadata create-batch --continue-on-erroralso skips metadata failures - Previously the flag skipped only assets whose path could not be resolved; a failed metadata update or delete (such as a type conflict) always terminated the batch. It now skips the offending asset and continues, consistent with how unresolved paths are handled. Authentication failures remain fatal.
v1.10.0
Added
- Homebrew formula published automatically - Releases now push an updated formula to the
jchultarsky101/homebrew-pcli2tap via cargo-dist, sobrew install jchultarsky101/pcli2/pcli2installs the current version (the tap had been stuck at v1.0.0). Requires theHOMEBREW_TAP_TOKENrepository secret. PCLI2_TIMEOUTenvironment variable - Overrides the request timeout (seconds). The default remains 30 minutes, which large model transfers require; users working with small files can opt into faster failures.
Fixed
- Redirected diagnostics are free of ANSI escape codes - tracing output (warnings, debug logs) emitted color codes even when stderr was redirected to a file. Colors on stderr now follow the same rules as stdout: disabled when not a terminal, or when
NO_COLOR/PCLI2_NO_COLOR/--no-coloris set.
Removed
update-latest-tag.ymlworkflow - It triggered onrelease: published, an event GitHub suppresses for releases created withGITHUB_TOKEN, so it had not run since October 2025. Nothing consumes thelatestgit tag it maintained (installers use GitHub'sreleases/latestURLs, which are independent); the stale tag has been deleted.
Changed
- Timed-out requests are retried only for reads - A network timeout can fire after the server has started processing a request, so retrying a timed-out write (POST/PUT/DELETE) could apply an operation twice. Timeouts now retry only GET requests; connection failures (request never reached the server) and transient status codes (408/429/502/503/504) retry for all methods as before.
- Failed update checks are not re-attempted until the next daily window - Previously an unreachable GitHub API (offline, firewalled, rate-limited) caused the version check to be re-attempted on every command once its cache went stale, adding up to 3 seconds per command. A failed check now counts as the day's attempt.
- Man page SYNOPSIS shows the real invocation - Pages now read
pcli2 folder delete [OPTIONS]instead ofpcli2-folder-delete [OPTIONS], while page names/headers keep the git-style dashed form (pcli2-folder-delete(1)).
v1.9.0
Added
--dry-runflag for destructive and bulk commands -asset delete,folder delete,asset create,asset create-batch, andfolder uploadcan now report exactly what they would delete or upload and exit without making any changes. Forfolder uploadthe check runs before remote folder resolution, so a dry run never creates the target folder.- Global
--verbose/-vand--quietflags - Quick verbosity control on every command:--verboseenables debug-level logging,--quietlimits diagnostics to errors. Both take precedence over thePCLI2_LOG_LEVEL/RUST_LOGenvironment variables. The local--verboseonconfig validateis replaced by the global flag (same behavior). - Automatic retries for transient failures - Network timeouts, connection errors, and HTTP 408/429/502/503/504 responses are now retried with exponential backoff and jitter, honoring the server's
Retry-Afterheader. Default is 2 retries;PCLI2_MAX_RETRIESoverrides (0 disables). pcli2 mancommand - Generates Unix man pages for pcli2 and every subcommand (one page per command, e.g.pcli2-folder-delete.1) into a directory given by--output-dir.- Update notifications - After a successful command in an interactive terminal session, pcli2 prints a one-line stderr hint when a newer release is available on GitHub. Checked at most once per 24 hours with a 3-second timeout; skipped in CI, for redirected output, and for
completions/man; opt out withPCLI2_NO_UPDATE_CHECK. - Spinners for quick operations - Single API round-trips (
asset get,tenant list,config validate --api) show a spinner on stderr so the CLI never appears hung. Hidden automatically when stderr is not a terminal. - Scripting and Automation documentation - New docs page (and README sections) covering exit codes, machine-friendly output, dry-run mode, retries, and a GitHub Actions integration example.
Changed
- Interactive
auth login- When--client-id/--client-secretare omitted and no stored credentials exist, pcli2 now prompts for them interactively (masked input for the secret, keeping it out of shell history) instead of erroring. Non-interactive sessions keep the previous missing-argument error. - Colors are TTY-aware and respect
NO_COLOR- The banner, help examples, and help styling no longer emit ANSI escape codes when output is piped or redirected, whenNO_COLOR/PCLI2_NO_COLORis set, or when the global--no-colorflag (previously defined but inoperative) is passed. - Consistent progress bars - All overall progress bars now show ETA and throughput (the batch upload, asset download, and batch create bars were missing one or both), and per-file spinners show elapsed time.
Fixed
- Warnings are no longer printed twice - Warnings (e.g. skipped rows in
asset metadata create-batch --continue-on-error) were emitted both through tracing and a direct stderr print. They now go through tracing only, so their visibility is controlled by--verbose/--quiet,RUST_LOG, orPCLI2_LOG_LEVEL. The tracing subscriber also writes to stderr (previously stdout), so diagnostics never pollute piped command output.- Note for script authors: warning lines now use the tracing format (
<timestamp> WARN <module>: <message>) instead of the previous⚠️ Warning: <message>prefix - scripts that matched the old literal prefix on stderr need updating. - Note on
--quiet: warnings are suppressed under--quiet(errors only). End-of-run summaries (batch statistics and remediation blocks) are still printed.
- Note for script authors: warning lines now use the tracing format (