Major CLI & CMA changes for managing content programmatically
TLDR
The last couple of weeks brought a set of converging improvements across the CLI, the JS CMA client, and the structured-text packages.
datocms cma:script. Run ad-hoc TypeScript against your project from the CLI, no boilerplate required, standard input mode built for agentic/LLM workflowsdatocms schema:inspect. Inspect models, fields, and relationships without opening the UIschema:generatenow emitsSchema.X.IDandSchema.X.REFruntime constants alongside typesNew
FieldValueInRequest<T, K>helper family for typing CMA write payloads correctlyisBlockWithItemOfTypepredicate for narrowing structured text block nodes to a specific modelNew
datocms-structured-text-dastdownpackage to serialize and edit DAST documents as plain textmapNodesupgraded to support full structural tree rewrites (splat, remove, transform)Records and Update a record docs fully rewritten with end-to-end examples
To update:
npm i -g @datocms/clifor CLI changes,npm i @datocms/cma-client-node@latestfor client changes,npm i datocms-structured-text-dastdownfor the new package.
The not-so-great truth about working with the CMA programmatically for a while was that the API itself is powerful, but getting it to behave well with TypeScript (or inside an automated pipeline) required more manual work than it should have.
So we made some dramatic changes to the CLI for the CMA.
This batch of changes addresses both:
Better TypeScript inference across the board so the type system catches mistakes before they hit the API.
And a ✨new✨ set of CLI primitives that make the CMA usable from automated workflows and LLM-generated scripts.
Closing those annoying TypeScript gaps
Working with block fields through the CMA always required you to know a loooot of context up front: what shape the API accepts on writes (different from what it returns on reads), how to type an accumulator when rebuilding a block array, how to narrow a cluster of blocks down to a specific model... The client didn't help thaaat much either. There were times when fields came back as unknown, and wrong payloads only surfaced as 422s at runtime.
The new FieldValueInRequest<T, K> helper family fixes the write-side typing gap. You pass it the record type and a field key, and it gives you back the exact TypeScript type the API expects for that field on a create or update call, including what each block entry needs to look like.
const page = await client.items.find<Schema.LandingPage>(id, { nested: true });
// typed accumulatorconst sections: NonNullable<FieldValueInRequest<typeof page, 'sections'>> = [];
for (const block of page.sections) { if (isBlockOfType(Schema.HeroBlock.ID, block)) { sections.push( buildBlockRecord<Schema.HeroBlock>({ id: block.id, headline: block.attributes.headline.toUpperCase(), }) ); } else { sections.push(block.id); // keep unchanged blocks as IDs }}
await client.items.update<Schema.LandingPage>(page, { sections });Three variants cover different points in the data flow.
FieldValue<T, K>for standard responses,FieldValueInNestedResponse<T, K>for when you've fetched withnested: true, andFieldValueInRequest<T, K>for what you're sending back.Taccepts a fetched record, a narrowed block, or aSchema.Xmarker directly.
For structured text specifically, there's now isBlockWithItemOfType/isInlineBlockWithItemOfType which is a predicate that narrows a block node inside a DAST tree to a specific model shape in one step. No extra casting, and it works both, as an inline guard, and as a curried predicate for findFirstNode / filter / find:
// inline guardif (isBlockWithItemOfType(Schema.CtaBlock.ID, node)) { // node.item.attributes is now typed as CtaBlock}
// curried predicateconst firstCta = findFirstNode(content, isBlockWithItemOfType(Schema.CtaBlock.ID));Oh. And schema:generate now emits runtime constants alongside types (Schema.Article.ID and Schema.Article.REF) so you can stop hardcoding item-type ID strings:
// value position now generated from your projectawait client.items.create({ item_type: Schema.Article.REF, title: 'Hello world',});
if (item.relationships.item_type.data.id === Schema.Article.ID) { ... }The CLI can now run scripts directly
Getting a CMA script running previously meant bootstrapping a proper TypeScript project with the whole imports, client setup, tsconfig, etc. etc.. which is a bearable overhead for migrations you're saving, but it's a choooore for one-off jobs.
datocms cma:script fills the gap between cma:call (single API operations, limited) and spinning up a full external TypeScript project just to run a script. You get a pre-authenticated client from datocms link, --api-token, or an environment variable, and two modes depending on what you need.
stdin mode is the fast path , no exports, no imports, just top-level await with client and Schema available as globals:
npx datocms cma:script <<'EOF'await client.items.create<Schema.Article>({ item_type: Schema.Article.REF, title: 'Hello world',});EOFFull typechecking runs before anything hits the API. This is also the mode designed for agentic workflows so an LLM can write the script and pipe it to the CLI in a single step, with the type system catching wrong payload shapes before they're executed.
File mode is for anything that needs local helpers or editor LSP support:
npx datocms cma:script tmp/scripts/backfill-slugs.tsThe file uses the migration function signature (export default async function(client: Client)), so when a one-off script is worth keeping, it moves into migrations/ without modifications.
schema:generate now emits runtime constants. Schema.Article.ID and Schema.Article.REF are now generated from your project alongside the TypeScript types, so you can stop hardcoding item-type ID strings that drift out of sync across envs:
// type position: the model's TS shape, as beforeconst article = await client.items.find<Schema.Article>(id);
// value position: the model's id and ref, generated from your projectawait client.items.create({ item_type: Schema.Article.REF, // …});
if (item.relationships.item_type.data.id === Schema.Article.ID) { // …}datocms schema:inspect lets you inspect DatoCMS models and modular blocks to emit their structure, fields, and relationships. Pass an exact or fuzzy filter (API key, ID, or display name) to narrow the scope, or omit it to list the entire project.
The command defaults to a compact TOON output and basic field data. Use the --json flag to format output for pipeline tools like jq, and flags like --include-validators or --fields-details=complete to expand field verbosity.
You can also walk the schema graph using specific inclusion flags:
--include-nested-blocks— recursively includes nested blocks.--include-referenced-models— pulls in models referenced by link or structured text fields.--include-embedding-models— fetches models that embed the target blocks.
datocms cma:docs now shows the TypeScript signature of the matching client method alongside the docs, so you can see argument shapes and return types without leaving the terminal. Two new flags (--expand-types (inline specific or all reachable type declarations) and --types-depth (control how deep the type walker descends)) let you drill into the type definitions when you need more detail.
Editing Structured Text without touching the AST
Structured Text content lives as a DAST tree which a nested structure of typed nodes. Any programmatic edit has required either writing traversal logic against the AST directly, or skipping it. Neither was really that practical for common operations like bulk find-and-replace across articles, brand renames, or feeding structured content to an LLM for a rewrite.
The new datocms-structured-text-dastdown package gives you a different option. Serialize the DAST tree to a readable markdown-like format, edit it as plain text, and parse it back:
import { parse, serialize } from 'datocms-structured-text-dastdown';
const cur = await client.items.find<Schema.Article>('article-id', { nested: true });
// 1. serialize to dastdownconst text = serialize(cur.body);// 2. edit as plain textconst edited = text.replace(/Acme Corp/g, '**Acme Inc.**');// 3. parse back, reusing the original document so untouched blocks// keep their original payload by referenceconst body = parse(edited, cur.body);
await client.items.update<Schema.Article>('article-id', { body });Embedded blocks appear in the serialized output as <block id="..."/> placeholders so you can move or delete them, but their internal fields are opaque at this layer. For editing block contents or doing structural tree rewrites, you use mapNodes from datocms-structured-text-utils which now supports full structural transforms, not just 1:1 node mapping.
Return a single node, an array to splat into siblings, or null to remove.
The LLM angle here is the same as with cma:script: dastdown output is plain text a model can process directly and pipe back to parse(). Bulk content rewrites, tone adjustments, translation, or any text operation that would previously have required understanding the DAST format can now be handed off to a model with a straight string in and string out.
This really is a great fit for text-heavy content where edits are textual and may cross node boundaries. Think articles, docs, and chapters. Its' not really recommended for landing pages made up of opaque blocks.
Two new tools for working with your schema
datocms schema:inspect is a new CLI command that lets you examine models and blocks without opening the UI. Pass a filter (API key, ID, and display name with fuzzy matching) or omit it to just list the whole project:
# compact overviewnpx datocms schema:inspect article
# full details for pipeline usenpx datocms schema:inspect article --json --fields-details=complete | jq '.fields'
# walk the graphnpx datocms schema:inspect landing-page --include-nested-blocks --include-referenced-modelsAnd datocms cma:docs has been enhanced so it now shows the TypeScript signature of the matching client method alongside the documentation, and two new flags (--expand-types and --types-depth) let you drill into type definitions without leaving the terminal.
The records docs got a full rewrite
To cover this in a lot more detail, the Records guide and Update a record guide have been completely rewritten. They now cover block field operations (modular content, single block, structured text) with actual before/after terminal outputs for every operation, and a full treatment of localization update rules, bulk block operations across the content hierarchy, and optimistic locking. Check them out.