Skip to content
Open
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
152 changes: 152 additions & 0 deletions .github/scripts/lint-skill-entry.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
#!/usr/bin/env node
//
// Structural validator for skill contributions.
//
// Reuses the installer's own parser (bin/metamask-skills.mjs collectSkills /
// parseFrontmatter) so that it validates exactly what ships, rather than a
// parallel model. Errors block; warnings advise. Exits non-zero on any error.
//
// Run against the repo: node .github/scripts/lint-skill-entry.mjs
// Run against another tree: SKILLS_LINT_ROOT=/path node .github/scripts/lint-skill-entry.mjs

import { readFileSync, readdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

import { collectSkills, parseFrontmatter } from '../../bin/metamask-skills.mjs';
import {
ALLOWED_SIBLING_DIRS,
DESCRIPTION_MAX,
INSTALLED_PREFIX,
KNOWN_FRONTMATTER,
KNOWN_REPOS,
MATURITY_VALUES,
NAME_PATTERN,
RECOMMENDED_SECTIONS,
} from '../../tools/skill-schema.mjs';

const ROOT = process.env.SKILLS_LINT_ROOT
? path.resolve(process.env.SKILLS_LINT_ROOT)
: path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');

const allowedSiblings = new Set(ALLOWED_SIBLING_DIRS);
const TRUTHY = new Set(['1', 'true', 'yes', 'on']);

export function lintSkill(skill) {
const errors = [];
const warnings = [];
const dirName = skill.id.slice(skill.domain.length + 1);

let raw;
try {
raw = parseFrontmatter(readFileSync(path.join(skill.path, 'skill.md'), 'utf8'));
} catch (error) {
return { errors: [`could not read skill.md: ${error.message}`], warnings };
}

if (!raw.name) {
errors.push('missing required `name` in frontmatter');
} else {
if (raw.name !== dirName) {
errors.push(`\`name\` "${raw.name}" must match the directory "${dirName}"`);
}
if (!NAME_PATTERN.test(raw.name)) {
errors.push(`\`name\` "${raw.name}" must be kebab-case`);
}
if (raw.name.startsWith(INSTALLED_PREFIX)) {
errors.push(`source \`name\` must not carry the \`${INSTALLED_PREFIX}\` prefix; the installer adds it`);
}
}

if (!raw.description) {
errors.push('missing required `description` in frontmatter');
} else if (raw.description.length > DESCRIPTION_MAX) {
errors.push(`\`description\` is ${raw.description.length} chars, over the ${DESCRIPTION_MAX}-char operator ceiling`);
}

if (raw.maturity && !MATURITY_VALUES.includes(raw.maturity)) {
errors.push(`\`maturity\` "${raw.maturity}" must be one of: ${MATURITY_VALUES.join(', ')}`);
}
Comment on lines +67 to +69

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Validate scope and mandatory — typos silently change semantics.

OPTIONAL_FRONTMATTER includes scope and mandatory and the installer treats scope: user specially (otherwise falls back to project scope). A typo like scope: users or mandatory: ture is currently a no-op — KNOWN_FRONTMATTER accepts the key and no enum validation runs, so the skill installs in a way the author didn't intend. Same shape as the existing maturity check; warnings are sufficient if you want to keep the blocking surface small.

  if (raw.maturity && !MATURITY_VALUES.includes(raw.maturity)) {
    errors.push(`\`maturity\` "${raw.maturity}" must be one of: ${MATURITY_VALUES.join(', ')}`);
  }

  if (raw.scope !== undefined && !SCOPE_VALUES.includes(raw.scope)) {
    warnings.push(`\`scope\` "${raw.scope}" is unrecognised (known: ${SCOPE_VALUES.join(', ')}); installer falls back to project scope`);
  }

  if (raw.mandatory !== undefined && !TRUTHY.has(String(raw.mandatory).toLowerCase()) && !FALSY.has(String(raw.mandatory).toLowerCase())) {
    warnings.push(`\`mandatory\` "${raw.mandatory}" is not a recognised boolean (true|false|yes|no|1|0)`);
  }


// On-demand-only contract: a source skill must not force persistent loading.
if (raw.alwaysApply !== undefined && TRUTHY.has(String(raw.alwaysApply).toLowerCase())) {
errors.push('skills are on-demand only; remove `alwaysApply: true` (always-on guidance belongs in AGENTS.md)');
}

// Sibling directories: bundle dirs and repos/ only. knowledge/ is rejected.
let entries = [];
try {
entries = readdirSync(skill.path, { withFileTypes: true });
} catch {
// skill dir vanished mid-run; nothing to check
}
for (const entry of entries) {
if (entry.isDirectory() && !allowedSiblings.has(entry.name)) {
errors.push(`unexpected directory "${entry.name}/" beside skill.md (allowed: ${[...allowedSiblings].join(', ')}); domain knowledge belongs in references/`);
}
}

for (const repo of skill.repos) {
if (!KNOWN_REPOS.includes(repo)) {
warnings.push(`repos/${repo}.md targets an unknown consumer (known: ${KNOWN_REPOS.join(', ')})`);
}
}

for (const key of Object.keys(raw)) {
if (!KNOWN_FRONTMATTER.includes(key) && key !== 'alwaysApply') {
warnings.push(`unknown frontmatter key "${key}"; operators silently ignore unrecognised keys (typo?)`);
}
}

for (const section of RECOMMENDED_SECTIONS) {
if (!new RegExp(`^#{1,4}\\s+${section}\\b`, 'imu').test(skill.body)) {
warnings.push(`missing recommended section "## ${section}"`);
}
}
Comment on lines +101 to +105

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

polish: Recommended-section regex matches ## When To Use Cases as When To Use.

\b after a multi-word section name only requires the next character not to be a word boundary, so ## When To Use Cases satisfies When To Use. If the intent is exact, anchor to end-of-line; if prefix is fine, leave a comment explaining the choice.

  for (const section of RECOMMENDED_SECTIONS) {
    if (!new RegExp(`^#{1,4}\\s+${section}\\s*$`, 'imu').test(skill.body)) {
      warnings.push(`missing recommended section "## ${section}"`);
    }
  }


return { errors, warnings };
}

// Restrict to skills touched by the given file paths (the CI gate passes the
// PR's changed files, so pre-existing drift in untouched skills never blocks an
// unrelated change). With no paths, every skill is linted (a full audit).
function skillsForPaths(skills, paths) {
const resolved = paths.map((file) => path.resolve(ROOT, file));
return skills.filter((skill) =>
resolved.some((file) => file === skill.path || file.startsWith(`${skill.path}${path.sep}`)),
);
}

function main() {
const paths = process.argv.slice(2).filter((arg) => !arg.startsWith('-'));
const all = collectSkills([ROOT]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking): Pass an explicit repo arg to collectSkills.

collectSkills(sources, repo) works incidentally with repo undefined because the linter never reads repoApplicable. A future refactor that makes repo required will break this silently. Pass an explicit sentinel and add a one-line note, or expose a no-repo entry point in the installer.

  // Linter does not read repoApplicable; the wildcard documents intent and
  // survives a future signature where repo becomes required.
  const all = collectSkills([ROOT], '*');
  const skills = paths.length > 0 ? skillsForPaths(all, paths) : all;

const skills = paths.length > 0 ? skillsForPaths(all, paths) : all;
Comment on lines +120 to +123

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (blocking): Malformed directory shapes silently pass — the gate doesn't actually enforce domains/<domain>/skills/<name>/skill.md.

The PR description claims to enforce that layout, but main() calls collectSkills([ROOT]) first and then filters by changed paths. collectSkills only returns directories that already match the expected layout and contain a readable skill.md; anything malformed is invisible to skillsForPaths and the run prints 0 skill(s) checked, 0 error(s). All of these slip through:

domains/foo/bar/skill.md                       # missing skills/<name>/ segment
domains/foo/skills/bar/SKILL.md                # wrong filename casing
domains/foo/skills/bar/README.md               # skill.md missing entirely
domains/foo/skills/bar/references/doc.md       # changed file under skill-shaped dir, no skill.md

The last one is the worst: a changed file lives under a skill-shaped directory but the linter has no skill object to validate, so a skill.md deletion or rename inside a single PR ships unchecked.

Adding a path-level validation phase before the collectSkills filter closes this. For every changed domains/** path, derive the expected skill root and fail if (a) the path is not under domains/<domain>/skills/<name>/..., or (b) the root has no readable skill.md. Sketch:

const SKILL_PATH = /^domains\/([^/]+)\/skills\/([^/]+)\/(?:[^/]+\/)*[^/]+$/u;

function validatePathShape(file) {
  if (!file.startsWith('domains/')) return null;
  const m = SKILL_PATH.exec(file);
  if (!m) return `path "${file}" is not under domains/<domain>/skills/<name>/`;
  const [, domain, name] = m;
  const root = path.join(ROOT, 'domains', domain, 'skills', name);
  try {
    statSync(path.join(root, 'skill.md'));
  } catch {
    return `skill root "domains/${domain}/skills/${name}/" has no readable skill.md`;
  }
  return null;
}

function main() {
  const paths = process.argv.slice(2).filter((arg) => !arg.startsWith('-'));

  let errorCount = 0;
  for (const file of paths) {
    const err = validatePathShape(file);
    if (err) {
      console.log(`\nerror: ${err}`);
      errorCount += 1;
    }
  }

  const all = collectSkills([ROOT], '*');
  const skills = paths.length > 0 ? skillsForPaths(all, paths) : all;
  // ... existing per-skill loop, accumulating into errorCount ...
  process.exitCode = errorCount > 0 ? 1 : 0;
}

let errorCount = 0;
let warningCount = 0;

for (const skill of skills) {
const { errors, warnings } = lintSkill(skill);
if (errors.length > 0 || warnings.length > 0) {
console.log(`\n${skill.id}`);
for (const message of errors) {
console.log(` error: ${message}`);
}
for (const message of warnings) {
console.log(` warning: ${message}`);
}
}
errorCount += errors.length;
warningCount += warnings.length;
}

console.log(`\n${skills.length} skill(s) checked, ${errorCount} error(s), ${warningCount} warning(s).`);
// Set exitCode rather than process.exit() so buffered stdout flushes when it
// is a pipe (e.g. under CI or execFileSync), instead of being truncated.
process.exitCode = errorCount > 0 ? 1 : 0;
}

const invokedDirectly =
process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (invokedDirectly) {
main();
}
33 changes: 33 additions & 0 deletions .github/workflows/lint-skill-entry.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Lint skill entries

on:
pull_request:
paths:
- 'domains/**'
- 'tools/**'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: tools/** is in the trigger paths but never reaches the lint step.

The workflow triggers on tools/**, but the changed-files filter on line 29 lists only domains/**. So a tools/skill-schema.mjs change spins the workflow up, computes zero changed skill files, skips the lint step, and reports green — the catalogue is never re-validated against the tightened schema. Either drop tools/** from the trigger or branch on it: when tools/skill-schema.mjs is in the changed set, run a full audit (node .github/scripts/lint-skill-entry.mjs with no args) so a schema change re-checks every existing skill. The full-audit mode is what catches drift like the workflows/ directories already on main.

- '.github/scripts/lint-skill-entry.mjs'
- '.github/workflows/lint-skill-entry.yml'

permissions:
contents: read

jobs:
lint-skills:
name: Lint skill entries
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 24.x

- name: Get changed skill files
id: changed
uses: tj-actions/changed-files@v45

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (security): Pin tj-actions/changed-files to a commit SHA, or replace it.

This is the only third-party action under .github/workflows/. The repo's other workflows use MetaMask-owned actions (MetaMask/action-checkout-and-setup@v3) and the actionlint download in main.yml is pinned to a specific SHA — same posture is expected here, especially given v45's compromise history (March 2025). Replacing the action with git diff -z (see line 16) removes this concern entirely; if the action stays, pin to a SHA and add a Dependabot entry.

with:
files: domains/**

- name: Lint changed skills
if: steps.changed.outputs.any_changed == 'true'
run: node .github/scripts/lint-skill-entry.mjs ${{ steps.changed.outputs.all_changed_files }}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (blocking, security): Changed filenames are interpolated directly into a shell command.

${{ steps.changed.outputs.all_changed_files }} is space-joined and dropped into the shell. Filenames come from PR contents, so a path with spaces splits incorrectly and a path with shell metacharacters can alter the command. contents: read reduces blast radius but does not make this safe. Resolved by the suggested step on line 16 — files are read via git diff -z, passed through an env var, and parsed by mapfile/argv rather than interpolated.

      - name: Lint changed skills
        if: steps.changed.outputs.files != ''
        env:
          CHANGED_FILES: ${{ steps.changed.outputs.files }}
        run: |
          mapfile -t files <<< "$CHANGED_FILES"
          node .github/scripts/lint-skill-entry.mjs "${files[@]}"

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
"test": "node --test test/*.test.mjs",
"pack:dry-run": "yarn pack --dry-run",
"lint": "yarn lint:changelog",
"lint:changelog": "auto-changelog validate --formatter oxfmt"
"lint:changelog": "auto-changelog validate --formatter oxfmt",
"lint:skills": "node .github/scripts/lint-skill-entry.mjs"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking): lint:skills will fail on main post-merge — name signals scope.

The PR notes that the full audit currently surfaces drift (the workflows/ directories in smart-accounts-kit / x402-payments, missing maturity, etc.) and is not blocking because the CI gate is changed-files only. That means yarn lint:skills will fail on main after merge until the retrofit lands. Either rename to audit:skills (matches the catalogue audit semantics) or split into lint:skills (changed-files mode, used by CI) and audit:skills (full-catalogue mode).

    "audit:skills": "node .github/scripts/lint-skill-entry.mjs",
    "lint:skills": "node .github/scripts/lint-skill-entry.mjs --changed"

},
"publishConfig": {
"access": "public",
Expand Down
110 changes: 110 additions & 0 deletions test/lint-skill-entry.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, describe, test } from 'node:test';

const LINTER = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'.github',
'scripts',
'lint-skill-entry.mjs',
);

const roots = [];

function makeRoot() {
const root = mkdtempSync(path.join(os.tmpdir(), 'skill-lint-'));
roots.push(root);
return root;
}

afterEach(() => {
while (roots.length > 0) {
rmSync(roots.pop(), { recursive: true, force: true });
}
});

function writeSkill(root, domain, name, frontmatter, body) {
const dir = path.join(root, 'domains', domain, 'skills', name);
mkdirSync(dir, { recursive: true });
const defaultBody = '## When To Use\n\n- always\n\n## Workflow\n\n1. do the thing\n';
writeFileSync(path.join(dir, 'skill.md'), `---\n${frontmatter}\n---\n\n${body ?? defaultBody}`);
return dir;
}

function lint(root) {
const result = spawnSync(process.execPath, [LINTER], {
env: { ...process.env, SKILLS_LINT_ROOT: root },
encoding: 'utf8',
});
return { code: result.status, output: `${result.stdout}${result.stderr}` };
}
Comment on lines +39 to +45

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (blocking, test): Tests cover full-catalogue mode only; the CI-critical changed-files mode has zero coverage.

lint(root) invokes the linter with no path arguments, so every test exercises the paths.length === 0 branch (full audit). The workflow runs the other branch — node .github/scripts/lint-skill-entry.mjs <changed files...> — and that path has no tests. Combined with the structural-check gap, that is how malformed paths reach main. Add cases that mirror the CI invocation:

  • valid changed skill.md is linted
  • a changed reference file (references/foo.md) maps back to its skill root
  • malformed domains/** path fails (domains/foo/bar/skill.md)
  • a deleted/missing skill.md is reported, not silently skipped
  • filenames containing spaces survive argv handling
  • warnings-only input exits 0
  • a description of exactly DESCRIPTION_MAX passes; +1 fails
function lint(root, paths = []) {
  const result = spawnSync(process.execPath, [LINTER, ...paths], {
    env: { ...process.env, SKILLS_LINT_ROOT: root },
    encoding: 'utf8',
  });
  return { code: result.status, output: `${result.stdout}${result.stderr}` };
}

test('changed skill.md is linted (CI mode)', () => {
  const root = makeRoot();
  writeSkill(root, 'testing', 'unit-testing', 'name: unit-testing\ndescription: x');
  const file = path.join(root, 'domains/testing/skills/unit-testing/skill.md');
  assert.equal(lint(root, [file]).code, 0);
});

test('malformed changed path fails before collectSkills', () => {
  const root = makeRoot();
  const stray = path.join(root, 'domains/foo/bar/skill.md');
  mkdirSync(path.dirname(stray), { recursive: true });
  writeFileSync(stray, '---\nname: bar\ndescription: x\n---\n');
  const { code, output } = lint(root, [stray]);
  assert.equal(code, 1);
  assert.match(output, /not under domains\/<domain>\/skills/u);
});

test('description at the ceiling passes; +1 fails', () => {
  const ok = makeRoot();
  writeSkill(ok, 'testing', 'unit-testing', `name: unit-testing\ndescription: ${'x'.repeat(1024)}`);
  assert.equal(lint(ok).code, 0);
  const bad = makeRoot();
  writeSkill(bad, 'testing', 'unit-testing', `name: unit-testing\ndescription: ${'x'.repeat(1025)}`);
  assert.equal(lint(bad).code, 1);
});


describe('lint-skill-entry', () => {
test('a well-formed skill passes', () => {
const root = makeRoot();
writeSkill(root, 'testing', 'unit-testing', 'name: unit-testing\ndescription: Write unit tests');
assert.equal(lint(root).code, 0);
});

test('missing name fails', () => {
const root = makeRoot();
writeSkill(root, 'testing', 'unit-testing', 'description: x');
const { code, output } = lint(root);
assert.equal(code, 1);
assert.match(output, /missing required `name`/u);
});

test('name not matching the directory fails', () => {
const root = makeRoot();
writeSkill(root, 'testing', 'unit-testing', 'name: wrong-name\ndescription: x');
const { code, output } = lint(root);
assert.equal(code, 1);
assert.match(output, /must match the directory/u);
});

test('a knowledge/ sibling directory fails (the conversion guarantee)', () => {
const root = makeRoot();
const dir = writeSkill(root, 'perps', 'fix-bug', 'name: fix-bug\ndescription: x');
mkdirSync(path.join(dir, 'knowledge'));
const { code, output } = lint(root);
assert.equal(code, 1);
assert.match(output, /knowledge/u);
});
Comment on lines +70 to +77

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (test): Add a workflows/ sibling-directory fixture.

The current sibling-directory test uses knowledge/. The actual drift this PR's audit surfaces on main is the workflows/ directory in smart-accounts-kit and x402-payments — that's the regression the gate is meant to prevent. A test against the live failure mode is worth more than the hypothetical one. Either replace or add alongside.

  test('a workflows/ sibling directory fails (the live drift case)', () => {
    const root = makeRoot();
    const dir = writeSkill(root, 'perps', 'fix-bug', 'name: fix-bug\ndescription: x');
    mkdirSync(path.join(dir, 'workflows'));
    const { code, output } = lint(root);
    assert.equal(code, 1);
    assert.match(output, /workflows/u);
  });


test('an mms- prefix in the source name fails', () => {
const root = makeRoot();
writeSkill(root, 'testing', 'mms-unit', 'name: mms-unit\ndescription: x');
const { code, output } = lint(root);
assert.equal(code, 1);
assert.match(output, /prefix/u);
});

test('a description over the operator ceiling fails', () => {
const root = makeRoot();
writeSkill(root, 'testing', 'unit-testing', `name: unit-testing\ndescription: ${'x'.repeat(1100)}`);
const { code, output } = lint(root);
assert.equal(code, 1);
assert.match(output, /operator ceiling/u);
});

test('an invalid maturity value fails', () => {
const root = makeRoot();
writeSkill(root, 'testing', 'unit-testing', 'name: unit-testing\ndescription: x\nmaturity: beta');
const { code, output } = lint(root);
assert.equal(code, 1);
assert.match(output, /maturity/u);
});

test('alwaysApply: true fails (on-demand-only contract)', () => {
const root = makeRoot();
writeSkill(root, 'testing', 'unit-testing', 'name: unit-testing\ndescription: x\nalwaysApply: true');
const { code, output } = lint(root);
assert.equal(code, 1);
assert.match(output, /on-demand/u);
});
});
36 changes: 36 additions & 0 deletions tools/skill-schema.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Single source of truth for the skill contribution schema.
//
// Imported by the lint-skill-entry validator so that the documented schema and
// the enforced schema cannot drift apart. The installer's bundle-directory list
// in tools/install (Bash) mirrors BUNDLE_DIRS; keep the two in sync.

export const REQUIRED_FRONTMATTER = ['name', 'description'];
export const OPTIONAL_FRONTMATTER = ['maturity', 'mandatory', 'scope'];
export const KNOWN_FRONTMATTER = [...REQUIRED_FRONTMATTER, ...OPTIONAL_FRONTMATTER];

export const MATURITY_VALUES = ['experimental', 'stable', 'deprecated'];

// Directories the installer copies alongside skill.md (see tools/install).
export const BUNDLE_DIRS = ['references', 'scripts', 'assets', 'adapters'];
Comment on lines +13 to +14

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking): Guard BUNDLE_DIRStools/install against silent drift.

BUNDLE_DIRS mirrors a hard-coded Bash list in tools/install (the loop that copies references scripts assets adapters). The header comment notes the manual sync, which is fine — but a 5-line node test that greps the bash list and asserts equality with BUNDLE_DIRS.join(' ') would catch silent drift in the same spirit as making the schema the single source.

test('tools/install bundle list matches BUNDLE_DIRS', () => {
  const install = readFileSync(path.resolve(REPO, 'tools/install'), 'utf8');
  const m = /for bundle in ([a-z ]+); do/u.exec(install);
  assert.ok(m, 'could not find bundle loop in tools/install');
  assert.equal(m[1].trim(), BUNDLE_DIRS.join(' '));
});


// Directories allowed beside skill.md: the bundle dirs plus the repo-overlay
// dir. Anything else (notably knowledge/) is rejected, because the installer
// does not ship it and the reference would dangle post-install.
export const ALLOWED_SIBLING_DIRS = [...BUNDLE_DIRS, 'repos'];

export const KNOWN_REPOS = ['metamask-extension', 'metamask-mobile', 'core'];

// The description is always loaded into the operator's discovery surface, so it
// is the per-skill always-on cost. The ceiling is the per-operator minimum
// (OpenCode caps description at 1024), so a description that passes here is
// accepted by every target.
export const DESCRIPTION_MAX = 1024;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (non-blocking, docs): README.md documents ≤1,536 chars but this enforces 1024.

DESCRIPTION_MAX = 1024 is the OpenCode minimum and the right ceiling, but the authoring section in README.md (line 369) still says descriptions may be ≤1,536 chars. PR #46 has authors return to the README as canonical, so a contributor following the docs can land a skill that this validator rejects. Either update README.md here (one-line change to align with DESCRIPTION_MAX) or land the docs reconciliation before enabling the gate.


export const RECOMMENDED_SECTIONS = ['When To Use', 'Workflow'];

// kebab-case, matching the name regex Claude Code and OpenCode require.
export const NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/u;

// The installer prepends this to generated output names; source names must not
// carry it.
export const INSTALLED_PREFIX = 'mms-';
Loading