# crm — D365 CE on-prem CLI > CLI + agent guide for Dynamics 365 CE on-premises customizations crm — a stateful CLI for Microsoft Dynamics 365 Customer Engagement. Targets on-prem v9.x (NTLM) or Dataverse online (OAuth client-credentials) over the Dataverse Web API (OData v4) / HTTPS. Covers record CRUD, OData/FetchXML queries, metadata browsing and write, solution lifecycle, declarative apply, and bulk CSV/JSON export. `--json` everywhere for agents. # Getting started # Quickstart From nothing to a working query in about five minutes. ## 1. Install ``` irm https://pub-bbeb86c46454443ca76521dd4d29818e.r2.dev/install.ps1 | iex ``` ``` curl -fsSL https://pub-bbeb86c46454443ca76521dd4d29818e.r2.dev/install.sh | sh ``` The prebuilt binary bundles Python — nothing else to install. See [Install](https://crm-cli-docs.pages.dev/getting-started/install/index.md) for `uv` and from-source options, and for managed machines where the binary is blocked. ## 2. Open a new shell So your `PATH` picks up `crm`, then confirm: ``` crm --version ``` ## 3. Create a profile ``` crm profile add ``` On a terminal this runs a wizard: enter your server URL, and the CLI infers the auth scheme (any host containing `.dynamics.` → OAuth, anything else → NTLM), prompts for what that scheme needs, runs a `WhoAmI` to verify **before** saving anything, then stores the secret and activates the profile. See [Add a profile](https://crm-cli-docs.pages.dev/getting-started/add-profile/index.md) for the non-interactive form and what happens if the live test fails. ## 4. Confirm it works ``` crm connection whoami crm query odata accounts --top 5 ``` If `whoami` prints your user and organization, you're connected. ______________________________________________________________________ **Next:** - [Install the skill](https://crm-cli-docs.pages.dev/getting-started/skill/index.md) and [use `/crm` with a coding agent](https://crm-cli-docs.pages.dev/getting-started/agent/index.md). - Browse the [how-to guides](https://crm-cli-docs.pages.dev/how-to/connection/index.md) for task recipes. - Hit a snag? See [Troubleshooting](https://crm-cli-docs.pages.dev/getting-started/troubleshooting/index.md). # Concepts A few terms used throughout these docs. ## On-prem vs cloud `crm` talks to two kinds of Dynamics 365 CE servers. It picks the auth scheme from your server URL — you don't choose it manually. | | On-premises | Cloud (Dataverse online) | | ----------- | -------------------------------- | ----------------------------------- | | URL shape | `https://crm.contoso.local/org` | `https://contoso.crm.dynamics.com` | | Auth | **NTLM** (Windows Integrated) | **OAuth 2.0** client-credentials | | You provide | username (+ domain) and password | tenant id, client id, client secret | | API version | caps at v9.1 (auto-negotiated) | v9.2 | The same `crm` commands work against both targets. ## Profile A **profile** is a saved connection: the server URL, the auth scheme, the identity fields, and the secret. You create one with [`crm profile add`](https://crm-cli-docs.pages.dev/getting-started/add-profile/index.md) and switch between several with `crm profile use`. There is no `.env` file and no credential environment variables — credentials live only in a profile. State (profiles, cached tokens, completion scripts) lives under `~/.crm/`. The only environment knob that affects connections is `CRM_HOME`, which relocates that directory. ## Solution and publisher prefix In Dynamics, customizations belong to a **solution**, and new schema names carry a **publisher prefix** (e.g. `cwx_caseid`). Every customization-write command requires its own explicit `--solution ` — there is no profile default and no opt-out, so an accidental write can never silently land only in the system Default Solution. Attach a publisher prefix to a profile so you don't pass it every time: ``` crm profile add --url ... --publisher-prefix cwx --name crmworx ``` See [Configure & switch](https://crm-cli-docs.pages.dev/getting-started/configure/index.md) for the full field reference. # Install The canonical install instructions live in the project [README](https://github.com/Gharib89/crm#install) and are summarised here. ## Install script (no Python required) ``` irm https://pub-bbeb86c46454443ca76521dd4d29818e.r2.dev/install.ps1 | iex ``` ``` curl -fsSL https://pub-bbeb86c46454443ca76521dd4d29818e.r2.dev/install.sh | sh ``` Pin a version by setting `CRM_VERSION` (e.g. `v1.0.0`). The binary is unsigned; Windows SmartScreen may warn on first run. On managed machines it may be blocked outright by endpoint security (e.g. Microsoft Defender ASR or AppLocker) — use [uv tool install](#uv-tool-install-isolated) in that case. ### Integrity verification Both scripts verify the downloaded archive's SHA-256 against the `SHA256SUMS` published alongside it before extracting; a mismatch or a missing `SHA256SUMS` aborts the install. To pin a hash you obtained out-of-band, set `CRM_SHA256` (this also covers releases published before checksums existed): ``` curl -fsSL https://pub-bbeb86c46454443ca76521dd4d29818e.r2.dev/install.sh | CRM_SHA256= sh ``` ``` $env:CRM_SHA256=''; irm https://pub-bbeb86c46454443ca76521dd4d29818e.r2.dev/install.ps1 | iex ``` The checksum is fetched from the same R2 bucket as the archive, so this guards against download corruption and single-object tampering, not a full bucket compromise. Use `CRM_SHA256` with a hash from a trusted channel for stronger guarantees. ## uv tool install (isolated) Installs `crm` into an isolated environment that runs through your trusted `python` interpreter instead of a standalone binary, so there is no new unsigned executable for endpoint security to flag. Recommended when the install script's prebuilt binary is blocked on a managed machine. First install [uv](https://docs.astral.sh/uv/getting-started/installation/) if you don't already have it (`winget install --id=astral-sh.uv -e` on Windows, or `curl -LsSf https://astral.sh/uv/install.sh | sh` on Linux/macOS). Then: ``` uv tool install git+https://github.com/Gharib89/crm crm --version ``` `crm` is not published to PyPI, so install from the Git source (above) or a wheel. If even the launcher uv places on your PATH is blocked, run the CLI as a module — no executable is created at all: ``` uv run --from git+https://github.com/Gharib89/crm crm --version ``` ## From source ``` pip install -e . crm --version ``` Requires Python ≥ 3.13. See the README for the full per-platform walkthrough. ## Verify ``` crm --version ``` Then create a connection with [Add a profile](https://crm-cli-docs.pages.dev/getting-started/add-profile/index.md), or jump to the [Quickstart](https://crm-cli-docs.pages.dev/getting-started/quickstart/index.md). # Update Keep the `crm` CLI current. ## Check for a newer release ``` crm self-update --check ``` Reports your running version, the latest published version, and whether an update is available — without changing anything. Works on every install type. ## Upgrade in place ``` crm self-update ``` For a binary installed via the install script, this downloads the platform archive, verifies it against the published `SHA256SUMS`, and swaps the bundle in place. A checksum mismatch or download failure leaves your install untouched. For `pip` / `uv` / source installs, `self-update` doesn't touch the binary — it points you at `pip install -U crm` (or re-running `uv tool install`). A non-`--check` update also re-syncs any agent skills you installed (see [Install the skill](https://crm-cli-docs.pages.dev/getting-started/skill/index.md)), so the shipped skill never lags the CLI. ## The passive update notice On an interactive terminal, `crm` checks at most once every 24 hours for a newer release and prints a one-line notice on stderr after a command finishes. It is silent under `--json`, when stderr isn't a terminal, when `CI` is set, and when `CRM_NO_UPDATE_CHECK` is set. Opt out permanently: ``` export CRM_NO_UPDATE_CHECK=1 ``` See [how-to: self-update](https://crm-cli-docs.pages.dev/how-to/self-update/index.md) for the per-destination skill-sync detail and the full flag reference. # Add a profile A working setup is a single saved **profile** — the server URL, auth scheme, identity, and secret. Create one with: ``` crm profile add ``` On a terminal, `add` with no flags runs an interactive wizard: it asks for the server URL, infers the auth scheme from it (`*.dynamics.*` → OAuth, anything else → NTLM), collects the identity fields (NTLM username/domain or OAuth tenant/client id) and the secret, then runs a `WhoAmI` against the server **before** saving. On success it saves the profile, stores the secret, and activates it — no extra prompts. If the live test fails but the profile still looks structurally valid (likely a transient outage), it asks "Save the profile anyway?" before giving up; declining saves nothing. A clearly malformed profile (bad URL, missing tenant/client id, no secret) is rejected outright, live test or not. You don't even have to run it first: the **first time you run any connection command with no profile configured**, the CLI launches this wizard for you automatically (on a terminal). Under `--json` or a non-interactive shell it skips the wizard and errors cleanly, telling you to run `crm profile add`. ## Non-interactive (scripting / CI) Pass flags instead of answering prompts. **On-prem (NTLM):** ``` crm profile add \ --url https://crm.contoso.local/contoso \ --username alice --domain CONTOSO \ --password "$SECRET" \ --name prod ``` **Online / Dataverse (OAuth):** ``` crm profile add \ --url https://contoso.crm.dynamics.com \ --tenant-id --client-id \ --password "$CLIENT_SECRET" \ --name online ``` Non-interactively (`--json`, CI, scripts), a failed-but-plausible live test errors without saving unless you pass `--save-on-test-failure` — for spinning up a profile before its target is reachable (VPN not yet up, app user not yet provisioned). See [Configure & switch](https://crm-cli-docs.pages.dev/getting-started/configure/index.md) for the full NTLM vs OAuth field reference and day-to-day profile management, and [how-to: profile](https://crm-cli-docs.pages.dev/how-to/profile/index.md) for every flag. # Configure The CLI authenticates with **NTLM (Windows Integrated)** for on-prem, or **OAuth 2.0 client-credentials** for Dataverse online. Both live in a saved **profile** — there is no `.env` file and no credential environment variables. Create a profile with `crm profile add`: ``` crm profile add ``` On a terminal this runs an interactive wizard: it asks for the server URL, infers the auth scheme from it (`*.dynamics.*` → OAuth, anything else → NTLM), collects the identity fields and the secret, then runs a `WhoAmI` against the server **before** saving anything. On success it saves the profile, stores the secret, and activates it. If the test fails but the profile still looks structurally plausible, it asks before saving anyway (non-interactively, pass `--save-on-test-failure`); a clearly malformed profile is rejected outright, with nothing saved. For scripting, pass flags instead: **On-prem (NTLM):** ``` crm profile add \ --url https://crm.contoso.local/contoso \ --username alice --domain CONTOSO \ --password "$SECRET" \ --name prod ``` `--domain` is optional when the username is a UPN. Omit `--api-version` to auto-negotiate — on-prem caps at v9.1 (v9.2 returns HTTP 501), so the CLI steps down automatically. **Online / Dataverse cloud (OAuth):** ``` crm profile add \ --url https://contoso.crm.dynamics.com \ --tenant-id --client-id \ --password "$CLIENT_SECRET" \ --name online ``` The OAuth scope and authority are derived automatically (public cloud only). The app registration needs an **application user** with a security role in Dynamics. The bearer token is cached under `~/.crm/` (`0600`) and reused until it expires; username / password / domain are not used in this mode. Attach a schema-name prefix so scaffolding and column-name derivation don't need a per-command flag: ``` crm profile add --url ... --publisher-prefix cwx --name crmworx ``` There is no profile default for the target **solution** — every customization-write command requires its own explicit `--solution ` (see [How-to: profile](https://crm-cli-docs.pages.dev/how-to/profile/index.md)). ## How the secret is stored `crm profile add` stores the secret automatically. By default it goes into the OS keyring (macOS Keychain / Windows Credential Manager / Linux SecretService); on hosts with no keyring backend (typical WSL / headless CI) it falls back automatically to a `0600` plaintext entry inside the profile file. Force plaintext with `--store-password-plaintext`. Replace a stored secret later with `crm profile set-password --profile `. When a command needs the secret it resolves it in this order: `--password` (a per-run override) → the stored secret (plaintext entry, then keyring) → an interactive TTY prompt. No environment variable is consulted. State lives under `~/.crm/` — the only environment knob that affects connections is `CRM_HOME`, which relocates that directory. See [How-to: profile](https://crm-cli-docs.pages.dev/how-to/profile/index.md) for the full profile reference. ## Switching and managing profiles You can keep several profiles and switch the active one: ``` crm profile list # show all profiles; the active one is marked crm profile use online # make "online" the active profile crm profile edit prod # change saved fields crm profile set-password --profile prod # replace the stored secret crm profile rm old # delete a profile ``` Commands use the active profile unless you pass `--profile ` for a single run. See [how-to: profile](https://crm-cli-docs.pages.dev/how-to/profile/index.md) for every flag. ## Next-step hints The first time you run a few common commands (`crm profile add`, `crm profile use`, `crm solution export`, `crm query odata`) the CLI prints a dim one-line **next-step hint** suggesting a natural follow-up — for example, `crm connection whoami` after adding a profile. Each hint appears only once (tracked under `CRM_HOME`) and only in human/REPL output on a terminal; it is never emitted under `--json`, in a pipe, or to agents. To turn hints off completely, set `CRM_NO_HINTS` to any value: ``` export CRM_NO_HINTS=1 ``` # Install the skill `crm` ships an agent skill that teaches a coding agent how to drive Dynamics 365. Install it into your agent's skill directory: ``` crm skill install --target claude ``` `--target` is `claude | copilot | cursor` (default `claude`). This copies the bundled skill tree (`SKILL.md` + `reference/*.md`) into the agent's skill directory and records the destination so [`crm self-update`](https://crm-cli-docs.pages.dev/getting-started/update/index.md) keeps it in sync as the CLI upgrades. Install to a custom directory with `--dest ./my-skills` (overrides `--target`); add `--force` to overwrite an existing skill. See [how-to: skill](https://crm-cli-docs.pages.dev/how-to/skill/index.md) for `path`, `uninstall`, and every flag. Next: [use `/crm` with a coding agent](https://crm-cli-docs.pages.dev/getting-started/agent/index.md). # Use /crm with a coding agent Once you've [installed the skill](https://crm-cli-docs.pages.dev/getting-started/skill/index.md), a skill-aware agent (Claude Code, Copilot CLI) can drive Dynamics 365 for you in plain language. ## How it triggers The skill activates when your request mentions Dynamics 365, D365 CE, Dataverse, the Web API, FetchXML, or on-prem CRM. The agent then runs `crm` commands with `--json` and reads the structured output — no copy-pasting GUIDs. ## What it looks like ``` You: List the top 5 accounts by name from my D365 org. Agent: Running: crm --json query odata accounts --top 5 --select name --order-by name Here are the 5 accounts: 1. Adventure Works 2. Coho Vineyard 3. Contoso Ltd 4. Fabrikam, Inc. 5. Northwind Traders You: Create a new account called "Tailspin Toys". Agent: Running: crm --json entity create accounts --data '{"name":"Tailspin Toys"}' Created account Tailspin Toys (id 8a1c…). ``` ## Why `--json` Every `crm` command emits a structured envelope under `--json`, so the agent reads results deterministically instead of scraping human text. Use `--dry-run` to have the agent preview a mutation before it runs. Prerequisites: a working [profile](https://crm-cli-docs.pages.dev/getting-started/add-profile/index.md) (the agent uses your active connection) and the [installed skill](https://crm-cli-docs.pages.dev/getting-started/skill/index.md). # How-to # How-to: action OData function and action recipes, taken from the CRMWorx build (§4, §11). See the [CLI reference](https://crm-cli-docs.pages.dev/reference/cli/index.md) for every flag. ## Call an unbound function ``` crm --json action function RetrieveCurrentOrganization --params '{"AccessType":"Default"}' ``` `--params` is a JSON dict encoded inline per OData v4; returns the function result under `data`. ## Pass a record-reference or special-character param A param value that is a JSON object `{"@odata.id": "()"}` is a **record reference** — it is sent as an OData parameter alias (`Fn(P=@p1)?@p1=...`) carrying the `@odata.id`, so functions that take an entity reference are invocable: ``` crm --json action function RetrievePrincipalAccess \ --bind-set systemusers --bind-id \ --params '{"Target": {"@odata.id": "accounts()"}}' ``` String values that contain URL-reserved characters (`/ < > * % & : \ ? +`) or whitespace are aliased the same way automatically — passing them inline would `400`/`404`. Plain scalars stay inline. A malformed reference (a JSON object that is not exactly `{"@odata.id": "..."}`) is rejected with a clear error. ## Call a bound function ``` # Collection-bound: GET /Microsoft.Dynamics.CRM.(params) crm --json action function --bind-set # Record-bound: GET ()/Microsoft.Dynamics.CRM.(params) crm --json action function RetrieveUserPrivileges --bind-set systemusers --bind-id ``` `--bind-set` alone binds to the collection; adding `--bind-id` binds to a single record. Override the `Microsoft.Dynamics.CRM` namespace segment with `--cast ` only for custom namespaces. `--bind-id` requires `--bind-set`. Functions issue a **GET** (read-only, params encoded inline) — unlike `action invoke`, which issues a **POST**. ## Invoke an unbound action from a body file ``` crm --json action invoke AddAppComponents --body-file /tmp/cwx_addcomponents.json ``` Use `--body-file` (or `--body`) for the JSON payload; `AddAppComponents` binds typed entity references to a model-driven app. ## Invoke a bound action ``` crm --json action invoke --bind-set workflows --bind-id --body '{}' ``` `--bind-set` + `--bind-id` bind the action to a record; pass both together (override the namespace with `--cast` only for custom actions). `` is the action's schema/logical name (e.g. as listed in the entity's metadata). # How-to: app Create model-driven apps (appmodule) and bind components, taken from the CRMWorx build (§11, §13). See the [CLI reference](https://crm-cli-docs.pages.dev/reference/cli/index.md) for every flag. ## Create the app (idempotent) ``` crm --json app create --name CRMWorx --unique-name cwx_crmworx \ --description "CRMWorx IT ticketing" --if-exists skip ``` `--unique-name` must carry the publisher prefix; `--if-exists skip` reports a skip with the existing `appmoduleid` instead of duplicating — this holds even when a just-created app isn't query-visible yet: the server's duplicate fault is treated as a skip, with a best-effort `appmoduleid` that may be `null` until the app is published. Creation **stages** by default (no publish); the read-back goes through the appmodule *unpublished* view, so it succeeds whether or not `--publish` was passed — a bare app has no sitemap bound, so it can't itself be app-published to become GET-visible in the plain `appmodules` collection until it's completed and published that way (see `crm apply`'s `apps:` block, which does this for a created app once its sitemap is bound). The required `webresourceid` icon comes from `--icon-webresource ` (a name is resolved to its id, a GUID used directly, e.g. `--icon-webresource cwx_/icons/app.svg`); omit it to keep the platform default icon. ## Bind views, charts, forms, and the dashboard ``` crm --json app add-components \ --component view: --component chart: \ --component form: --component dashboard: ``` `` comes from `app create`. `--component` is repeatable as `kind:guid`; kinds are `view|chart|form|dashboard|sitemap|bpf`. Tables surface through the sitemap, not here. ## Unbind components ``` crm --json app remove-components \ --component view: --component chart: ``` The inverse of `add-components` (RemoveAppComponents): same repeatable `kind:guid` grammar and the same `view|chart|form|dashboard|sitemap|bpf` vocabulary. Use `crm --dry-run app remove-components ...` to preview the components it would unbind without issuing the call. ## Attach a sitemap ``` crm --json app set-sitemap "CRMWorx Sitemap" --xml-file sitemap.xml --unique-name cwx_crmworx ``` Reads SiteMapXml from `--xml-file`; `--unique-name` sets `sitemapnameunique` so the sitemap auto-associates with that app. Creation **stages** by default (no publish); pass `--publish` to publish it immediately, exactly as on `build-sitemap`. ## Build a sitemap from structured input ``` crm --json app build-sitemap "CRMWorx Sitemap" \ --area 'sales:Sales' \ --group 'sales/accounts:Customers' \ --subarea 'sales/accounts:entity=account:Accounts' \ --subarea 'sales/accounts:entity=contact' \ --unique-name cwx_crmworx ``` Generates the SiteMapXml for you, then creates the sitemap via the same path as `set-sitemap` (which instead uploads a pre-built XML file). The grammar is `--area 'id[:Title]'` (repeatable, at least one required), `--group 'areaId/groupId[:Title]'` (nested under an area), and `--subarea 'areaId/groupId:entity=[:Title]'`. Titles are optional throughout: omit an Area/Group title and it falls back to its own Id as the label. A SubArea binds a table through the SiteMapXml `Entity=` attribute; its Title is optional too, and when omitted the platform derives the label from the entity. SubArea Ids are auto-allocated from the entity logical name (you don't supply them); Area/Group Ids and the references between them are validated, so broken references or duplicate Ids fail with an error. Every attribute value is XML-escaped. `--unique-name` sets `sitemapnameunique` to auto-associate with that app, exactly as on `set-sitemap`. Creation **stages** by default (no publish); pass `--publish` to publish it immediately. Use `crm --dry-run app build-sitemap ...` to print the generated SiteMapXml without creating anything. ## Delete the app ``` crm --dry-run app delete cwx_crmworx # preview: names the app + dependent rows it would sweep, issues no DELETE crm app delete cwx_crmworx --yes # delete, skipping the destructive-op confirmation ``` Resolves `NAME_OR_ID` as an `appmoduleid` (GUID), else by `uniquename`, else by display `name`; an unknown or ambiguous name fails with a clear error. Before deleting the app it **sweeps the dependent data rows that hold a record-level foreign key to it** — chiefly `appsetting`. Without that sweep a bare `crm entity delete appmodules ` fails with server error `0x80048d21` ("cannot delete because it is referenced by another record"). This FK block happens on **both** on-prem v9.x and Dataverse online (online blocks too, even though the `appsetting` relationship's metadata claims cascade-delete), so the sweep does not trust cascade metadata — it removes whatever rows reference the app. A **managed** app is refused with an actionable error (uninstall its parent solution instead). The destructive-op gate is `--yes` (skip the confirmation, exactly like `entity delete`); the global `--dry-run` and `--json` apply. # How-to: apply `crm apply -f spec.yaml` stands up a whole custom table — publisher, solution, entity, columns, option sets, relationships, views, web resources, security roles, and plug-in assemblies with their types, steps, and images — from a single declarative spec. It orchestrates the existing metadata and plug-in commands in dependency order (publisher → solution → entities → option sets → attributes → relationships → views → web resources → security roles → plug-ins) and runs `PublishAllXml` **once** at the end — but only when a publishable component was created or updated. Security roles and plug-in components are not publishable customizations, so an apply that touches only those does not publish. `apply` is **convergent**: a component that already exists is reconciled against the spec rather than blindly skipped. Three outcomes per component: - **equal** — spec matches live definition → `skipped` (idempotent re-apply). - **updatable divergence** — an in-place-editable field drifted → updated in place, counted as `updated`. Updatable fields: entity display name / display-collection name / description, and enabling `has_notes` / `has_activities` (`false → true`); attribute display name, description, required level, and string `max_length` growth (shrinking is out of scope); adding declared options to a global option set; relationship cascade configuration, associated-menu (label / behavior / order), and `is_hierarchical`, plus the relationship-backed lookup column's display name, description, and required level (surfaced as one merged `updated` entry per relationship block); view `description`, `is_default`, `columns`, `filter_active`, `order_by`, `order_desc` (reconciled by record PATCH of regenerated fetchxml / layoutxml). - **immutable/destructive divergence** — the change cannot be made without dropping the component → `replace_blocked`: reported, **no write for that component**, run ends `ok=false` (exit 1). Blocked cases: entity ownership change; explicit `has_notes` / `has_activities` disable (`true → false` — enable-only; platform forbids disabling); `is_activity` change (identity); attribute data-type change; relationship type mismatch or a referenced/referencing-entity or lookup-column change. > **Create-only vs. reconciled spec keys.** The full builder keyword surface is expressible in the spec. Keys that are **reconciled** on re-apply (drift is detected and updated in place): > > - **Relationship** — `cascade_assign`, `cascade_delete`, `cascade_reparent`, `cascade_share`, `cascade_unshare`, `cascade_merge` (cascade configuration); `menu_label`, `menu_behavior`, `menu_order` (associated-menu); `is_hierarchical`; and the relationship-backed lookup column's `lookup_display`, `lookup_description`, `required` (reconciled via the referencing attribute — surfaced as one merged `updated` entry per relationship block). > - **Entity** — `has_notes` and `has_activities` (`false → true` only — enable-only capability; the platform forbids disabling, so an explicit `true → false` is `replace_blocked`); display name, display-collection name, and description (see the updatable bullet above). `is_activity` divergence is `replace_blocked` (identity change). Only spec-declared fields drift; omission never blanks. > - **View** — an existing saved view matched by `(entity, name, query_type)` is reconciled in place: `description`, `is_default`, `columns` (regenerates `layoutxml`), `filter_active`, `order_by`, `order_desc` (regenerate `fetchxml`). A changed `name` or `query_type` has no live match and falls to the create path (a new view is made; the old one is left for `--prune`) — a documented limitation. An ambiguous match (>1 live view sharing the identity tuple) is `skipped` with a reason rather than patching an arbitrary row. > > Keys that take effect at **CREATE only** (re-applying an existing component does **not** yet reconcile them): > > - **Attribute** — `default_value`, `true_label` / `false_label`, `min_value` / `max_value`, `max_size_kb`, `auto_number_format`, `behavior_name`, `relationship_schema`, `is_audit_enabled`. > - **Entity** — `primary_attr_max_length`, `data_provider_id`, `data_source_id`, `external_name`, `external_collection_name`, `is_audit_enabled`. > - **Relationship** — `is_audit_enabled` (on the backing lookup column). > > `export-spec` emits the subset of these keys that map to live Web API fields (the flat `cascade_*`/`menu_*`/`is_hierarchical`/`lookup_description`, view `filter_active`/`order_desc`, attribute `auto_number_format`/`min_value`/`max_value`/ `behavior_name`/`max_size_kb`, entity `has_notes`/`has_activities`/`primary_attr_max_length`), omitting platform defaults, so a create-side round-trip is lossless for those. Reconciliation also runs under `--dry-run`, read-only: it reads the live org while the reads-execute rule suppresses every write, so a dry-run reports the full drift — `planned` (would create), `updated` (would update, each entry carrying a field-level `diff`), `replace_blocked`, and `pruned` (solution components absent from the spec) — without issuing a write. See the [CLI reference](https://crm-cli-docs.pages.dev/reference/cli/index.md) for the flags. ## Spec schema The spec is YAML (JSON is also accepted — it is a YAML subset). Every section is optional; provide only what you want to create. ``` publisher: unique_name: contosopub # required friendly_name: Contoso Publisher prefix: contoso # required — 2-8 alphanumerics, customizationprefix option_value_prefix: 10000 # required — 10000-99999 solution: unique_name: ContosoCore # required; created components land here friendly_name: Contoso Core version: 1.0.0.0 optionsets: # global (org-level) option sets - name: contoso_priority # required, must include the publisher prefix display_name: Priority # required options: - {value: 100000000, label: Low} - {value: 100000001, label: High} entities: - schema_name: contoso_Project # required, PascalCase with prefix display_name: Project # required display_collection_name: Projects ownership: UserOwned primary_attr: {schema_name: contoso_Name, label: Name} attributes: - {kind: string, schema_name: contoso_Code, display_name: Code, max_length: 100} - {kind: picklist, schema_name: contoso_Priority, display_name: Priority, optionset_name: contoso_priority} - {kind: lookup, schema_name: contoso_Owner, display_name: Owner, target_entity: systemuser} relationships: - schema_name: contoso_project_task referenced_entity: contoso_project # the "1" side referencing_entity: contoso_task # the "many" side lookup_schema: contoso_ProjectId lookup_display: Project views: - {name: Active Projects, columns: [contoso_name, contoso_code]} forms: # converge the entity's main form (ADR 0024) - # name omitted — targets the entity's primary main form tabs: - name: contoso_details # required; logical tab name (scripts bind to it) label: Details # optional (defaults to name) columns: 2 # optional, 1-4 sections: - name: contoso_info label: Info fields: - {name: contoso_code, label: Code} # attribute logical name libraries: [contoso_/scripts/project.js] # web-resource names handlers: - {event: onload, function: Contoso.onLoad, library: contoso_/scripts/project.js} - {event: onchange, field: contoso_code, function: Contoso.onCode, library: contoso_/scripts/project.js} webresources: - name: contoso_/scripts/project.js # required, unique name (must include publisher prefix) file: scripts/project.js # required, path relative to the spec file display_name: Project Script # optional # webresourcetype omitted — inferred from .js extension security_roles: - name: Contoso Project Manager # required, role display name (key) # business_unit omitted — defaults to the caller's business unit privileges: - access: [read, write, create] entities: [contoso_project, contoso_task] depth: deep - access: [read] all_entities: true depth: basic - privilege_names: [prvReadSystemForm] depth: global plugins: - assembly: Contoso.Plugins # optional; defaults to the DLL's file stem file: bin/Contoso.Plugins.dll # required; resolved relative to the spec file isolation_mode: sandbox # optional (none|sandbox), default sandbox version: 1.0.0.0 # optional override, default 1.0.0.0 # culture / public_key_token / description are optional overrides types: - type_name: Contoso.Plugins.AccountHandler # required; fully-qualified class (the key) friendly_name: Account Handler # optional steps: - name: Contoso Account Handler # required; unique, stable convergent key message: Create # required; SDK message (e.g. Create, Update) plugin_type: Contoso.Plugins.AccountHandler # required; a registered type (declare it under types to register it) entity: account # optional; entity scope (omit = message-level) stage: postoperation # optional (prevalidation|preoperation|postoperation), default postoperation mode: sync # optional (sync|async), default sync rank: 1 # optional, default 1 filtering_attributes: name,... # optional; only meaningful on Update configuration: "..." # optional unsecure config images: - alias: PreImage # required; the key within the step image_type: pre # required (pre|post|both) attributes: name,... # optional; comma-separated logical names message_property_name: Target # optional override apps: # top-level: model-driven apps (ADR 0024) - name: Contoso Projects # required; app display name unique_name: contoso_projects # required; publisher-prefixed identity description: Project management # optional components: # optional; record-backed components to bind - {kind: view, id: 00000000-0000-0000-0000-000000000000} # kind: view|chart|form|dashboard|bpf|sitemap sitemap: # optional; navigation, replaced wholesale areas: - id: contoso_area title: Projects # optional (defaults to id) groups: - id: contoso_group title: Delivery subareas: - {entity: contoso_project, title: Projects} # tables reach the app here ``` `attributes[].kind` is any kind `metadata add-attribute` accepts (`string`, `memo`, `integer`, `bigint`, `decimal`, `double`, `money`, `boolean`, `datetime`, `picklist`, `multiselect`, `image`, `file`, `lookup`). A `picklist` needs `optionset_name` (a global set, usually declared under `optionsets`) **or** inline `options`; a `lookup` needs `target_entity`. `max_length` is optional for `string`/`memo` (defaults to 100 / 2000 when omitted) and rejected on any other kind. `source_type` (`simple` / `calculated` / `rollup`) and `formula_definition` (XAML string) layer a rollup or calculated column on top of a supported kind — mirroring `metadata add-attribute --type`. `source_type: calculated` or `rollup` requires `formula_definition` and is rejected on `lookup`/`customer` kinds. Omitting `source_type` (or `source_type: simple`) creates a plain column. The reconcile pass does **not** compare `formula_definition` — drift in the formula is not detected or updated. View `columns` are entity **logical** names; use `name:width` (or `{name, width}`) to set a column width (default 100). Malformed input is rejected up front, before any HTTP call. `webresources[].webresourcetype` is an integer (1=HTML, 2=CSS, 3=JS, 4=XML, 5=PNG, 6=JPG, 7=GIF, 8=XAP, 9=XSL, 10=ICO, 11=SVG, 12=RESX). When omitted, the type is inferred from the file extension. The web resource body comes from either: - `file` — a path resolved relative to the spec file's directory (type inferred from extension when `webresourcetype` is omitted), or - `content` — an inline base64 string (emitted by `solution export-spec`; when using this form `webresourcetype` is required, as there is no extension to infer from). Exactly one of `file` or `content` must be present. Web resources are published by the end-of-run `PublishAllXml` (deferred by `--stage-only`). Convergent: unchanged content → `skipped`; content or display name drift → `updated`. `security_roles[].privileges` is a list of grant rows that are merged into the declared set (highest depth wins per privilege). Each row specifies `depth` (`basic`/`local`/`deep`/`global`) and either: - `access` (list of actions: `read`, `write`, `create`, `delete`, `append`, `appendto`, `assign`, `share`) with `entities` (list of logical names) **or** `all_entities: true`, or - `privilege_names` (list of privilege names like `prvReadAccount`). Convergent: skipped when every declared privilege is already present at its declared depth; on drift, replaced to the declared set (extras dropped). ## Security role privileges: platform baseline and removal-only no-op Dataverse automatically grants every role a set of **immovable baseline privileges** (e.g. SharePoint document management, prvReadSharePointData). These cannot be removed via `ReplacePrivilegesRole`. "Exactly the declared set" means the declared privileges at their declared depths **plus** those immovable platform privileges — apply will not report a `replace_blocked` for them. A privilege **dropped** from the spec is only removed if some other declared privilege also drifts in the same run (triggering a fresh replace). A **removal-only change** — where all remaining declared privileges are already satisfied — is a convergent no-op. To force a remove-only reconciliation, make a no-op edit to another privilege in the role (e.g. increment and reset a depth), or use `crm security set-role-privileges` directly. ## Forms: converge the entity's main form A `forms:` block is nested under an entity and **converges that entity's platform-generated main form** (ADR 0024) — `apply` never forges a form from scratch. The platform auto-creates a valid main form on entity create; the block layers its declared structure onto that form and commits one `formxml` PATCH: - `name` (optional) selects among the entity's main forms; omitted, the primary main form is used. A declared `name` must match an existing main form. - `tabs[]` — each needs a `name` (the logical name scripts bind to); `label` and `columns` (1-4) are optional. `sections[]` nest under a tab (same `name` / `label` / `columns` shape). `fields[]` nest under a section; each needs the attribute's logical `name` and takes an optional `label` (the control `classid` is resolved from the attribute's type). - `libraries[]` — JS web-resource names to register on the form (each must already exist as a web resource; declare it under `webresources:` in the same spec to seed it first). - `handlers[]` — event wiring. Each needs `event` (`onload` / `onsave` / `onchange`), `function`, and `library`; an `onchange` handler also needs the `field` it fires on (and only `onchange` takes a `field`). `pass_context` (default true) and `enabled` (default true) are optional. Convergence is **additive and idempotent**: a declared tab / section / field / library / handler that is absent is added; one already present but **drifted** is converged in place and counted `updated` with a field-level `diff`; one that already matches is left untouched, so re-applying an unchanged spec reports the form `skipped`. The drift converged in place: a tab / section **label**, a field's **tab + section placement** (the existing control is relocated, not duplicated), the **relative order** of the declared tabs / sections, and a handler's `enabled` / `pass_context` **flags**. A field's control `classid` is **create-only** — a live control type that diverges from the attribute is never retyped in place. An **identity / ownership divergence** is refused with no write (`replace_blocked`, exit 1, siblings still reconcile): a declared `name` that does not resolve to a single existing main form. The stance is *converge an existing main form* — `apply` never creates a named form from scratch or rewrites the wrong one. `--dry-run` classifies the form `planned` (a greenfield entity's main form not yet materialised is also `planned`) or, when only drift is present, `updated` with the would-converge `diff`. Forms publish with the rest of the customization at the end-of-run `PublishAllXml`, and `--stage-only` defers that publish like every other kind. Forms are **out of scope for `--prune`**. ## Model-driven apps: create the app, then converge it A top-level `apps:` block declares a model-driven app (ADR 0024). An app that does not yet exist is **created** through the same app-module and sitemap builders the `crm app` verbs use, its declared components bound and its sitemap set, so one spec can stand up a table and the app that exposes it in a single `apply`. An app that **already exists** is **reconciled** (#796) against the declared block: - `name` and `unique_name` are required; `unique_name` is the publisher-prefixed identity (the convergent key) and `description` is optional. - `components[]` — record-backed components to bind via `AddAppComponents`. Each is `{kind, id}` where `kind` is one of `view`, `chart`, `form`, `dashboard`, `bpf`, `sitemap` and `id` is the component's GUID. **Tables are not bound here** — they reach the app through a sitemap subarea (`entity:` below). - `sitemap` — navigation as `areas[] → groups[] → subareas[]`. An area/group takes an `id` and optional `title` (defaults to the id); a subarea takes an `entity` (logical name) and optional `title`. The sitemap is auto-linked to the app by its unique name and set from the declared document wholesale. **Reconcile, on re-apply:** - **Component set** — only view/chart/form/dashboard/bpf components are diffed (the app's sitemap component and its implicit table bindings are never touched here). A component declared in `components[]` but not bound on the live app is added (`AddAppComponents`); a component the live app binds but the spec no longer declares is removed (`RemoveAppComponents`). - **Sitemap** — converges by **whole-document replacement**: if the declared sitemap XML differs from the live sitemap's, the live `sitemapxml` is PATCHed wholesale. An app with no linked sitemap yet gets one created. - **Verdicts** — an app that already matches the declared block is `skipped` (idempotent re-apply); any component or sitemap change is `updated`, and the entry carries a `components: [{kind, id, change: "added"|"removed"}]` list and/or a `sitemap: "converged"|"added"` field. A **managed** app is refused with no write — `replace_blocked`, run exits 1, siblings still reconcile — since its components and sitemap are owned by its parent solution. - `--dry-run` reads the live app and reports the full drift as `updated` (component and sitemap changes included), with every converge write suppressed. `--dry-run` on an absent app classifies it `planned` and issues no write. Apps and sitemaps are publishable, so a created or updated app publishes with the rest of the customization at the end-of-run `PublishAllXml`, and `--stage-only` defers that publish. A **created** app that has a sitemap bound is also app-published individually (`PublishAllXml` does not publish appmodules) so it is immediately GET-visible in the `appmodules` collection rather than an invisible orphan; a bare app (no sitemap) can't pass app-scoped publish validation and is left unpublished. An **updated** (already-existing) app is not re-app-published. Malformed app/sitemap blocks are rejected up front, before any HTTP call. Apps are **out of scope for `--prune`**. App `unique_name` identity is not changed in place; app existence (create vs. reconcile) is the only thing this block decides between. ## Plug-ins: assembly, types, steps, and images `plugins[]` declares one or more plug-in assemblies. Each entry identifies a built DLL (`file`, resolved relative to the spec file) and optionally names the assembly, its isolation mode, and its version override. Under it you declare the `types[]` (the `IPlugin` classes) and `steps[]` (SDK message processing steps) that should exist. Convergent reconcile per component: - **Assembly** — created when absent; the DLL content is PATCH-updated when a rebuilt binary differs from the live assembly; skipped when content is identical. The assembly name (or file stem if `assembly:` is omitted) is the convergent key. - **Type** — registered when the declared `type_name` is not already present; skipped when it is. `type_name` is immutable once registered. - **Step** (keyed by the unique `name`) — created when absent; runtime config fields (`stage`, `mode`, `rank`, `filtering_attributes`, `configuration`) are updated in place when they drift. Only spec-declared fields are reconciled. A **binding change** — a different `message`, `entity`, or `plugin_type` on an existing step — is classified `replace_blocked`: reported, no write, run exits - The platform fixes bindings at creation; updating them requires a delete-and-recreate that `apply` does not perform automatically. - **Image** (keyed by step + `alias`) — registered when absent; skipped when already present. Plug-in components are not publishable, so a plugins-only apply does not issue `PublishAllXml`. `--dry-run` is fully supported: a greenfield spec reports components as `planned`; drift reports `updated` (with field-level `diff`) or `replace_blocked`. > On-prem metadata writes are synchronous, so a single apply registers a new assembly, its types, and its steps in one pass. On Dataverse (cloud) a newly-registered plug-in type can take a few seconds to become queryable, so a single apply that both registers a new type **and** a step binding to it may report the step as `failed` (the type is not yet resolvable); re-apply once it has propagated and the step lands (the already-created assembly and type are skipped). On-prem is the plug-in extensibility target. ## Stand up a table in one shot ``` crm --json apply -f project.yaml ``` Output is `{ok, data:{applied, updated, skipped, replace_blocked, pruned, planned, failed}, meta:{staged}}`. Each entry is `{kind, name}`; `failed` and `replace_blocked` entries also carry `error` / `reason`. A re-apply of an unchanged spec reports all matching components under `skipped`. A re-apply of a changed spec reports in-place edits under `updated`; immutable divergences under `replace_blocked` (and exits 1). `pruned` entries carry `{kind, name, deleted}` (plus `reason` when a data-bearing component is refused without `--allow-data-loss`, plus `would_prune: true` under `--dry-run`). `pruned` is populated under `--dry-run` (candidates, `deleted: false`) and `--prune` (deletions); a plain real-run apply with neither leaves it empty. ## Preview a greenfield spec ``` crm --dry-run --json apply -f project.yaml ``` Dry-run reports everything that would be created under `planned` and makes no changes. Dependents of a not-yet-created resource (a column on a new table, a picklist on a new option set, a solution on a new publisher) are reported `planned` too, instead of erroring. > A brand-new table's `ObjectTypeCode` is often not readable until the apply's final publish, so its **views land as `planned`**. Run `apply` a second time after the first publish to create them. ## Save a drift report as a plan artifact `-o/--plan-out ` serializes the `--dry-run` drift report to a **plan** — a self-contained JSON artifact you can review, attach to a PR or ticket, and later execute with `--from-plan` (below). It is valid **only** with the global `--dry-run` flag (a usage error, exit 2, otherwise). ``` crm --dry-run --json apply -f project.yaml -o project.plan.json ``` The plan is written on **every** dry-run, including one that exits 1 because a component is `replace_blocked` — the plan doubles as the drift-report artifact, and the exit code is unchanged. A plan captures: - a **header** — plan-format version, the target Web API base URL and `organizationid` (from WhoAmI), the solution `unique_name`, the CLI version, a timestamp, and the **plan intent** (`prune`, `allow_data_loss`, `stage_only` as passed at plan time); - the resolved **spec** embedded verbatim (not a path reference); - **payload pins** — a `sha256` per referenced file payload (web-resource bodies, plug-in assembly DLLs), pinning content without inlining it; and - **verdict records** — one per component: its `kind`, `name`, `verdict` (`planned` / `updated` / `skipped` / `replace_blocked` / `pruned` / `failed`), and the field-level `diff` where the engine computes one. Verdict records cover **every** apply kind — schema, automation, and the UI kinds (forms, and model-driven apps + their sitemaps, ADR 0024) alike, since `kind` is a free string. An `updated` form carries its per-component converge diff; an `updated` app carries a changed-field set of its component-add/remove and sitemap actions — so a whole customization (schema + UI) rides one approval-gated plan. Forms and apps stay **out of `--prune`** (ADR 0024), so a `pruned` record never names one. The command's own JSON envelope also reports the written path in `meta.plan_out`. ## Execute a plan — approval-gated apply `--from-plan ` runs a saved plan, but **only if it is still exactly true**. This closes the approval gap: what you reviewed in the plan is what runs, or nothing runs. It is mutually exclusive with `-f` (exactly one is required). ``` # 1. plan (review plan.json, attach it to a PR/ticket, get it approved) crm --dry-run --json apply -f project.yaml -o project.plan.json # 2. re-verify without executing — the CI pre-check crm --dry-run --json apply --from-plan project.plan.json # 3. execute the approved plan crm --json apply --from-plan project.plan.json ``` **Plan intent is replayed, not re-specified.** `--prune`, `--allow-data-loss`, and `--stage-only` are fixed when the plan is created and read back from its header — passing any of them (or `-o`) alongside `--from-plan` is a usage error (exit 2). The destructive confirmation (`--yes` / TTY prompt) still applies when the plan carries prune intent. **Pre-flight refusals (exit 1, no writes):** - an unknown/newer `plan_format` the CLI can't read; - an `organization_id` that doesn't match the connected org's WhoAmI (a mismatched URL or CLI version is a `meta.warnings` note, not a refusal — hostnames may be aliased); - a plan carrying `replace_blocked` / `failed` components (the **clean-plan rule** — such a plan approves an outcome apply will never converge to); - any pinned payload that is missing or whose content changed since plan time. Payload `file` paths are resolved relative to **the plan file's directory**, so keep referenced web-resource/DLL files next to the plan. **The whole-run gate.** Execution first recomputes the drift report from live reads and compares it to the plan at the **action level** — the component set, each verdict, and each `updated` component's changed-field set must match exactly (live field *values* need no byte equality). Any divergence is a **stale plan**: zero writes, `ok=false`, exit 1, with each diverged component reported under `data.divergences` as "plan said X, live now computes Y". The remedy is always to re-plan and re-approve. Without prune intent, a stray new solution component that surfaces live is informational and does not invalidate the plan; under prune intent the approved deletions participate in the gate. A live **UI edit** between plan and apply — a form's layout or an app's sitemap changed out of band — shifts that component's recomputed action and is caught by the same gate. `--dry-run --from-plan` stops after the compare (verify mode): it reports `data.plan_valid` (`true`/`false`) and writes nothing — use it as a CI gate that a pending plan is still applicable. > **TOCTOU note.** A residual window survives between the verify pass and the writes — metadata writes are not transactional — so a concurrent customization could still slip in after the gate passes. The gate shrinks the window from preview-to-apply to verify-to-write; it does not eliminate it. ## Stage without publishing ``` crm --stage-only --json apply -f project.yaml ``` `--stage-only` creates every component but skips `PublishAllXml`; `meta.staged` is `true`. Publish later with `crm solution publish`. ## Partial failure Metadata POSTs are **not transactional**. If a step fails, `apply` aborts the remaining steps, reports the failure under `failed` (with the error), exits non-zero, and does **not** publish. Whatever was already created is left staged-but-unpublished (`meta.staged` is `true`) — fix the spec and re-apply; the already-created resources are skipped. ## Prune org-extras `--prune` opts in to solution-bounded deletion of components that are members of the target solution but are no longer declared in the spec. A plain `apply` never reads solution members and never deletes anything; pruning is entirely opt-in. **Eligible kinds (six):** `entity`, `attribute`, `view`, `security-role`, `webresource`, `plugin-step`. Every other solution component type (option sets, relationships, plug-in assemblies/types, forms) is out of scope. **Gating:** - Schema-only extras (`view`, `security-role`, `webresource`, `plugin-step`) are deleted on `--prune` after confirmation. - Data-bearing extras (`entity`, `attribute`) destroy row data and are refused unless `--allow-data-loss` is also passed. Without it they appear in `pruned` with `deleted: false` and `reason: "data-bearing; pass --allow-data-loss to delete"`. - Under `--json` or a non-TTY, a **real** `--prune` requires `--yes` (no interactive prompt); a `--dry-run` preview deletes nothing and needs no confirmation. - `--prune` is scoped to the spec's mandatory top-level `solution:` block — every spec must declare one (see "Spec schema" above), so a target solution is always in place. - Pruning is suppressed when the convergence phase itself has failures or replace-blocked components — a partial-failure run does not also delete org-extras. **Always preview first:** ``` crm --dry-run --json apply -f project.yaml --prune ``` Dry-run reports all prune candidates under `pruned` with `deleted: false`; those that would actually be deleted carry `would_prune: true`. No write is issued. Pruning never triggers a publish. **Worked example — remove a stale web resource and view from the solution:** Spec `project.yaml` declares `solution: {unique_name: ContosoCore}` and previously declared `contoso_/scripts/old.js` and a view `Old Projects`; both are now removed from the spec. The solution still has them as members. ``` # 1. Preview what would be pruned crm --dry-run --json apply -f project.yaml --prune # 2. Apply with pruning (interactive confirmation on a TTY) crm apply -f project.yaml --prune # 3. Under CI / --json there is no prompt, so pass --yes crm --json apply -f project.yaml --prune --yes ``` Example output (`data.pruned`): ``` [ {"kind": "webresource", "name": "contoso_/scripts/old.js", "deleted": true}, {"kind": "view", "name": "Old Projects", "deleted": true} ] ``` A data-bearing extra refused without `--allow-data-loss`: ``` {"kind": "attribute", "name": "contoso_legacycode", "deleted": false, "reason": "data-bearing; pass --allow-data-loss to delete"} ``` ## Referenced global option sets When a spec contains an `optionsets` block, `apply` automatically adds each referenced global option set to the spec's `solution:` block target (via `AddSolutionComponent`) even if the option set already existed and was skipped during creation. This ensures option sets created in a previous apply run (or pre-existing) are properly linked to the solution. To opt out: ``` crm apply -f spec.yaml --no-include-referenced-optionsets ``` This flag is also exposed as `include_referenced_optionsets` on the Python `apply_spec` function for programmatic callers. # How-to: async Common `crm async` recipes. See the [CLI reference](https://crm-cli-docs.pages.dev/reference/cli/index.md) for every flag. ## List pending async operations ``` crm --json async list --state ready --top 50 ``` `--state` accepts `ready | suspended | locked | completed | `; add `--all` to follow `@odata.nextLink` (capped by `--max-pages`). ## List operations for a specific message ``` crm --json async list --message ImportSolution ``` `--message` filters by `messagename`; `--owner ` narrows to one user. ## Advanced filtering and sorting ``` crm --json async list --state ready --filter "executiontimespan gt 1000" --order-by "startedon asc" ``` Use `--filter` for raw OData `$filter` expressions (AND-joined with canned filters like `--state` or `--message`). The default sort is `--order-by "createdon desc"`. ## Inspect one operation ``` crm --json async get ``` Use an `asyncoperationid` from `async list`; the response includes `state`, `status`, and `message` for error detail. ## Cancel a pending or suspended operation ``` crm --json async cancel --yes ``` `--yes` skips the confirmation prompt; cancellation is gated by the destructive-op hook. # How-to: audit Server-side audit history from Dynamics 365 — distinct from the local `session audit` journal, which records only this CLI's own mutations. See the [CLI reference](https://crm-cli-docs.pages.dev/reference/cli/index.md) for every flag. ## Prerequisites - The calling user needs the **`prvReadRecordAuditHistory`** and **`prvReadAuditSummary`** privileges. - Auditing must be enabled at the org level, the table level, and (for column-level detail) the column level in D365. When auditing is off the server returns a well-formed but empty `AuditDetailCollection` — no error, just zero rows. ## Retrieve a record's change history ``` crm --json audit history accounts ``` Returns an `AuditDetailCollection` with the paging fields `MoreRecords` (bool), `PagingCookie` (str), and `TotalRecordCount` (int), plus an `AuditDetails` array — one entry per audited change event. Each `AuditDetail` entry carries an `AuditDetailType` field (e.g. `AttributeAuditDetail`) derived from the Web API `@odata.type` discriminator. The standard emit envelope strips all `@odata.*` keys (ADR 0008), so without this promotion the subtype would be invisible — `AuditDetailType` is the canonical field to branch on. ## Page through a long history `MoreRecords: true` means more pages exist. Pass the returned `PagingCookie` to fetch the next page: ``` # Page 1 (default) crm --json audit history accounts --count 20 # Page 2 — copy the PagingCookie from the page 1 response crm --json audit history accounts --count 20 \ --paging-cookie '' ``` The `--page` option selects a 1-based page number when you know the target page upfront (no cookie needed). Combine `--page` with `--count` to jump directly to a page of a known size. ## Retrieve a single audit row by ID ``` crm --json audit detail ``` Decodes the `AuditDetail` for one row in the `audits` table. The concrete type is surfaced as `AuditDetailType` (same promotion as above). Use this to inspect the full before/after field values for a specific change event when you already have the `auditid` from an `audit history` result. ## Why not `action function`? `RetrieveRecordChangeHistory` requires a `Target` EntityReference and a `PagingInfo` complex object passed as OData parameter aliases (`?@target=...&@paginginfo=...`). The `action function` command encodes parameters inline as `Fn(k=v)`, which cannot express complex-type aliases. The `audit` group handles this encoding internally, making these functions reachable from the CLI for the first time. ## Session audit vs server-side audit | | `crm session audit` | `crm audit history` | | ----------------- | ------------------------------------------------------- | ----------------------------------------------------------- | | What it records | Mutations issued by **this CLI** in the current session | All audited changes stored in the **D365 server** audit log | | Where data lives | Local JSONL file (`~/.crm/audit/.jsonl`) | D365 `audits` entity, server-side | | Connection needed | No | Yes | | Privilege needed | None (local file) | `prvReadRecordAuditHistory` + `prvReadAuditSummary` | # How-to: chart Author system and user charts headlessly — list, get, create, and delete `savedqueryvisualization` (system) and `userqueryvisualization` (user) records without opening the chart designer. See the [CLI reference](https://crm-cli-docs.pages.dev/reference/cli/index.md) for every flag. A chart binds to its host table via `primaryentitytypecode` (the logical-name string) and carries two XML columns: `datadescription` (an aggregate FetchXML) and `presentationdescription` (the rendering/series XML). Authoring a chart from source control means committing those two XML files and recreating the chart with `chart create`. ## List a table's charts ``` crm chart list contact # system charts (the default) crm chart list contact --user-owned # user-owned charts ``` Output columns: `name`, the id (`savedqueryvisualizationid`, or `userqueryvisualizationid` with `--user-owned`), and `isdefault` (system charts only). `list` returns only these list-oriented fields — to read a chart's `datadescription` / `presentationdescription` XML, use `chart get `. ## Get a single chart ``` crm chart get 1111aaaa-2222-bbbb-3333-cccccccccccc crm chart get 1111aaaa-2222-bbbb-3333-cccccccccccc --user-owned ``` `get` returns the chart's XML in the `--json` envelope — pipe it to files to capture a chart into source control: ``` crm --json chart get | jq -r '.data.datadescription' > chart.data.xml crm --json chart get | jq -r '.data.presentationdescription' > chart.pres.xml ``` ## Create a chart A system chart is the default; `--user-owned` creates a user-owned chart. There are two mutually exclusive authoring modes. ### XML mode — from datadescription + presentationdescription files ``` crm chart create contact \ --name "Contacts by Method" \ --data-description chart.data.xml \ --presentation-description chart.pres.xml \ --solution cwx_crmworx ``` `--solution` is required on every mutating chart verb (`create`, `update`, `set-fetch`, `add-series`, `remove-series`, `set-groupby`) — a component created without an explicit target solution would otherwise land only in the system Default Solution. Pass `--solution Default` for a deliberate Default-Solution-only write. Both files are required in XML mode. The server validates the XML (for example, the number of chart areas in the presentation XML must match the number of categories in the data XML), so a malformed pair is rejected with a `400`. ### Web-resource mode — script-based visualization ``` crm chart create contact --name "Custom Viz" --web-resource new_chartscript --solution cwx_crmworx ``` `--web-resource` takes a web resource name or GUID and is mutually exclusive with `--data-description` / `--presentation-description`. A name is resolved to its `webresourceid` automatically. ### Create a user chart ``` crm chart create contact --name "My View" \ --data-description chart.data.xml \ --presentation-description chart.pres.xml \ --solution cwx_crmworx \ --user-owned ``` ### Publishing `chart create` **stages** the change by default — no `PublishAllXml` runs (the CLI-wide convention shared with `form clone`, `metadata create-entity`, etc.). Pass `--publish` to make a new chart visible immediately, or batch several operations and publish once at the end: ``` crm chart create contact --name "Q" \ --data-description d.xml --presentation-description p.xml \ --solution cwx_crmworx --publish # ...more staged operations... crm solution publish-all # publish everything once the batch is done ``` ### Preview without writing ``` crm --dry-run chart create contact --name "Q" \ --data-description d.xml --presentation-description p.xml \ --solution cwx_crmworx ``` Returns `{_dry_run: true, would_create: {entity_set, body}}` with the fully resolved request body (a `--web-resource` name is resolved live first); no chart is created. `--solution` is still required under `--dry-run` — it is validated before any backend call. ## Delete a chart ``` crm chart delete 1111aaaa-2222-bbbb-3333-cccccccccccc --yes crm chart delete 1111aaaa-2222-bbbb-3333-cccccccccccc --user-owned --yes ``` `delete` is destructive: omitting `--yes` prompts on a TTY, and under `--json` or a non-TTY it fails fast with an error that names `--yes`. Under `--dry-run`, delete returns `{_dry_run: true, would_delete: true, savedqueryvisualizationid: }` (or `userqueryvisualizationid` with `--user-owned`) without issuing the `DELETE`. To remove a chart from a solution (rather than delete it), use `crm solution remove-component`. ## Update a chart's XML, name, or series type `chart update` replaces one or both XML columns, the display name, the description, or the chart type on every series — any combination, in one call: ``` crm chart update --data-description new.data.xml --solution cwx_crmworx crm chart update --presentation-description new.pres.xml --solution cwx_crmworx crm chart update --name "Contacts by Region" --description "Q3 rollout" --solution cwx_crmworx crm chart update --type Bar --solution cwx_crmworx crm chart update --data-description d.xml --presentation-description p.xml --solution cwx_crmworx ``` `--type` sets `ChartType` on **every** `` element in the presentationdescription (e.g. `Column`, `Bar`, `Line`, `Pie`). ### Partial-XML update and the alias-coupling invariant When only one of `--data-description` / `--presentation-description` is supplied, the command reads the other column live from the server and validates the cross-container alias-coupling pair before PATCHing. This means: - Every `` in the fetch must match a `` alias in the datadescription and a positionally-coupled `` in the presentationdescription. - A mismatched pair is rejected before any write is issued. The chart's host entity (`primaryentitytypecode`) is never changed — re-homing a chart to a different table is not supported. ### Publishing and solution `update` follows the same `--publish` / `--no-publish` / `--solution` contract as `create` (see above) — `--solution` is required, and it **stages** by default. For system charts the change is only visible in the UI after `PublishAllXml` runs; user charts (`--user-owned`) are never published and take effect immediately. ``` crm chart update --type Line --solution cwx_crmworx --publish crm chart update --name "New Name" --solution cwx_crmworx # staged crm solution publish-all # publish when ready ``` ## Replace the inner fetch query `chart set-fetch` replaces the `` element inside the datadescription while leaving the `` (grouping categories) intact: ``` crm chart set-fetch --fetch new_query.xml --solution cwx_crmworx # staged crm chart set-fetch --fetch new_query.xml --solution cwx_crmworx --user-owned crm chart set-fetch --fetch new_query.xml --solution cwx_crmworx --publish # publish immediately ``` Use this when you need to change the query (entity, filters, linked entities) without rebuilding the full datadescription. The `--fetch` file should contain only the `` element itself, not a full wrapped datadescription. The alias-coupling invariant is validated after the splice: the replacement `` must still carry `` elements whose aliases match the existing `` aliases. ## Add an aggregate series `chart add-series` adds one new aggregate series — a fetch attribute, a measurecollection entry, and a presentation `` — in one call: ``` crm chart add-series --column estimatedvalue --aggregate sum --alias total_value --solution cwx_crmworx crm chart add-series --column opportunityid --aggregate count --alias opp_count --solution cwx_crmworx ``` A chart is capped at **5 series**. Per-series edits are not supported on a **comparison chart** (one with 2 `` categories — it pairs two groupings against a single series); `add-series` / `remove-series` refuse it with a hint to use `chart update` instead. The `--alias` must be unique within the chart; the `--column` must be a logical name that exists on the chart's host entity (validated against live metadata). A series is modeled as one `` per series — the server couples the inner `` count to a category's measurecollection count, not to its individual `` count. Keep that 1:1 mapping in mind when inspecting the raw XML. ## Remove a series `chart remove-series` removes the series identified by its alias — the fetch attribute, its measurecollection entry, and the positionally-coupled presentation ``: ``` crm chart remove-series --alias total_value --solution cwx_crmworx crm chart remove-series --alias opp_count --solution cwx_crmworx --user-owned ``` Removing the **last** series is refused — a chart must have at least one — as is removing a series from a comparison chart (see `add-series` above). ## Change the grouping column `chart set-groupby` replaces the grouping (category) column in the fetch's `` element and in the datadescription's ``: ``` crm chart set-groupby --column createdon --dategrouping month --solution cwx_crmworx crm chart set-groupby --column ownerid --solution cwx_crmworx ``` `--dategrouping` is only meaningful for date/datetime columns. It is rejected for non-date columns. The `--column` is validated against live entity metadata. ## Publish gating — system vs user charts This applies to all editor verbs (`update`, `set-fetch`, `add-series`, `remove-series`, `set-groupby`): - **System charts** (`savedqueryvisualization`, the default) **stage** by default — no `PublishAllXml` runs. The change is only reflected in the UI after publish. Pass `--publish` per edit, or batch a run of staged edits and publish once with `crm solution publish-all`. - **User charts** (`userqueryvisualization`, `--user-owned`) are **never published** — edits reflect immediately and `--publish` / `--no-publish` is accepted but has no effect. Don't chain staged edits on a *system* chart A system-chart editor reads the **published** snapshot before writing, so a second staged (flagless) edit reads the chart without the first edit's pending change and overwrites it. To make several system-chart edits safely: either pass `--publish` on each one, or publish between edits with `crm solution publish-all`. User charts (`--user-owned`) are unaffected — they aren't published, so each edit reads the previous one's result. ## Relationship to `metadata clone-entity --with-charts` `crm chart` is the standalone surface for the chart logic that `crm metadata clone-entity --with-charts` uses when duplicating a whole entity. Use `chart get` + `chart create` to move or version a single chart without cloning the table itself. # How-to: completion Tab-completion for `crm` in bash, zsh, fish, or PowerShell. `crm completion` is a thin wrapper over Click's built-in completion — it makes it discoverable, caches the generated script to a file, and records a marker so [`self-update`](https://crm-cli-docs.pages.dev/how-to/self-update/index.md) keeps it current. See the [CLI reference](https://crm-cli-docs.pages.dev/reference/cli/index.md) for every flag. ## Install completion ``` crm completion install --shell zsh ``` Writes the completion script to `${CRM_HOME:-~/.crm}/completion/crm.zsh` and prints the single line to add to your shell startup file. It **never edits the file for you** — copy the printed line yourself. `--shell` defaults to autodetecting `$SHELL`; pass it explicitly if autodetection can't map `$SHELL` to bash/zsh/fish. PowerShell sets no `$SHELL`, so `--shell powershell` is **required** (it can't be autodetected). Re-running is idempotent: it rewrites the same script and marker, no duplication. Once installed, `--profile ` dynamically completes your saved connection profile names (a local read of `${CRM_HOME:-~/.crm}/profiles/`, never a network call) — like any global option, place it before the subcommand: `crm --profile entity get ...`. Positional **entity-set** arguments also complete — `crm entity get `, `crm query odata `, and the other entity-set slots across the `entity`, `query`, `data`, `security`, and `audit` groups. These read the **on-disk metadata cache** for the resolved profile (the `--profile` on the line, else the active profile) and, like `--profile`, never make a network call — a per-Tab round-trip would be unacceptable. The cache fills the first time `crm` resolves entity names against a profile; to populate it up front, run any command once with `--cache-metadata` (e.g. `crm --cache-metadata metadata entities`). A cold cache completes to nothing rather than going to the server. ### Per-shell setup After `crm completion install`, add the printed line to the matching startup file and restart your shell (or re-source it): - **zsh** — add to `~/.zshrc`: `source ~/.crm/completion/crm.zsh` - **bash** — add to `~/.bashrc`: `source ~/.crm/completion/crm.bash` - **fish** — add to `~/.config/fish/config.fish`: `source ~/.crm/completion/crm.fish` - **PowerShell** — add to your `$PROFILE` (Windows PowerShell 5.1 or PowerShell 7+): `. ~/.crm/completion/crm.ps1` (PowerShell dot-sources; install it with `crm completion install --shell powershell`) ## Print the script without installing ``` crm completion show --shell bash ``` Prints the completion source script to stdout and writes nothing — useful to pipe into a system-wide completion directory yourself, or to inspect the script. ## Install to a custom path ``` crm completion install --shell zsh --path ~/.zfunc/_crm ``` `--path` overrides the default `${CRM_HOME}/completion/crm.` location. The marker records this path so `self-update` refreshes the script there. ## Keeping completion current across upgrades If you installed completion through `crm completion install`, a later [`crm self-update`](https://crm-cli-docs.pages.dev/how-to/self-update/index.md) regenerates the cached script at the recorded path using the upgraded binary. A completion-refresh failure never fails the update — it's surfaced as a status line instead. If you set completion up manually (without `crm completion install`), `self-update` leaves it untouched. Why a cached file, not `eval` `install` always caches the script to a file and tells you to `source` it. Avoid the inline `eval "$(_CRM_COMPLETE=zsh_source crm)"` form in your rc — it spawns Python on every shell start, slowing down each new terminal. ## REPL tab-completion (built-in, nothing to install) Everything above is **OS-shell** completion — for typing `crm ...` at your bash/zsh/fish/PowerShell prompt. The interactive `crm repl` has its own, separate completer that needs no install step at all: Tab works the moment you launch it. It completes group/command names when the tokens typed so far are all subcommand names (a preceding flag, like `--profile foo`, stops command-name resolution there), flags for the resolved command (including `--no-*` secondary forms), values for `Choice`-typed flags, saved profile names after `--profile` (fires wherever the previous token is literally `--profile`, regardless of position), entity names at their existing slot (see [how-to: metadata](https://crm-cli-docs.pages.dev/how-to/metadata/#scope) for the on-disk cache backing that last one), and attribute logical names after `--select` once an entity is on the line — e.g. `entity get accounts RECORD_ID --select ` (the REPL holds a live connection, so it fetches an entity's columns once, then memoizes them for the session). It shares no code or cache with the OS-shell completion above and works even if you've never run `crm completion install`. # How-to: connection Diagnose the active connection — reachability, identity, and a layered probe of a broken link. See the [CLI reference](https://crm-cli-docs.pages.dev/reference/cli/index.md) for every flag. Profile setup moved to `crm profile` Creating, switching, and storing credentials for profiles now lives under `crm profile` — see [How-to: profile](https://crm-cli-docs.pages.dev/how-to/profile/index.md). The `connection` group is diagnostics only (`whoami`, `test`, `doctor`, `status`). ## Confirm reachability and identity ``` crm --json connection whoami ``` Returns the user identity GUIDs plus connection metadata so the result is self-identifying — useful for confirming which org is active without matching `OrganizationId` by hand: | field | meaning | | ---------------- | ----------------------------------------------------------------------- | | `UserId` | the calling user's GUID | | `BusinessUnitId` | the user's business unit GUID | | `OrganizationId` | the org GUID | | `profile` | the resolved profile name (reflects any `--profile` override) | | `url` | the resolved Web API base URL, e.g. `https://host/org/api/data/v9.1/` | | `org_name` | friendly org name from the `organizations` table (null on read failure) | The success envelope also carries `meta.profile` and `meta.url` (as for every backend-connected `--json` command — see "Connection identity" in `CONTEXT.md`). A non-zero exit (e.g. `401`) means the credentials are wrong — for NTLM the `DOMAIN\username` / password, for OAuth (online) the app-registration client id / secret / tenant, or a missing application user with a security role in Dynamics. There is no automatic retry. ## Reachability check with the API base ``` crm --json connection test ``` Runs a `WhoAmI` and reports the resolved API base — a quick smoke check that the active profile reaches the server. ## Inspect the active session and profile ``` crm --json connection status ``` Shows the `active_profile` with its `publisher_prefix`. It makes **no network call** — use it to confirm which target the next command will hit. Profiles no longer carry a `default_solution` — every customization-write command requires an explicit `--solution` of its own (#636). ## Diagnose a broken connection ``` crm connection doctor # or the alias: crm doctor crm --json connection doctor ``` Runs a live, ordered probe and renders a five-line checklist — `dns_tcp`, `tls`, `version` (the configured `api_version`), `auth`, and an informational `rate_limit` — so a failing layer is pinpointed (DNS vs TCP vs TLS vs wrong api_version vs `401`/`403`) with an actionable hint rather than collapsed into one generic error. It is read-only: it never negotiates or mutates the profile, and the raw GETs run regardless of `--dry-run`. Under `--json` it emits `{ok, data:{checks:[{check,ok,detail,hint}]}}`; the overall `ok` (and exit code) is the AND of the four diagnostic checks — `rate_limit` is informational and never fails the command. # How-to: connectionrole Manage **connection roles** headlessly: create a role, restrict it to one or more entity types, and pair two roles as reciprocal matching partners. See the [CLI reference](https://crm-cli-docs.pages.dev/reference/cli/index.md) for every flag. Connection roles (`connectionrole`) describe how two records are related via a connection. A role can be **unrestricted** (usable with any entity) or **scoped** to specific entity types (one `connectionroleobjecttypecode` per entity, created with `scope`). Two roles are paired as reciprocal partners by linking them through the self-referential `connectionroleassociation_association` N:N relationship — that is what `match` does. ## The workflow 1. `connectionrole create` — create one or both roles. 1. `connectionrole scope` — (optional) restrict a role to one entity type; call repeatedly to add more entity types. 1. `connectionrole match` — pair the two roles as reciprocal/matching partners. ## Create a role ``` crm --json connectionrole create --name "Stakeholder" --category stakeholder ``` `--name` is required. `--category` maps a friendly name to the `connectionrole_category` global choice set. `--description` adds a plain-text description. `--solution` sets `MSCRM.SolutionUniqueName` to land the role in an unmanaged solution. `--dry-run` previews the POST without issuing it. Returns: ``` { "ok": true, "data": { "created": true, "connectionroleid": "", "name": "Stakeholder" } } ``` ## Scope a role to an entity type ``` crm --json connectionrole scope "Stakeholder" --entity account ``` `ROLE` is a role **name or id**. `--entity` is the logical name of the entity type to restrict the role to. Each call creates one `connectionroleobjecttypecode` record. Call `scope` multiple times to allow the role on several entity types. `--solution` is supported; `--dry-run` previews the POST. Returns: ``` { "ok": true, "data": { "created": true, "connectionroleobjecttypecodeid": "" } } ``` ## Match two roles as reciprocal partners ``` crm --json connectionrole match "Stakeholder" "Vendor" ``` `ROLE_A` and `ROLE_B` are names or ids. `match` creates the link through the `connectionroleassociation_association` N:N relationship, making the two roles reciprocal — when one role is used on a connection, the other appears as its counterpart. > **No `--solution` for `match`.** The `connectionroleassociation_association` intersect table is not a solution component, so there is no `MSCRM.SolutionUniqueName` header to set. This is the same precedent as `fieldsec assign`, which associates via a non-solution-component N:N. `--dry-run` previews the associate call without issuing it. Returns: ``` { "ok": true, "data": { "matched": true, "role_a": "", "role_b": "" } } ``` ## `--dry-run` and `--json` conventions All write verbs (`create`, `scope`, `match`) honor `--dry-run` (previews the would-be request, `meta.dry_run: true`; reads still run for real) and `--json` (stable `{ ok, data, meta }` envelope). Pass `--json` from agent contexts. # How-to: dashboard Author organization-owned **system dashboards** headlessly — list, get, create, delete, and splice tiles into `systemform` records with `type = 0` without opening the dashboard designer. See the [CLI reference](https://crm-cli-docs.pages.dev/reference/cli/index.md) for every flag. A dashboard's layout lives in its `formxml` column. The CLI does **not** generate that XML — it posts the file you give it verbatim — so authoring a dashboard from source control means committing its FormXml and recreating it with `dashboard create`. `systemform` also backs every other form type (main, quick-create, card, …); every `dashboard` verb scopes its reads to `type eq 0`, so the group only ever sees dashboards. ## List dashboards ``` crm dashboard list ``` Output columns: `name`, `formid`, and `isdefault`. `list` returns only these list-oriented fields — to read a dashboard's `formxml`, use `dashboard get `. ## Get a single dashboard ``` crm dashboard get 1111aaaa-2222-bbbb-3333-cccccccccccc ``` `get` returns the dashboard's FormXml in the `--json` envelope — capture it into source control to version a dashboard: ``` crm --json dashboard get | jq -r '.data.formxml' > dashboard.xml ``` ## Create a dashboard ``` crm dashboard create --name "Sales Overview" --formxml dashboard.xml --solution cwx_crmworx ``` `--solution` is required — a component created without an explicit target solution would otherwise land only in the system Default Solution. Pass `--solution Default` for a deliberate Default-Solution-only write. `--formxml` takes the path to a dashboard FormXml file. The created record is an organization-owned dashboard (`objecttypecode` `none`), not bound to a single table. The server validates the FormXml, so a malformed layout is rejected with a `400`. A round-tripped FormXml from `dashboard get` is the most reliable starting point. ### Interactive dashboards are not creatable Interactive-experience (type-10) dashboards cannot be created over the Web API. Passing `--interactive` fails fast with a clear error instead of silently creating a standard dashboard: ``` crm dashboard create --name "X" --formxml d.xml --interactive --solution cwx_crmworx # error: Interactive-experience (type-10) dashboards are not programmatically # creatable over the Web API — author them in the dashboard designer. ``` ### Publishing `dashboard create` **stages** the change by default — no `PublishAllXml` runs (the CLI-wide convention shared with `chart create`, `form clone`, etc.). Pass `--publish` to make a new dashboard visible immediately, or batch several operations and publish once at the end: ``` crm dashboard create --name "Q" --formxml d.xml --solution cwx_crmworx --publish # ...more staged operations... crm solution publish-all # publish everything once the batch is done ``` ### Preview without writing ``` crm --dry-run dashboard create --name "Q" --formxml d.xml --solution cwx_crmworx ``` Returns `{_dry_run: true, would_create: {entity_set, body}}` with the fully resolved request body; no dashboard is created. `--solution` is still required under `--dry-run` — it is validated before any backend call. ## Delete a dashboard ``` crm dashboard delete 1111aaaa-2222-bbbb-3333-cccccccccccc --yes ``` `delete` is destructive: omitting `--yes` prompts on a TTY, and under `--json` or a non-TTY it fails fast with an error that names `--yes`. Under `--dry-run`, delete returns `{_dry_run: true, would_delete: true, formid: }` without issuing the `DELETE`. To remove a dashboard from a solution (rather than delete it), use `crm solution remove-component`. ## Add a chart tile — `dashboard add-chart` Splices a ChartGrid tile (a chart rendered above its grid) into an existing dashboard's FormXml without touching the dashboard designer: ``` crm dashboard add-chart 1111aaaa-2222-bbbb-3333-cccccccccccc \ --view \ --chart \ --solution cwx_crmworx ``` `--view` is the `savedqueryid` of the public view whose data the grid shows. `--chart` is the `savedqueryvisualizationid` of an org-owned chart; its primary entity must match the view's entity — the CLI validates both references live and rejects a mismatch before writing. `--solution` is required, as on every mutating dashboard verb (`add-*`, `remove-component`, `create`) — pass `--solution Default` for a deliberate Default-Solution-only write. ### Tile placement By default each tile lands in its own **new section** so that the `rowspan == count()` layout invariant holds per component. To place a tile in an existing section, pass `--section ` — the section must be **empty** (have no component yet), since a section holds at most one component while keeping the invariant; targeting an occupied section is refused: ``` crm dashboard add-chart --view --chart \ --tab "Sales" --section "Pipeline" --solution cwx_crmworx ``` `--rowspan` and `--colspan` control the cell size; the section is padded to match `--rowspan`. ### Six-component cap Dashboards have a default six-component cap. `add-chart` refuses to exceed it unless you pass `--force`: ``` crm dashboard add-chart --view --chart --force --solution cwx_crmworx ``` ### Publishing `add-chart` **stages** by default (no `PublishAllXml`). Batch several tile-add calls, then publish once: ``` crm dashboard add-chart --view --chart --solution cwx_crmworx crm dashboard add-view --view --solution cwx_crmworx crm solution publish-all ``` > **Gotcha — `dashboard get` returns the published layer.** A staged tile-add will not show up in a `dashboard get` read-back. Pass `--publish` on the write, or run `crm solution publish-all` afterward, before verifying the edit. ## Add a view-only grid tile — `dashboard add-view` Splices a view-only grid tile (no chart) into an existing dashboard: ``` crm dashboard add-view 1111aaaa-2222-bbbb-3333-cccccccccccc \ --view --solution cwx_crmworx ``` `--mode list` (the default) renders the grid alone. `--mode all` renders the grid with the chart-toggle control so the user can switch to a chart in the UI without a fixed chart selection: ``` crm dashboard add-view --view --mode all --solution cwx_crmworx ``` `--records-per-page` sets the row count per page in the grid (default 10). All placement, cap, and publish options work identically to `add-chart`. ## Add an IFRAME tile — `dashboard add-iframe` Splices an IFRAME tile into an existing dashboard's FormXml: ``` crm dashboard add-iframe 1111aaaa-2222-bbbb-3333-cccccccccccc \ --url https://example.com/embed --solution cwx_crmworx ``` `--url` is **required and must be non-empty.** An IFRAME tile with an empty URL renders silently blank in the UI — the CLI refuses it before writing. The `--security`, `--scrolling`, `--border`, and `--pass-parameters` flags map directly to the FormXml typed-boolean parameters that control cross-frame scripting restriction, scrollbar visibility, border rendering, and whether the record's object-type code and id are appended as URL query parameters respectively. All placement, cap, and publish options work identically to `add-chart`. ### Preview without writing ``` crm --dry-run dashboard add-iframe --url https://example.com/embed --solution cwx_crmworx ``` Returns `{_dry_run: true, would_add: true, url: "..."}` without patching the dashboard. ## Add a web-resource tile — `dashboard add-webresource` Splices a web-resource tile into an existing dashboard's FormXml: ``` crm dashboard add-webresource 1111aaaa-2222-bbbb-3333-cccccccccccc \ --webresource cwx_/pages/summary.html --solution cwx_crmworx ``` `--webresource` accepts either a GUID or the web resource's unique name. The CLI validates the web resource exists before writing; it emits a warning (in `meta.warnings`) when the resource is not form-enabled — CSS, scripts, data XML, XSL, and RESX types do not render as dashboard tiles (only HTML, images, and Silverlight do). The write still proceeds; the warning is advisory. The tile's `` is set to `$webresource:` — the platform directive that resolves the resource's hosted URL on the server side. All placement, cap, and publish options work identically to `add-chart`. ### Preview without writing ``` crm --dry-run dashboard add-webresource --webresource cwx_/pages/summary.html \ --solution cwx_crmworx ``` Returns `{_dry_run: true, would_add: true, webresource: "..."}` (the web resource is still resolved live to validate it exists; no PATCH is issued). ## Remove a tile — `dashboard remove-component` Removes exactly one tile from an existing dashboard's FormXml, selected by **exactly one** of five selectors: ``` crm dashboard remove-component --index 0 --solution cwx_crmworx # first tile (0-based) crm dashboard remove-component --cell-id --solution cwx_crmworx # by cell id in FormXml crm dashboard remove-component --view --solution cwx_crmworx crm dashboard remove-component --chart --solution cwx_crmworx crm dashboard remove-component --url https://example.com/embed --solution cwx_crmworx ``` Passing more than one selector, or none, is a usage error. Passing a value selector (`--view`, `--chart`, `--url`) that matches no component or more than one component is also refused — for an ambiguous multi-match, switch to `--cell-id` or `--index` to target exactly one tile. `--index` is 0-based among all component cells in document order. Use `crm --json dashboard get | jq -r '.data.formxml'` to inspect the FormXml and find the right index or cell id before removing. After removal the CLI reconciles the section's empty `` padding so the `rowspan == count()` layout invariant is maintained. `remove-component` does **not** accept the tile layout options (`--tab`, `--section`, `--rowspan`, `--colspan`, `--force`) — those are add-only. ### Preview without writing ``` crm --dry-run dashboard remove-component --index 0 --solution cwx_crmworx ``` Returns `{_dry_run: true, would_remove: true, cell_id: "...", control_id: "..."}` without patching the dashboard. # How-to: data Bulk dataset import and export recipes, taken from the CRMWorx build (§4). See the [CLI reference](https://crm-cli-docs.pages.dev/reference/cli/index.md) for every flag. ## Import All imports are routed through the Dataverse `$batch` endpoint — the only bulk-write mechanism available on-prem (`CreateMultiple`/`UpsertMultiple` are cloud-only). ### Create records from a JSONL file ``` crm data import accounts records.jsonl ``` Format is inferred from the file suffix (`.jsonl` or anything non-`.csv` → JSONL). Each line must be a JSON object; blank lines are skipped. Values are kept verbatim — numbers stay numbers, booleans stay booleans, and a hand-written lookup bind (`"