Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/pin-reference-snapshot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'datocms': patch
---

Pin reference downloads to an immutable revision so documentation remains available when upstream files move.
5 changes: 5 additions & 0 deletions .changeset/unified-reference-lookup.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<domain>/<name>` 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
Expand Down
58 changes: 49 additions & 9 deletions packages/cli/src/commands/agents/reference.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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,
}),
};
Expand All @@ -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' : '"<domain>/<name>"'
} with ${
legacyDomain ? '' : `domain one of ${[...DOMAINS].join(', ')} and `
}name matching ${NAME_PATTERN}`,
);
}

const url = `${REFERENCES_BASE_URL}${referencePath}.md`;

let response: Response;
try {
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/cma/script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
155 changes: 155 additions & 0 deletions packages/cli/test/commands/agents-reference.test.ts
Original file line number Diff line number Diff line change
@@ -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<Response>;

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',
);
});
});
11 changes: 8 additions & 3 deletions packages/cli/test/commands/help.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});