Stateful CRM CLI for Microsoft Dynamics 365 Customer Engagement — on-premises v9.x (NTLM) or Dataverse online (OAuth). Wraps the real Dataverse Web API (OData v4) over HTTPS; the same commands run against both targets.
Customizations as code — your org's schema and UI live in a git repo as a declarative spec; every change ships as a reviewed plan artifact, and
apply --from-planruns exactly what was approved. On-prem and online, same loop. See the guide: docs/guides/customizations-as-code.md
D365 CE on-prem ships with a GUI and a SOAP/.NET SDK. There's no first-party CLI optimized for AI agents or shell scripting. This harness gives you:
crmin yourPATH- One-shot subcommands for record CRUD, OData and FetchXML queries, metadata introspection, solution lifecycle, and plug-in assembly + step registration
- A stateful REPL for ad-hoc admin work
--jsoneverywhere for agent consumption--fields KEY[,KEY...]to project any response down to a few columns client-side, so a fat list verb (e.g.--fields uniquename,version solution list) fits an agent's context budget — post-curation, envelope-preserving, works in human mode too (selects table columns)--jq PROGRAMto run a jq program over any response client-side, so--jq length solution listreturns a single count andgroup_by/mapexpressions produce census-style summaries instead of the full payload — same emit seam as--fields(mutually exclusive with it), implies--json, envelope-preserving--dry-runto preview writes without issuing them (reads still run for real); on writes that name another object — a lookup's target entity, a picklist's option set, a plug-in step's message/type/entity — the preview also reports whether each reference exists (data.references[], dangling ones flagged inmeta.warnings)- These five flags —
--json,--fields,--jq,--profile,--dry-run— are dual-position: valid before or after the subcommand, socrm entity get accounts --jsonis byte-identical tocrm --json entity get accounts(position is pure syntax). Supplying one in both positions is a usage error. The other global flags stay root-only (before the command) - An append-only JSONL audit journal (
~/.crm/audit/<session>.jsonl) of every mutating command;crm session auditto review it --cache-metadatafor a persistent per-profile entity-definition cache (speeds up repeated one-shot agent calls; env:CRM_CACHE_METADATA=1)crm ribbonto read and edit entity command-bar buttons (export / list / add-button / set-label / set-icon / remove / hide-button / set-rules / add-custom-rule / apply) without manual solution-XML editing;add-buttonandset-iconcan set a button's icon (--modern-imageSVG for the Unified Interface,--image16/--image32classic rasters);ribbon export --applicationexports the application-wide ribbon; a working-copy flow (ribbon export --solution→--diff-fileedits →ribbon apply) composes several ribbon edits offline into a single publishcrm solution layer-conflictsto detect components shared by a managed and an unmanaged solution (unmanaged-layer conflicts) — works on-prem too, where XrmToolBox's layer explorer can't
Full docs (install, configure, per-group how-tos, generated CLI reference) live at crm-cli-docs.pages.dev.
Two surfaces feed AI agents:
crm/skills/— the agent skill loaded by skill-aware harnesses (Claude Code, Copilot CLI, …) aftercrm skill install: a thinSKILL.mdrouter plusreference/*.mdfiles loaded on demand.llms.txt/llms-full.txt— published at the docs-site root for any web-fetch agent:/llms.txtis a curated index,/llms-full.txtis every page in one fetch.
| Requirement | Version | Notes |
|---|---|---|
| Python | ≥ 3.13 | |
| Dynamics 365 CE on-prem | 9.0 / 9.1 / 9.2 | Reachable from your machine over HTTPS |
| Auth | NTLM (on-prem) · OAuth (online) | NTLM = Windows Integrated; OAuth = client-credentials for Dataverse cloud. |
The D365 server is a hard runtime dependency — without it the CLI has nothing to talk to. E2E tests fail loudly if credentials are missing.
The prebuilt crm binary bundles CPython and all dependencies. Install it with
a one-liner — no GitHub account or Python needed.
Windows (PowerShell):
irm https://pub-bbeb86c46454443ca76521dd4d29818e.r2.dev/install.ps1 | iexInstalls to %LOCALAPPDATA%\Programs\crm and adds it to your user PATH. Open a
new shell, then run crm --version. The binary is unsigned, so Windows
SmartScreen may warn on first run. On managed machines this binary may be
blocked outright by endpoint security (e.g. Microsoft Defender ASR or
AppLocker) — use Option 2: uv tool install
below in that case. To uninstall, download install.ps1 and run
.\install.ps1 -Uninstall.
Linux:
curl -fsSL https://pub-bbeb86c46454443ca76521dd4d29818e.r2.dev/install.sh | shInstalls to ~/.local/share/crm and links ~/.local/bin/crm. Ensure
~/.local/bin is on your PATH. Built on Ubuntu 22.04, so it runs on any Linux
with glibc ≥ 2.35. To uninstall, download install.sh and run
sh install.sh --uninstall.
Pin a version by setting CRM_VERSION (e.g. v0.6.0) before running.
Both scripts verify the archive's SHA-256 against the published SHA256SUMS
before extracting and abort on a mismatch or if it can't be fetched. To pin a
hash from a trusted channel (or to install a release published before checksums
existed), set CRM_SHA256 ($env:CRM_SHA256 on Windows). See
the install guide.
Installs crm into an isolated environment that runs through your trusted
python interpreter instead of a standalone binary. Use this when Option 1's
prebuilt binary is blocked by endpoint security (Microsoft Defender ASR,
AppLocker, etc.) — there is no new unsigned executable for those policies to
flag.
First install uv if you don't already have it:
Windows (PowerShell):
winget install --id=astral-sh.uv -eLinux / macOS:
curl -LsSf https://astral.sh/uv/install.sh | shThen install crm from the repository (it is not published to PyPI, so install
from the Git source or a wheel):
uv tool install git+https://github.com/Gharib89/crm
crm --versionuv tool install places a small launcher on your PATH; run uv tool update-shell once if crm isn't found in a new shell. If even that launcher is
blocked, run the CLI as a module instead — no executable is created at all:
uv run --from git+https://github.com/Gharib89/crm crm --version# From source (local dev)
pip install -e .
# Verify the command is on PATH
which crm
crm --versionOn an interactive terminal, crm checks at most once a day whether a newer
release exists and prints a one-line notice (at most once a day) after a command
finishes. The check
runs in the background against the release server, never blocks the command, and
is silent under --json, when stderr is not a terminal, when CI is set, or when
CRM_NO_UPDATE_CHECK is set.
For binaries installed via the install script (Option 1), upgrade in place:
crm self-update # download, checksum-verify, and swap the binary
crm self-update --check # report current vs latest version, change nothingpip/uv/source installs aren't modified by self-update — it points you at
pip install -U crm (or re-running uv tool install) instead. Either way,
self-update also re-syncs any agent skills you installed with crm skill install, and any shell completion you installed with crm completion install
(below), so both stay current with the CLI.
Enable tab-completion for crm in bash, zsh, fish, or PowerShell:
crm completion install # caches the script under ~/.crm and prints one rc line
crm completion show --shell zsh # or just print the script to stdoutinstall writes the completion script under ${CRM_HOME:-~/.crm}/completion/ and
prints the single line to add to your shell startup file — source <path> for
bash/zsh/fish, . <path> for PowerShell — and never edits the file for you.
--shell defaults to autodetecting $SHELL; PowerShell sets no $SHELL, so pass
--shell powershell explicitly. Once installed, --profile and positional
entity-set arguments (crm entity get <TAB>) complete dynamically from local state
and the on-disk metadata cache — never a network call. See the
completion how-to for per-shell setup.
Credentials live in a saved profile — there is no .env file and no credential
environment variables. Create one with crm profile add:
crm profile addOn a terminal this runs an interactive wizard (URL → inferred auth scheme →
identity → secret), then runs a WhoAmI against the server before saving
anything. On success it saves the profile, stores the secret, and activates it —
no extra prompts. If that live test fails, a structurally malformed profile (bad
URL, missing tenant/client id, no secret) is rejected outright with nothing saved;
a plausible one (likely a transient outage — VPN down, app user not yet
provisioned) prompts "Save the profile anyway?" on a TTY, or errors without saving
non-interactively unless you pass --save-on-test-failure. The first connection
command run with no profile configured launches this wizard automatically; under
--json / no TTY it errors cleanly and tells you to run crm profile add.
For scripting, pass flags instead. The auth scheme is inferred from the URL
(*.dynamics.* → OAuth, anything else → NTLM); override with --auth-scheme.
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 → HTTP 501); online uses v9.2.
Online / Dataverse cloud (OAuth 2.0 client-credentials):
crm profile add \
--url https://contoso.crm.dynamics.com \
--tenant-id <aad-tenant-id> --client-id <app-registration-id> \
--password "$CLIENT_SECRET" \
--name onlineThe app registration needs an application user in Dynamics with a suitable
security role. The token scope (https://<host>/.default) and authority
(https://login.microsoftonline.com/<tenant>) are derived automatically; the
bearer token is cached at ~/.crm/msal_token_cache.json (mode 0600) and reused
across invocations 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 crmworxMark a profile read-only to block accidental writes — the backend refuses every org mutation (non-GET, minus solution/translation export actions) with a loud operational failure (exit 1), while reads run normally:
crm profile add --url ... --name prod-ro --read-only # or: crm profile edit prod-ro --read-onlySetting it is unrestricted (tighten from anywhere, including CI); clearing it
(crm profile edit prod-ro --no-read-only) needs an interactive terminal to
confirm, so an agent with no TTY can't flip it off. It's a guardrail, not a
security boundary — the same OS user can hand-edit the profile file; real
enforcement is a server-side read-only security role. See
ADR 0021.
BREAKING (#636): every customization-write command (metadata create-* /
update-*, plugin create/register-*/update-step-*,
webresource, form, view, chart, dashboard, sitemap, app, sla,
report, connectionrole, dup, fieldsec, security create-role,
scaffold, ribbon, workflow clone) now requires an explicit
--solution <unique_name> — there is no profile default and no opt-out
(pass --solution Default for a deliberate Default-Solution-only write).
Hard metadata delete-* verbs are exempt: a hard delete removes the component
globally, so the header can't orphan it — --solution stays optional there.
Removed: profile default_solution (and --default-solution on
profile add/edit), --require-solution, CRM_REQUIRE_SOLUTION,
solution create --set-default, and the apply --solution override flag —
crm apply now requires a top-level solution: block in the spec itself
(metadata export-spec --solution <name> bakes it in). See
ADR 0020.
State lives under ~/.crm/ — CRM_HOME is the only env var involved in
credential/connection resolution (it relocates that directory). No credentials
are ever read from the environment. (Other CRM_* vars tune unrelated runtime
behavior — logging, retries, stage-only — but never supply connection config.)
crm profile add stores the secret automatically:
- By default it goes into your OS keyring (macOS Keychain / Windows Credential Manager / Linux SecretService). Keyring support is built in — no extra install.
- On hosts with no keyring backend (typical WSL / headless CI) it falls back
automatically to a
0600plaintext entry inside the profile file. Force plaintext anywhere with--store-password-plaintext(0600on POSIX; perms unenforced on Windows). crm profile set-password --profile NAMEstores or replaces the secret for an existing profile (NTLM password or OAuth client secret alike); never contacts the server.crm profile delete-password --profile NAMEremoves it.crm profile listshows each profile's storage type (keyring / plaintext / none).
Secret resolution order: --password (per-run override) > stored secret (plaintext
entry, then keyring) > interactive prompt (TTY only). No environment variable is
consulted.
$ crm
◆ d365 [prod] ❯ connection whoami
◆ d365 [prod] ❯ query odata contacts --top 5 --select fullname,emailaddress1
◆ d365 [prod] ❯ help
◆ d365 [prod] ❯ quitBare crm only opens the REPL on an interactive terminal. A non-interactive
caller — --json, CRM_NO_REPL=1, or a piped/redirected stdin (agents, CI) —
exits 2 with a usage message pointing at crm --help instead of hanging on a
prompt. Explicit crm repl always launches.
Inside the REPL, commands are typed without the crm prefix
(connection whoami, not crm connection whoami), but a single leading crm
is tolerated and ignored so a copy-paste from the docs still works.
After a few commands (profile add, profile use, solution export,
query odata) the CLI prints a dim next-step hint suggesting a natural
follow-up. Each hint shows only once (per CRM_HOME) and only in human/REPL
output on a terminal — never under --json, in a pipe, or for agents. Set
CRM_NO_HINTS to any value to turn them off entirely.
# Preflight diagnostic — pinpoints which layer is broken (DNS/TLS/version/auth)
crm connection doctor # or: crm doctor
crm --json connection doctor # JSON: {ok, data:{checks:[{check,ok,detail,hint}]}}
# Identity check
crm --json connection whoami
# Read
crm query odata contacts \
--filter "statecode eq 0" --select fullname,telephone1 --top 10
# Write (preview first)
crm --dry-run entity create contacts \
--data '{"firstname":"Rafel","lastname":"Shillo"}'
# Then actually create
crm --json entity create contacts \
--data '{"firstname":"Rafel","lastname":"Shillo"}'
# Catch typo'd field names before the write (1-3 metadata GETs; composes with --dry-run)
crm --json entity create contacts --validate \
--data '{"firstnaem":"Rafel"}' # -> ok:false, did_you_mean {firstnaem: firstname}
# Verify after write/publish — repeatable --expect ATTR=VALUE asserts a field on the
# returned record (AND-gated, stringified); first mismatch exits 1 with meta{attr,expected,actual}.
crm --json entity get contacts <guid> --expect statecode=1 # assert a write landed
crm --json metadata attribute account industrycode --expect AttributeType=Picklist # assert a publish landed
# FetchXML
crm query fetchxml accounts --file ./reports/by_industry.xml
# Solution export — name it explicitly, or omit the name on a TTY to pick interactively
crm solution export MyCustomSolution -o /tmp/snap.zip
# Org-to-org drift recipe: project a whole solution into one apply-consumable spec on dev,
# then preview schema drift on prod without writing anything.
crm solution export-spec MyCustomSolution -o desired.yaml # dev (source)
crm --dry-run apply -f desired.yaml # prod (preview drift)
# Solution source control: unpack a zip to a diff-able tree and pack it back
# (offline — no connection needed; needs the Power Platform CLI `pac` on PATH)
crm solution extract --zipfile /tmp/snap.zip --folder ./src/MyCustomSolution
crm solution pack --zipfile /tmp/built.zip --folder ./src/MyCustomSolution
# Validate a solution zip before import (offline by default; --against-org adds
# GUID-collision, existence, and package-version-ceiling checks against the connected
# org). Exits non-zero on any error.
crm solution validate /tmp/snap.zip
# Bulk export
crm data export opportunities -o /tmp/op.csv \
--filter "statecode eq 0" --select name,estimatedvalue
# Bulk import (JSONL create; use --mode upsert --id-column <col> for PATCH by GUID,
# or --mode upsert --key <attr> to PATCH by alternate key when no GUID is available;
# --mode delete removes records by the same GUID/alternate-key resolution).
# READ-shape lookups (_<attr>_value, as export emits) auto-rebind to
# <nav>@odata.bind from metadata, so an export round-trips with no hand-editing.
crm data import accounts records.jsonl
crm data import accounts updates.jsonl --mode upsert --key accountnumber
crm data import accounts to_delete.jsonl --mode delete --key accountnumber --yes
crm --dry-run data import accounts records.jsonl # preview: zero writes, dry_run:true
# Server-side BulkDelete job — deletes all records matching a FetchXML query
# (FetchXML is converted server-side to QueryExpression; raw OData $filter is not accepted)
crm --json data delete contacts \
--fetchxml '<fetch><entity name="contact"><filter><condition attribute="statecode" operator="eq" value="1"/></filter></entity></fetch>' \
--wait --yes
crm --dry-run data delete contacts --fetchxml-file ./stale-contacts.xml # preview matched count
# Discover the CLI surface (no connection needed) — for agents and scripts
crm --json describe
# Browse a curated gallery of runnable examples (no connection needed)
crm examples # TTY: pick a group, then an example — prints it to copy/edit
crm examples solution # jump straight to one group
crm --json examples # plain listing for scripts/agentsEvery command supports --json:
{
"ok": true,
"data": {...},
"meta": {"count": 12, "entity_set": "contacts"}
}data is a curated, CLI-owned shape, not a passthrough of the raw Web API
response (ADR 0008): list verbs put a bare array in data (data[0] is the
first row) with paging relocated to meta.next_link/meta.count; @odata.*
protocol keys are stripped everywhere; and the written record's GUID is surfaced
under the single normalized key _entity_id (with _entity_id_url) on
create/update/delete/clone/entity get — so one extraction rule works
across commands.
query odata follows @odata.nextLink automatically when you opt in: --all
merges every page into one data array (no meta.next_link in the result); --max-records N follows pages only until N rows are collected and caps the array there. When --max-records truncated the result (more rows existed), meta.truncated: true appears in the envelope. Default (neither flag) is unchanged: one server page, meta.next_link present when more pages exist — that default single page also sets meta.has_more: true and adds a meta.warnings advisory to page via meta.next_link (or --all/--max-records on query odata); a --count that lands exactly on the server's 5000-row cap alongside a cursor gets a second warning that the count is a clamped lower bound. These cursor-driven signals also appear on query saved/query user; query fetchxml pages via a FetchXML cookie (no cursor), so it never surfaces them, and --all/--max-records/--count are query odata-only. A read that fits in one page gets neither signal.
CLI-handled --json failures come back as valid envelopes with ok:false and
error. Backend / Web API failures add meta:
{"status": 404, "code": "...", "category": "not_found", "retryable": false}.
Usage errors may omit meta; non-usage Click-layer failures include an empty
meta object. Parse error first and treat meta as optional.
meta.category is a closed enum (not_found, auth_failed, forbidden,
concurrency_conflict, duplicate_detected, validation, throttled,
server_error, transport_error) and meta.retryable flags the transient classes.
The backend auto-retries the transient transport / 429 / 5xx classes for
idempotent verbs (concurrency_conflict (412) is flagged retryable too but is
never auto-retried — refetch a fresh ETag and retry), but a non-idempotent POST
(record create, action, associate) is not retried by default — a lost response
may mean the write already landed. Pass
--retry-on-ambiguous (env: CRM_RETRY_ON_AMBIGUOUS) to opt back into retrying
POSTs when re-sending is acceptable. $batch keeps its own retry loop regardless.
The one POST exception is the CustomizationLockException family (the org-wide
customization lock held by a concurrent publish/customization) — because the lock
fails the call before the operation starts, re-sending is always safe, so it is
auto-retried on any verb regardless of --retry-on-ambiguous.
meta.profile and meta.url appear on every success envelope from a command
that opened a connection — the resolved profile name and Web API base URL, so a
result is self-identifying without GUID-matching. They are absent on error
envelopes and on local verbs that never connect (connection status, session,
profile list, etc.).
meta.warnings is an array of non-fatal advisories — the one place to scan for
staged-but-unpublished changes, created-but-read-back-failed records, and
partial-optionset failures (which also surface meta.completed_steps /
meta.failed_stage on the error envelope).
| Group | Purpose |
|---|---|
connection |
Profiles, WhoAmI, preflight diagnostic (doctor), reachability checks |
org |
Org-level orientation: org brief is a one-call, read-only inventory (counts + key names, never full rows) — identity, solutions + candidate --solution targets, publishers + prefixes, custom entities, apps, and automation/component counts — so an agent can orient without sweeping the fat list verbs |
connectionrole |
Connection roles: create a role, scope it to an entity type, match two roles as reciprocal partners |
entity |
Record CRUD (get/create/update/upsert/delete); create/update/upsert accept read-shape lookup payloads from get/export/query and rebind them to @odata.bind; upsert accepts --key ATTR[,ATTR...] to match by alternate key instead of a primary GUID (key values read from --data, key attrs stripped from the body), plus --if-none-match for a create-only upsert (412 if the record exists); clone (single-record clone, lookups rebound to the same parents, --override/--unset; --with-children also clones the custom 1:N child rows, repointing them to the new parent — continue-and-report on failure, --skip-child-entity prunes); children (per-1:N related-record counts via chunked $batch, not one query per relationship) |
query |
OData v4 and FetchXML queries; query odata --apply runs server-side $apply aggregation / group-by / distinct |
metadata |
Entity / attribute / relationship CRUD; global option set CRUD |
apply |
Declarative desired-state from a YAML/JSON spec (apply -f spec.yaml); declares publishers, solutions, entities (with columns, views, and forms — tabs / sections / field placement / JS libraries + handlers, converged onto the entity's main form), option sets, web resources, security roles, plug-in assemblies / types / steps / images, and model-driven apps (top-level apps: — components + sitemap as areas / groups / subareas); --prune opts in to solution-bounded deletion of org components the spec no longer declares (gated; preview with --dry-run) |
scaffold |
Quick one-table shorthand: scaffold table DISPLAY --column ... creates an entity + N columns in one publish |
solution |
List / info / components (--save/--diff for drift detection) / dependencies (uninstall-blocker preview) / missing-components (pre-import dependency check against the target org) / add-component / remove-component / set-version / export / import / import-result / extract / pack / validate solutions; export-spec <unique_name> [-o FILE] projects a whole solution (entities and their seedable main forms, security roles, web resources, and model-driven apps — identity + Entity-backed sitemap) into one apply-consumable desired-state spec (org-to-org drift recipe source); managed-upgrade lifecycle: clone-as-patch / stage-and-upgrade (holding import, --promote) / apply-upgrade (separate promote) / uninstall |
data |
Bulk CSV/JSON dataset export + JSONL/CSV import via $batch; --mode upsert/--mode delete resolve records by GUID (--id-column) or alternate key (--key); data delete submits a server-side BulkDelete async job for records matching a FetchXML query |
webresource |
Create/update/get/list/delete web resources (HTML/JS/CSS/images); stages by default, --publish to publish immediately; push <DIR> --prefix <p> bulk-upserts a directory tree (name = <prefix>_<relpath>, type inferred from extension, skips byte-identical, --publish publishes once at the end); set as app icons |
view |
System views (savedquery): list / create; edit-columns to add, remove, resize, or reorder grid columns in place (keeps layoutxml + fetchxml coupled); set-order to replace, append, or clear the sort order; add-filter / remove-filter to edit FetchXML filter conditions in place — all without manual XML editing |
form |
Entity main forms (systemform): list, clone to another table, export formxml; add-field / remove-field / set-field / set-field-props to edit form layouts; add / remove / rename / move tab & section to edit the form's tab/section structure; add-library / add-handler / remove-handler / list-handlers to wire and inspect JS event handlers — all without manual FormXml editing |
chart |
System & user charts (savedqueryvisualization / userqueryvisualization): list / get / create / delete; author from datadescription + presentationdescription XML or a web-resource visualization; edit in-place with update (replace XML columns, name, description, or series chart type), set-fetch (swap the inner <fetch> while keeping categories), add-series / remove-series (add or remove an aggregate series), and set-groupby (change the grouping column) — all headlessly (no chart designer) |
sitemap |
Live SiteMap navigation editors — edit an existing sitemap in place over a read-modify-write path (GET → mutate → PATCH) without re-authoring the whole document: add-area, add-group, add-subarea (exactly one of --entity / --url / --dashboard, with --entity and --dashboard validated to exist and --pass-params to append context params to a --url), move-node (reorder a node within its parent via --before / --after / --index), remove-node (--comment-out to soft-delete), set-title / set-description (set localized <Titles>/<Descriptions> per LCID on any node). Complements app build-sitemap / app set-sitemap which POST a whole new SiteMapXml. Find a sitemap's GUID with crm query odata sitemaps --select sitemapname,sitemapid |
dashboard |
Organization-owned system dashboards (systemform type=0): list / get / create / delete; add-chart splices a ChartGrid tile (chart + grid) into an existing dashboard's FormXml; add-view splices a view-only grid tile; add-iframe embeds an IFRAME tile; add-webresource embeds a web-resource tile (validates existence, warns if not form-enabled); remove-component removes exactly one tile selected by --cell-id / --index / --view / --chart / --url. Create from a FormXml file, headlessly (no dashboard designer). Interactive (type-10) dashboards are not API-creatable and are rejected with a clear error |
theme |
Application themes (product branding): list / get / create / update / publish; set colors via --set FIELD=VALUE and a logo web resource, then publish to make one the active org theme (PublishTheme). Themes are not solution-aware (they don't travel with a solution export) |
report |
Register and manage custom reports headlessly: list / get / create / delete; create --body-file uploads an SSRS RDL (reporttypecode 1); create --url registers a link report (reporttypecode 3); create --org makes it org-wide (ispersonal=false); set-category files it under sales / service / marketing / administrative. Reports are solution-aware |
plugin |
Register/update/unregister plug-in assemblies, plug-in types, webhook service endpoints, SDK message processing steps (bind to a plug-in type or a service endpoint), and step entity images |
security |
Create security roles (create-role); grant / replace a role's privileges (set-role-privileges); list and assign roles to users or teams; show a user's effective privileges (incl. team-inherited); grant / revoke / list record sharing (POA) |
fieldsec |
Column-level (field) security: create field security profiles, grant --read/--create/--update column permissions, assign profiles to users or teams, list / get |
dup |
Duplicate-detection rules: create a rule, add match conditions, publish (async) / unpublish, check a candidate record against the published rules (RetrieveDuplicates), and bulk-detect — sweep a table for existing duplicates via a server-side BulkDetectDuplicates async job; list / get |
workflow |
List, activate/deactivate, delete, trigger, clone, export, import, update (metadata and on-prem whole-XAML replace), and migration-assess (classic-workflow → cloud-flow readiness) D365 workflow definitions |
sla |
Full SLA lifecycle: create an SLA for a target entity (auto-enabling IsSLAEnabled on that entity), add-kpi to attach KPI / SLA-item conditions (FetchXML --applicable-when / --success-criteria), and activate (activates backing workflows first, then the SLA, with structured per-step compile-error reporting) |
translation |
Export / import localizable display labels for a solution (ExportTranslation / ImportTranslation) |
action |
Call arbitrary OData functions and actions |
audit |
Retrieve server-side D365 audit change history (audit history / audit detail) — distinct from the local session audit journal |
session |
Local session state, command history, and audit journal |
completion |
Print or install shell completion (bash/zsh/fish/powershell); install caches the script + prints the rc line to source |
self-update |
Upgrade a frozen (install-script) binary in place and re-sync installed agent skills + shell completion; --check reports current vs latest |
For a continuous redeploy loop during front-end development, pipe entr (or watchexec) into webresource push:
# re-push whenever any JS file changes (find is portable; bash ** globstar is off by default)
find webresources -type f -name '*.js' | entr crm webresource push webresources --prefix cwx
# watchexec equivalent
watchexec -e js,css,html -- crm webresource push webresources --prefix cwxSee how-to/webresource for the full push semantics (naming convention, upsert logic, dry-run).
The metadata group covers both browsing and write verbs. metadata describe <entity> returns a one-shot, read-only write-readiness brief — entity set, primary
id/name, writable columns + required levels, lookup @odata.bind keys + targets, and
picklist/state/status options — everything needed to build a valid record payload in
one call. Write verbs new in 0.5.0:
metadata add-attribute <entity> --kind <k>— add a column (15 kinds, incl.customercomposite lookup);--type rollupor--type calculatedlayers rollup/calculated on top of a supported column kind (requires--formula-file <xaml>— XAML is editor-authored and sent verbatim)metadata update-attribute <entity> <attribute>— edit a column (display/description/required/bounds/format/audit);--behaviorperforms the platform's one-time datetime behavior changeUserLocal→DateOnly/TimeZoneIndependent(both terminal), auto-coupling the format and warning that existing stored values are not backfilledmetadata create-one-to-many/create-many-to-many— relationships;create-one-to-manyaccepts--hierarchicalto mark the 1:N as a parent/child hierarchy (self-referencing entity required)metadata update-relationship <schema>— update cascade/menu on an existing relationship;--hierarchical / --no-hierarchicalsetsIsHierarchicalon a 1:N (rejected for N:N)metadata can-relate <entity> --as referenced|referencing|many-to-many— read-only eligibility check before creating a relationship;--valid-partnerslists legal partner tables (N:N partner list is org-global, not entity-scoped)metadata create-entityaccepts--data-provider,--data-source,--external-name,--external-collection-nameto create a VIRTUAL (external-data-backed) table; on v9.1 virtual tables are read-only and require the data-provider record to exist first--audit / --no-auditoncreate-entity,add-attribute,update-entity, andupdate-attributetoggles theIsAuditEnabledmanaged property on a table/column (requires org-level auditing to be turned on for audit records to be written)metadata list-optionsets/get-optionset/create-optionset/update-optionset/delete-optionset— global option setsmetadata keys <entity>/create-key <entity> --key-attributes col1,col2/delete-key <entity> <key>— read, create, and drop alternate keys (the natural-key index thatentity upsert --key/data import --mode upsert --keymatch on)metadata delete-entity <logical-name>— drop a custom entity (gated)metadata delete-attribute <entity> <attribute>/delete-relationship <schema-name>— drop a custom column or relationship (gated)metadata dependencies <target> [--kind entity|attribute|optionset|relationship] [--for delete|dependents|required]— pre-delete dependency preview: returnscan_delete+blockers[];--for requiredlists what the target itself depends on (RetrieveRequiredComponents); pass--check-dependencieson ametadata delete-*verb (delete-entity, delete-attribute, delete-relationship, delete-optionset) to fold this into the previewmetadata export-spec <logical_name> [--with-views] [--with-relationships] [--with-forms] [-o FILE]— project a live entity into acrm apply -f–consumable desired-state spec (round-trip). Pure GETs;-owrites the bare YAML directly.--with-formsprojects the entity's seedable main form (custom-field placement, JS libraries + handlers) as aforms:block. Custom columns that cannot be represented (permission limits, unsupported formats) are reported inmeta.warningsinstead of silently dropped; a form that cannot round-trip a real apply (an additional main form) is reported inmeta.skipped.metadata clone-entity <source> <new-schema-name>— duplicate a custom entity (skeleton + opt-in--with-forms/--with-views/--with-workflows/--with-charts, or--with-all) purely over the Web API. The ribbon is not cloned (no API write path); N:N and parent-side relationships are not cloned.metadata status-add <entity> --state <stateCode> --label <text>— add astatuscodeoption tied to a state (InsertStatusValue);--valueis optional (server assigns the next value with publisher prefix if omitted)metadata state-relabel <entity> --value <stateCode> --label <text>— relabel astatecodestate option such as Active/Inactive (UpdateStateValue);--merge-labelspreserves labels in other languagesmetadata create-mapping <relationship> --from <attr> --to <attr>— create a field/attribute mapping on a 1:N relationship;--autobulk-generates the likely mappings viaAutoMapEntity(replaces any existing maps for the pair)metadata changes [--since <stamp>] [--entity <logical> ...] [--attributes]— retrieve new/changed metadata since a version stamp (RetrieveMetadataChanges); save the returnedserver_version_stampand pass it as--sincenext run to get only the delta. Omit--sincefor a baseline snapshot. Omit--entityto query every table (expensive on a baseline — scope with--entitywhen possible)
crm apply -f spec.yaml stands up a whole table (publisher, solution, entity,
columns, option sets, relationships, views, forms, web resources, security roles,
plug-in assemblies with their types, steps, and images, and model-driven apps
with their sitemaps) from one declarative
spec, in dependency order, publishing once at the end (when a publishable
component changed — security roles and plug-in components are not published). It
is convergent: a component that already exists is reconciled against the spec
— left untouched when it matches (skipped), updated in place when an allowed
field drifts (updated), or refused with no write when the divergence would
require a destructive drop-and-recreate (replace_blocked, ok=false, exit 1).
The full envelope is
{ok, data:{applied, updated, skipped, replace_blocked, pruned, planned, failed}, meta:{staged}}.
--dry-run reads the live org and reports the full drift without writing —
planned (would create), updated (would update), replace_blocked, and
pruned — so you can preview exactly what an apply would converge; in --json
mode the envelope's meta also carries dry_run: true. Add -o/--plan-out FILE
(dry-run only) to serialize that drift report as a self-contained plan
artifact — spec, payload sha256 pins, and per-component verdicts — for review in
a PR or ticket. crm apply --from-plan FILE then executes that plan only if it
is still exactly true: it re-reconciles against the live org, refuses a stale
plan (any diverged component → ok=false, exit 1, no writes), and replays the
plan's own intent (--prune / --allow-data-loss / --stage-only) rather than
letting you re-specify it — so what was approved is what runs. --dry-run --from-plan re-verifies without executing. See how-to/apply.
crm scaffold table DISPLAY --column 'DISPLAY:KIND[:opts]' ... is the quick
one-liner path: builds an entity + N columns in memory and runs them through the
same apply engine in one publish. Use it for simple tables; use apply when you
need publishers, solutions, option sets, relationships, views, web resources, or
security roles. See how-to/scaffold.
Use crm <group> --help for command-level details.
# Unit tests (mocked HTTP, no server needed)
pip install -e .[dev]
pytest crm/tests/test_core.py -v
# E2E (requires live server credentials)
# The E2E fixture reads these env vars to SEED a temporary profile (the CLI itself
# resolves from the saved profile, not the env).
export D365_URL=... D365_USERNAME=... D365_PASSWORD=... D365_DOMAIN=...
CRM_FORCE_INSTALLED=1 pytest crm/tests/ -v -sSee crm/tests/TEST.md for the full test plan and recorded results.
crm (Click + REPL)
│
├─ core/ record CRUD, queries, metadata, solutions, export, session
└─ utils/
├─ d365_backend.py requests + requests_ntlm → live Web API
└─ repl_skin.py shared REPL chrome
See D365.md in the project root for the full SOP.
- IFD (claims) auth, certificate credentials, and OAuth flows other than client-credentials (device-code, interactive, ROPC) — on-prem uses NTLM, cloud uses OAuth 2.0 client-credentials (secret) against the public cloud only.
- Plugin / workflow source code deployment — use solution import for that.
- Audit log / report execution — out of scope; can be added as an extension.
Copyright 2026 Ahmed Gharib, Principal Advanced Analytics Engineer at ITWorx (ahmed.gharib@itworx.com).
Licensed under the PolyForm Noncommercial License 1.0.0 — free to use, modify, and share for noncommercial purposes (personal, research, education, charity, government). This applies to the source and the released binaries.
Commercial use by any organization requires a separate written license from the copyright holder. Contact ahmed.gharib@itworx.com to arrange one.