From efaf62f1ba685b5d5034eb530c365586f73e385f Mon Sep 17 00:00:00 2001 From: Marcelo Finamor Vieira Date: Fri, 11 Sep 2026 11:47:22 +0200 Subject: [PATCH 1/4] Pin reference downloads to an immutable revision --- .changeset/pin-reference-snapshot.md | 5 ++ packages/cli/src/commands/agents/reference.ts | 2 +- .../test/commands/agents-reference.test.ts | 84 +++++++++++++++++++ 3 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 .changeset/pin-reference-snapshot.md create mode 100644 packages/cli/test/commands/agents-reference.test.ts diff --git a/.changeset/pin-reference-snapshot.md b/.changeset/pin-reference-snapshot.md new file mode 100644 index 0000000..89f670b --- /dev/null +++ b/.changeset/pin-reference-snapshot.md @@ -0,0 +1,5 @@ +--- +'datocms': patch +--- + +Pin reference downloads to an immutable revision so documentation remains available when upstream files move. diff --git a/packages/cli/src/commands/agents/reference.ts b/packages/cli/src/commands/agents/reference.ts index 00235af..12c36fe 100644 --- a/packages/cli/src/commands/agents/reference.ts +++ b/packages/cli/src/commands/agents/reference.ts @@ -2,7 +2,7 @@ import { BaseCommand, oclif } from '@datocms/cli-utils'; const NAME_PATTERN = /^[a-z0-9-]+$/; const SKILLS_BASE_URL = - 'https://raw.githubusercontent.com/datocms/agent-skills/refs/heads/master/skills/'; + 'https://raw.githubusercontent.com/datocms/agent-skills/86c533b74c2913e590797eb0b2622d2cdfe5cf68/skills/'; export default class Command extends BaseCommand { static hidden = true; diff --git a/packages/cli/test/commands/agents-reference.test.ts b/packages/cli/test/commands/agents-reference.test.ts new file mode 100644 index 0000000..758861b --- /dev/null +++ b/packages/cli/test/commands/agents-reference.test.ts @@ -0,0 +1,84 @@ +import { runCommand } from '@oclif/test'; +import { expect } from 'chai'; + +const REFERENCE_URL = + 'https://raw.githubusercontent.com/datocms/agent-skills/86c533b74c2913e590797eb0b2622d2cdfe5cf68/skills/datocms-cma/references/editing-records.md'; + +describe('agents:reference', () => { + const originalFetch = globalThis.fetch; + let requestedUrls: string[]; + let respond: () => Promise; + + beforeEach(() => { + requestedUrls = []; + respond = async () => new Response('# Editing records'); + globalThis.fetch = async (input) => { + requestedUrls.push(String(input)); + return respond(); + }; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + const run = () => + runCommand(['agents:reference', 'datocms-cma', 'editing-records'], { + root: process.cwd(), + }); + + it('fetches the pinned document and adds a missing trailing newline', async () => { + const { stdout, error } = await run(); + expect(error).to.equal(undefined); + expect(requestedUrls).to.deep.equal([REFERENCE_URL]); + expect(stdout).to.equal('# Editing records\n'); + }); + + it('preserves a document that already ends in a newline', async () => { + respond = async () => new Response('# Editing records\n'); + const { stdout, error } = await run(); + expect(error).to.equal(undefined); + expect(stdout).to.equal('# Editing records\n'); + }); + + it('rejects path traversal before fetching', async () => { + for (const args of [ + ['../datocms-cma', 'editing-records'], + ['datocms-cma', '../editing-records'], + ]) { + const { error } = await runCommand(['agents:reference', ...args], { + root: process.cwd(), + }); + expect(error?.message).to.match(/Invalid (skill|reference) name/); + } + expect(requestedUrls).to.deep.equal([]); + }); + + it('reports a missing reference with its pinned URL', async () => { + respond = async () => new Response('', { status: 404 }); + const { stdout, error } = await run(); + expect(stdout).to.equal(''); + expect(error?.message).to.equal( + `Reference "editing-records" not found at ${REFERENCE_URL}`, + ); + }); + + it('preserves HTTP error details', async () => { + respond = async () => + new Response('', { status: 503, statusText: 'Service Unavailable' }); + const { error } = await run(); + expect(error?.message).to.equal( + 'Failed to fetch reference "editing-records": HTTP 503 Service Unavailable', + ); + }); + + it('preserves network error details', async () => { + respond = async () => { + throw new Error('Connection failed'); + }; + const { error } = await run(); + expect(error?.message).to.equal( + 'Failed to fetch reference "editing-records": Connection failed', + ); + }); +}); From 705a2c1c643ce727b2cbc1c1bbf0908fa0f20130 Mon Sep 17 00:00:00 2001 From: Marcelo Finamor Vieira Date: Fri, 11 Sep 2026 11:59:05 +0200 Subject: [PATCH 2/4] Support references from the unified DatoCMS skill --- .changeset/unified-reference-lookup.md | 5 ++ README.md | 17 ++++ packages/cli/src/commands/agents/reference.ts | 58 ++++++++++-- packages/cli/src/commands/cma/script.ts | 4 +- .../test/commands/agents-reference.test.ts | 89 +++++++++++++++++-- 5 files changed, 153 insertions(+), 20 deletions(-) create mode 100644 .changeset/unified-reference-lookup.md diff --git a/.changeset/unified-reference-lookup.md b/.changeset/unified-reference-lookup.md new file mode 100644 index 0000000..bb974f5 --- /dev/null +++ b/.changeset/unified-reference-lookup.md @@ -0,0 +1,5 @@ +--- +'datocms': minor +--- + +Support qualified reference paths from the unified datocms skill while preserving legacy skill-name commands. Update script validation guidance to use the new command. diff --git a/README.md b/README.md index ea93061..c106558 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,23 @@ DatoCMS CLI tool for managing DatoCMS projects, environments and schemas. - [@datocms/cli-plugin-wordpress](https://github.com/datocms/cli/tree/main/packages/cli-plugin-wordpress): CLI plugin to import WordPress websites into DatoCMS projects - [@datocms/cli-plugin-contentful](https://github.com/datocms/cli/tree/main/packages/cli-plugin-contentful): CLI plugin to import Contentful spaces into DatoCMS projects +## Reference documents + +Read a bundled reference from the `datocms` skill without installing it locally: + +```sh +datocms agents:reference datocms cma/editing-records +``` + +Use `/` without the `.md` extension. Domains are `cda`, `cli`, +`cma`, `modeling`, `frontend`, `plugin`, `setup`, and `feedback`. Downloads use +an immutable revision of the skill bundle. + +Legacy commands such as `datocms agents:reference datocms-cma editing-records` +still work. `datocms-content-modeling` maps to `modeling`, +`datocms-frontend-integrations` maps to `frontend`, and the other legacy names +map to the domain after `datocms-`. + ## Development This is an npm-workspaces monorepo: `npm install` links the packages to each diff --git a/packages/cli/src/commands/agents/reference.ts b/packages/cli/src/commands/agents/reference.ts index 12c36fe..5e2b602 100644 --- a/packages/cli/src/commands/agents/reference.ts +++ b/packages/cli/src/commands/agents/reference.ts @@ -1,8 +1,19 @@ import { BaseCommand, oclif } from '@datocms/cli-utils'; const NAME_PATTERN = /^[a-z0-9-]+$/; -const SKILLS_BASE_URL = - 'https://raw.githubusercontent.com/datocms/agent-skills/86c533b74c2913e590797eb0b2622d2cdfe5cf68/skills/'; +const LEGACY_SKILL_DOMAINS = new Map([ + ['datocms-cda', 'cda'], + ['datocms-cli', 'cli'], + ['datocms-cma', 'cma'], + ['datocms-content-modeling', 'modeling'], + ['datocms-frontend-integrations', 'frontend'], + ['datocms-plugin', 'plugin'], + ['datocms-setup', 'setup'], + ['datocms-feedback', 'feedback'], +]); +const DOMAINS = new Set(LEGACY_SKILL_DOMAINS.values()); +const REFERENCES_BASE_URL = + 'https://raw.githubusercontent.com/datocms/agent-skills/b22e0f87d52bd1a20f146191ba760ce46616150e/skills/datocms/references/'; export default class Command extends BaseCommand { static hidden = true; @@ -12,19 +23,21 @@ export default class Command extends BaseCommand { static examples = [ { - description: "Print the datocms-cma skill's editing-records reference", + description: "Print the datocms skill's CMA editing-records reference", command: - '<%= config.bin %> <%= command.id %> datocms-cma editing-records', + '<%= config.bin %> <%= command.id %> datocms cma/editing-records', }, ]; static args = { skill: oclif.Args.string({ - description: 'Skill name (e.g. `datocms-cma`)', + description: + 'Skill name (`datocms`, or a legacy name such as `datocms-cma`)', required: true, }), name: oclif.Args.string({ - description: 'Reference name (e.g. `editing-records`)', + description: + 'Reference path (e.g. `cma/editing-records`, or `editing-records` with a legacy skill name)', required: true, }), }; @@ -38,13 +51,40 @@ export default class Command extends BaseCommand { ); } - if (!NAME_PATTERN.test(args.name)) { + const legacyDomain = LEGACY_SKILL_DOMAINS.get(args.skill); + if (args.skill !== 'datocms' && !legacyDomain) { this.error( - `Invalid reference name "${args.name}": must match ${NAME_PATTERN}`, + `Unknown skill "${ + args.skill + }": use "datocms" or a supported legacy skill name (${[ + ...LEGACY_SKILL_DOMAINS.keys(), + ].join(', ')})`, ); } - const url = `${SKILLS_BASE_URL}${args.skill}/references/${args.name}.md`; + const referencePath = legacyDomain + ? `${legacyDomain}/${args.name}` + : args.name; + const [domain, name, ...extraSegments] = referencePath.split('/'); + + if ( + !domain || + !DOMAINS.has(domain) || + !name || + !NAME_PATTERN.test(name) || + name !== name.trim() || + extraSegments.length > 0 + ) { + this.error( + `Invalid reference name "${args.name}": use ${ + legacyDomain ? 'a reference name' : '"/"' + } with ${ + legacyDomain ? '' : `domain one of ${[...DOMAINS].join(', ')} and ` + }name matching ${NAME_PATTERN}`, + ); + } + + const url = `${REFERENCES_BASE_URL}${referencePath}.md`; let response: Response; try { diff --git a/packages/cli/src/commands/cma/script.ts b/packages/cli/src/commands/cma/script.ts index 303a9bb..f64a482 100644 --- a/packages/cli/src/commands/cma/script.ts +++ b/packages/cli/src/commands/cma/script.ts @@ -354,12 +354,12 @@ export default class Command extends CmaClientCommand { '', '===== AGENT INSTRUCTION =====', `${context} Before retrying, you MUST read the`, - '`editing-records` reference of the `datocms-cma` skill. It', + '`cma/editing-records` reference of the `datocms` skill. It', 'contains the patterns and type-safety rules required to', 'write a valid script.', '', 'If the skill is not installed, fetch the reference with:', - ' datocms agents:reference datocms-cma editing-records', + ' datocms agents:reference datocms cma/editing-records', '=============================', '', ].join('\n'); diff --git a/packages/cli/test/commands/agents-reference.test.ts b/packages/cli/test/commands/agents-reference.test.ts index 758861b..cfcbb29 100644 --- a/packages/cli/test/commands/agents-reference.test.ts +++ b/packages/cli/test/commands/agents-reference.test.ts @@ -1,8 +1,9 @@ import { runCommand } from '@oclif/test'; import { expect } from 'chai'; -const REFERENCE_URL = - 'https://raw.githubusercontent.com/datocms/agent-skills/86c533b74c2913e590797eb0b2622d2cdfe5cf68/skills/datocms-cma/references/editing-records.md'; +const BASE_URL = + 'https://raw.githubusercontent.com/datocms/agent-skills/b22e0f87d52bd1a20f146191ba760ce46616150e/skills/datocms/references/'; +const REFERENCE_URL = `${BASE_URL}cma/editing-records.md`; describe('agents:reference', () => { const originalFetch = globalThis.fetch; @@ -22,8 +23,8 @@ describe('agents:reference', () => { globalThis.fetch = originalFetch; }); - const run = () => - runCommand(['agents:reference', 'datocms-cma', 'editing-records'], { + const run = (skill = 'datocms', name = 'cma/editing-records') => + runCommand(['agents:reference', skill, name], { root: process.cwd(), }); @@ -41,15 +42,85 @@ describe('agents:reference', () => { expect(stdout).to.equal('# Editing records\n'); }); - it('rejects path traversal before fetching', async () => { + it('maps legacy skill names to their unified domains', async () => { + for (const [skill, domain] of [ + ['datocms-cda', 'cda'], + ['datocms-cli', 'cli'], + ['datocms-cma', 'cma'], + ['datocms-content-modeling', 'modeling'], + ['datocms-frontend-integrations', 'frontend'], + ['datocms-plugin', 'plugin'], + ['datocms-setup', 'setup'], + ['datocms-feedback', 'feedback'], + ]) { + const { stdout, error } = await run(skill, 'example-reference'); + expect(error).to.equal(undefined); + expect(stdout).to.equal('# Editing records\n'); + expect(requestedUrls.at(-1)).to.equal( + `${BASE_URL}${domain}/example-reference.md`, + ); + } + expect(requestedUrls).to.have.length(8); + }); + + it('accepts qualified references in each supported domain', async () => { + for (const domain of [ + 'cda', + 'cli', + 'cma', + 'modeling', + 'frontend', + 'plugin', + 'setup', + 'feedback', + ]) { + const { error } = await run('datocms', `${domain}/example-reference`); + expect(error).to.equal(undefined); + expect(requestedUrls.at(-1)).to.equal( + `${BASE_URL}${domain}/example-reference.md`, + ); + } + }); + + it('rejects unknown skills before fetching', async () => { + const { error } = await run('datocms-missing', 'editing-records'); + expect(error?.message).to.contain('Unknown skill "datocms-missing"'); + expect(requestedUrls).to.deep.equal([]); + }); + + it('rejects invalid domains and paths before fetching', async () => { for (const args of [ ['../datocms-cma', 'editing-records'], ['datocms-cma', '../editing-records'], + ['datocms-cma', 'cma/editing-records'], + ['datocms-cma', 'editing-records.md'], + ['datocms', 'editing-records'], + ['datocms', 'missing/editing-records'], + ['datocms', 'CMA/editing-records'], + ['datocms', 'cma/Editing-records'], + ['datocms', '/editing-records'], + ['datocms', 'cma/'], + ['datocms', 'cma//editing-records'], + ['datocms', 'cma/editing-records/extra'], + ['datocms', 'cma/../editing-records'], + ['datocms', '../cma/editing-records'], + ['datocms', 'cma/editing-records.md'], + ['datocms', 'cma/editing-records?raw=1'], + ['datocms', 'cma/editing-records#section'], + ['datocms', 'cma%2Fediting-records'], + ['datocms', 'cma/%2e%2e'], + ['datocms', 'cma\\editing-records'], + ['datocms', 'cma/editing_records'], + // runCommand reparses the arguments, so quote whitespace to preserve it. + ['datocms', '"cma/editing-records\n"'], + ['datocms', '"cma/editing-records\r"'], ]) { const { error } = await runCommand(['agents:reference', ...args], { root: process.cwd(), }); - expect(error?.message).to.match(/Invalid (skill|reference) name/); + expect(error?.message, JSON.stringify(args)).to.match( + /Invalid (skill|reference) name/, + ); } expect(requestedUrls).to.deep.equal([]); }); @@ -59,7 +130,7 @@ describe('agents:reference', () => { const { stdout, error } = await run(); expect(stdout).to.equal(''); expect(error?.message).to.equal( - `Reference "editing-records" not found at ${REFERENCE_URL}`, + `Reference "cma/editing-records" not found at ${REFERENCE_URL}`, ); }); @@ -68,7 +139,7 @@ describe('agents:reference', () => { new Response('', { status: 503, statusText: 'Service Unavailable' }); const { error } = await run(); expect(error?.message).to.equal( - 'Failed to fetch reference "editing-records": HTTP 503 Service Unavailable', + 'Failed to fetch reference "cma/editing-records": HTTP 503 Service Unavailable', ); }); @@ -78,7 +149,7 @@ describe('agents:reference', () => { }; const { error } = await run(); expect(error?.message).to.equal( - 'Failed to fetch reference "editing-records": Connection failed', + 'Failed to fetch reference "cma/editing-records": Connection failed', ); }); }); From fed1b05099d959b7c8e44448a8e3d7a1f7c753b4 Mon Sep 17 00:00:00 2001 From: Marcelo Finamor Vieira Date: Fri, 11 Sep 2026 13:16:28 +0200 Subject: [PATCH 3/4] Run help assertion inside a registered test --- packages/cli/test/commands/help.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/cli/test/commands/help.test.ts b/packages/cli/test/commands/help.test.ts index bbb9a56..68bf9cb 100644 --- a/packages/cli/test/commands/help.test.ts +++ b/packages/cli/test/commands/help.test.ts @@ -1,7 +1,12 @@ import { runCommand } from '@oclif/test'; import { expect } from 'chai'; -describe('datocms', async () => { - const { stdout } = await runCommand('help'); - expect(stdout).to.contain('plugins'); +describe('datocms', () => { + it('shows the available commands', async () => { + const { stdout, error } = await runCommand('help', { + root: process.cwd(), + }); + expect(error).to.equal(undefined); + expect(stdout).to.contain('plugins'); + }); }); From 331cd4836413627a3719ba6e1691cd1f946142f2 Mon Sep 17 00:00:00 2001 From: Marcelo Finamor Vieira Date: Fri, 11 Sep 2026 13:19:24 +0200 Subject: [PATCH 4/4] Refresh the unified reference snapshot --- packages/cli/src/commands/agents/reference.ts | 2 +- packages/cli/test/commands/agents-reference.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/agents/reference.ts b/packages/cli/src/commands/agents/reference.ts index 5e2b602..c7c4a74 100644 --- a/packages/cli/src/commands/agents/reference.ts +++ b/packages/cli/src/commands/agents/reference.ts @@ -13,7 +13,7 @@ const LEGACY_SKILL_DOMAINS = new Map([ ]); const DOMAINS = new Set(LEGACY_SKILL_DOMAINS.values()); const REFERENCES_BASE_URL = - 'https://raw.githubusercontent.com/datocms/agent-skills/b22e0f87d52bd1a20f146191ba760ce46616150e/skills/datocms/references/'; + 'https://raw.githubusercontent.com/datocms/agent-skills/cf3d1bd2af63c95d657fa80f7c242827c689db7f/skills/datocms/references/'; export default class Command extends BaseCommand { static hidden = true; diff --git a/packages/cli/test/commands/agents-reference.test.ts b/packages/cli/test/commands/agents-reference.test.ts index cfcbb29..acff501 100644 --- a/packages/cli/test/commands/agents-reference.test.ts +++ b/packages/cli/test/commands/agents-reference.test.ts @@ -2,7 +2,7 @@ import { runCommand } from '@oclif/test'; import { expect } from 'chai'; const BASE_URL = - 'https://raw.githubusercontent.com/datocms/agent-skills/b22e0f87d52bd1a20f146191ba760ce46616150e/skills/datocms/references/'; + 'https://raw.githubusercontent.com/datocms/agent-skills/cf3d1bd2af63c95d657fa80f7c242827c689db7f/skills/datocms/references/'; const REFERENCE_URL = `${BASE_URL}cma/editing-records.md`; describe('agents:reference', () => {