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/.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 00235af..c7c4a74 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/refs/heads/master/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/cf3d1bd2af63c95d657fa80f7c242827c689db7f/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 new file mode 100644 index 0000000..acff501 --- /dev/null +++ b/packages/cli/test/commands/agents-reference.test.ts @@ -0,0 +1,155 @@ +import { runCommand } from '@oclif/test'; +import { expect } from 'chai'; + +const BASE_URL = + 'https://raw.githubusercontent.com/datocms/agent-skills/cf3d1bd2af63c95d657fa80f7c242827c689db7f/skills/datocms/references/'; +const REFERENCE_URL = `${BASE_URL}cma/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 = (skill = 'datocms', name = 'cma/editing-records') => + runCommand(['agents:reference', skill, name], { + 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('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, JSON.stringify(args)).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 "cma/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 "cma/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 "cma/editing-records": Connection failed', + ); + }); +}); 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'); + }); });