# Major CLI & CMA changes for managing content programmatically

Posted on May 18th, 2026 by Ronak Ganatra

### 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 workflows
-   `datocms schema:inspect`. Inspect models, fields, and relationships without opening the UI
    
-   `schema:generate` now emits `Schema.X.ID` and `Schema.X.REF` runtime constants alongside types
-   New `FieldValueInRequest<T, K>` helper family for typing CMA write payloads correctly
    
-   `isBlockWithItemOfType` predicate for narrowing structured text block nodes to a specific model
-   New `datocms-structured-text-dastdown` package to serialize and edit DAST documents as plain text
    
-   `mapNodes` upgraded to support full structural tree rewrites (splat, remove, transform)
-   [Records](https://www.datocms.com/docs/content-management-api/resources/item.md) and [Update a record](https://www.datocms.com/docs/content-management-api/resources/item/update.md) docs fully rewritten with end-to-end examples
    
-   To update: `npm i -g @datocms/cli` for CLI changes, `npm i @datocms/cma-client-node@latest` for client changes, `npm i datocms-structured-text-dastdown` for 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 `422`s 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.

```typescript
const page = await client.items.find<Schema.LandingPage>(id, { nested: true });

// typed accumulator
const 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 with `nested: true`, and
    
-   `FieldValueInRequest<T, K>` for what you're sending back. `T` accepts a fetched record, a narrowed block, or a `Schema.X` marker 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`:

```typescript
// inline guard
if (isBlockWithItemOfType(Schema.CtaBlock.ID, node)) {
  // node.item.attributes is now typed as CtaBlock
}

// curried predicate
const 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:

```typescript
// value position now generated from your project
await 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:

Terminal window

```bash
npx datocms cma:script <<'EOF'
await client.items.create<Schema.Article>({
  item_type: Schema.Article.REF,
  title: 'Hello world',
});
EOF
```

Full 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:

Terminal window

```bash
npx datocms cma:script tmp/scripts/backfill-slugs.ts
```

The 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:

```typescript
// type position: the model's TS shape, as before
const article = await client.items.find<Schema.Article>(id);

// value position: the model's id and ref, generated from your project
await 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](https://github.com/datocms/cli/tree/main/packages/cli#datocms-schemainspect-filter) 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](https://github.com/toon-format/toon) 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:

```typescript
import { parse, serialize } from 'datocms-structured-text-dastdown';

const cur = await client.items.find<Schema.Article>('article-id', { nested: true });

// 1. serialize to dastdown
const text = serialize(cur.body);
// 2. edit as plain text
const edited = text.replace(/Acme Corp/g, '**Acme Inc.**');
// 3. parse back, reusing the original document so untouched blocks
//    keep their original payload by reference
const 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:

Terminal window

```bash


# compact overview
npx datocms schema:inspect article

# full details for pipeline use
npx datocms schema:inspect article --json --fields-details=complete | jq '.fields'

# walk the graph
npx datocms schema:inspect landing-page --include-nested-blocks --include-referenced-models
```

And `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](https://www.datocms.com/docs/content-management-api/resources/item.md) and [Update a record guide](https://www.datocms.com/docs/content-management-api/resources/item/update.md) 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.