Compare commits

..

4 Commits

Author SHA1 Message Date
paoloricciuti
50ddc0c015 fix: remove unneeded frontmatter 2026-03-09 17:41:31 +01:00
paoloricciuti
6b881bd607 fix: bash script 2026-03-09 17:41:17 +01:00
paoloricciuti
7f328bcfa9 fix: remove skill: true from frontmatter 2026-03-09 17:19:10 +01:00
paoloricciuti
1a63280c49 feat: add sync skill docs 2026-03-07 10:17:51 +01:00
13 changed files with 433 additions and 185 deletions

View File

@@ -1,5 +0,0 @@
---
'@sveltejs/mcp': patch
---
feat: display similar result & error at the end

View File

@@ -1,5 +0,0 @@
---
"@sveltejs/opencode": patch
---
feat(opencode): mcp enabled option is passed to opencode

View File

@@ -1,5 +0,0 @@
---
'@sveltejs/opencode': patch
---
feat: allow enabling a specific skill in opencode plugin

116
.github/workflows/sync-docs-skills.yml vendored Normal file
View File

@@ -0,0 +1,116 @@
name: Sync Skills
on:
workflow_dispatch:
permissions:
contents: write
pull-requests: write
actions: write
jobs:
sync-skills:
if: github.repository == 'sveltejs/ai-tools'
name: Sync skills from svelte.dev
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 24
package-manager-cache: false # pnpm is not installed yet
- name: Install pnpm
shell: bash
run: |
PNPM_VER=$(jq -r '.packageManager | if .[0:5] == "pnpm@" then .[5:] else "packageManager in package.json does not start with pnpm@\n" | halt_error(1) end' package.json)
echo installing pnpm version "$PNPM_VER"
npm i -g "pnpm@$PNPM_VER"
- name: Setup Node.js with pnpm cache
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 24
package-manager-cache: true # caches pnpm via packageManager field in package.json
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts
- name: Clone svelte.dev
run: git clone --depth 2 https://github.com/sveltejs/svelte.dev.git "${{ runner.temp }}/svelte.dev"
- name: Discover changed skill files
id: discover
env:
SVELTE_DEV_ROOT: ${{ runner.temp }}/svelte.dev
run: |
skill_files=$(git -C "$SVELTE_DEV_ROOT" diff --name-only --diff-filter=ACMR HEAD~1 HEAD | grep '^apps/svelte.dev/content/docs/.*\.md$' | xargs -I{} grep -l '^skill: *true' "$SVELTE_DEV_ROOT/{}" || true)
echo "skill_files=$skill_files" >> "$GITHUB_OUTPUT"
- name: Sync skills
if: steps.discover.outputs.skill_files != ''
env:
SVELTE_DEV_ROOT: ${{ runner.temp }}/svelte.dev
DOCS_PREFIX: apps/svelte.dev/content/docs/
run: |
for full_path in ${{ steps.discover.outputs.skill_files }}; do
file="${full_path#$SVELTE_DEV_ROOT/}"
name=$(grep '^name: ' "$full_path" | head -1 | sed 's/^name: *//')
repo="${file#$DOCS_PREFIX}"
repo="${repo#/}"
repo="${repo%%/*}"
output_dir="tools/skills/$name"
rm -rf "$output_dir"
mkdir -p "$output_dir"
pnpm resolve-references --file "$full_path" --repo "$repo" --output "$output_dir"
done
- name: Sync plugins
if: steps.discover.outputs.skill_files != ''
run: |
pnpm sync-claude-plugin
pnpm sync-cursor-plugin
pnpm sync-opencode-plugin
pnpm generate-skill-docs
pnpm bump-plugin-versions
- name: Check for changes
id: git-check
run: |
git diff --exit-code -- tools/skills/ plugins/ packages/opencode/ documentation/docs/ || echo "changed=true" >> "$GITHUB_OUTPUT"
- name: Create Pull Request
if: steps.git-check.outputs.changed == 'true'
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: 'chore: sync skills from svelte.dev'
branch: chore/sync-skills
delete-branch: true
title: 'chore: sync skills from svelte.dev'
body: |
## Summary
Automatically synced skill markdown from `sveltejs/svelte.dev` into `tools/skills/`.
## Changes
- Cloned `sveltejs/svelte.dev`
- Filtered markdown files with `skill: true` frontmatter
- Rebuilt synced skill folders with `scripts/resolve-references.ts`
- Synced `plugins/claude/svelte/` (skills, agents)
- Synced `plugins/cursor/svelte/` (skills, agents, rules)
- Synced `packages/opencode/` (skills, instructions)
- Updated documentation
## Generated by
GitHub Action: Sync Skills
labels: |
chore
automated

View File

@@ -53,7 +53,7 @@ jobs:
run: pnpm sync-cursor-plugin
- name: Sync OpenCode plugin
run: pnpm sync-opencode-plugin && pnpm generate-opencode-jsonschema
run: pnpm sync-opencode-plugin
- name: Generate skills documentation
run: pnpm generate-skill-docs
@@ -69,7 +69,6 @@ jobs:
plugins/cursor/svelte/ \
packages/opencode/skills/ \
packages/opencode/instructions/ \
packages/opencode/schema.json \
documentation/docs/ \
|| echo "changed=true" >> $GITHUB_OUTPUT
@@ -91,7 +90,7 @@ jobs:
## Changes
- Synced `plugins/claude/svelte/` (skills, agents with `permissionMode`)
- Synced `plugins/cursor/svelte/` (skills, agents, rules)
- Synced `packages/opencode/` (skills, instructions, schema)
- Synced `packages/opencode/` (skills, instructions)
- Updated documentation
## Generated by

View File

@@ -32,7 +32,7 @@ The default configuration for the Svelte OpenCode plugin looks like this...
"enabled": true
},
"skills": {
"enabled": true // it can also be an array of all the skills to enable like ['svelte-core-bestpractices']
"enabled": true
},
"instructions": {
"enabled": true
@@ -40,6 +40,6 @@ The default configuration for the Svelte OpenCode plugin looks like this...
}
```
...but if you prefer, you can enable only the subagent, only the MCP, only the skills (`enabled` supports both a boolean or an array containing the name of all the skills to enable), or configure the kind of MCP server you want to use (`local` or `remote`).
...but if you prefer, you can enable only the subagent, only the MCP, only the skills, or configure the kind of MCP server you want to use (`local` or `remote`).
You can place this file in `./.opencode/svelte.json` (in your project), in `~/.config/opencode/svelte.json` or, if you have an `OPENCODE_CONFIG_DIR` environment variable specified, at `$OPENCODE_CONFIG_DIR/svelte.json`.

View File

@@ -28,7 +28,9 @@
"sync-claude-plugin": "node scripts/sync-claude-plugin.ts",
"sync-cursor-plugin": "node scripts/sync-cursor-plugin.ts",
"sync-opencode-plugin": "node scripts/sync-opencode-plugin.ts",
"bump-plugin-versions": "node scripts/bump-plugin-versions.ts"
"bump-plugin-versions": "node scripts/bump-plugin-versions.ts",
"resolve-references": "node scripts/resolve-references.ts",
"postresolve-references": "pnpm format"
},
"keywords": [
"svelte",

View File

@@ -91,56 +91,16 @@ export async function get_documentation_handler({
}
});
const successes = results.filter((r) => r.success);
const failed_sections = sections.filter(
(s) =>
!available_sections.some(
(a) => a.title.toLowerCase() === s.toLowerCase() || a.slug === s || a.url === s,
),
);
const has_any_success = results.some((result) => result.success);
let final_text = results.map((r) => r.content).join('\n\n---\n\n');
if (successes.length > 0 && failed_sections.length === 0) {
return successes.map((r) => r.content).join('\n\n---\n\n');
}
const parts: string[] = [];
if (successes.length > 0) {
parts.push(successes.map((r) => r.content).join('\n\n---\n\n'));
}
const fuzzy_results = failed_sections.map((requested) => {
const lower = requested.toLowerCase();
const matches = available_sections.filter(
(a) =>
a.title.toLowerCase().includes(lower) ||
a.slug.includes(lower) ||
lower.includes(a.slug.split('/').pop() ?? '') ||
a.use_cases.toLowerCase().includes(lower),
);
return { requested, matches };
});
const has_fuzzy = fuzzy_results.some((r) => r.matches.length > 0);
// Full list only when no successes and no fuzzy matches
if (successes.length === 0 && !has_fuzzy) {
if (!has_any_success) {
const formatted_sections = await format_sections_list();
parts.push(`${SECTIONS_LIST_INTRO}\n\n${formatted_sections}\n\n${SECTIONS_LIST_OUTRO}`);
final_text += `\n\n---\n\n${SECTIONS_LIST_INTRO}\n\n${formatted_sections}\n\n${SECTIONS_LIST_OUTRO}`;
}
// Similar results then errors
for (const { requested, matches } of fuzzy_results) {
if (matches.length > 0) {
const match_list = matches.map((m) => `- title: ${m.title}, section: ${m.slug}`).join('\n');
parts.push(
`${matches.length} similar result${matches.length > 1 ? 's' : ''} for "${requested}":\n${match_list}`,
);
}
parts.push(`Section not found: "${requested}".`);
}
return parts.join('\n\n---\n\n');
return final_text;
}
export function get_documentation(server: SvelteMcp) {

View File

@@ -16,57 +16,31 @@ const default_config = {
enabled: true,
},
skills: {
enabled: true as boolean | string[],
enabled: true,
},
};
export const config_schema = v.object({
mcp: v.pipe(
v.optional(
v.object({
type: v.optional(v.picklist(['remote', 'local'])),
enabled: v.optional(v.boolean()),
}),
),
v.description(
"Configuration for the MCP. You can chose if it should be enabled or not and the transport to use 'remote' (default) and 'local'.",
),
mcp: v.optional(
v.object({
type: v.optional(v.picklist(['remote', 'local'])),
enabled: v.optional(v.boolean()),
}),
),
subagent: v.pipe(
v.optional(
v.object({
enabled: v.optional(v.boolean()),
}),
),
v.description('Configuration for the subagent. You can choose if it should be enabled or not.'),
subagent: v.optional(
v.object({
enabled: v.optional(v.boolean()),
}),
),
instructions: v.pipe(
v.optional(
v.object({
enabled: v.optional(v.boolean()),
}),
),
v.description(
'Configuration for the automatic AGENTS.md injection. You can choose if it should be enabled or not.',
),
v.description(
'Configuration for the automatic AGENTS.md injection. You can choose if it should be enabled or not.',
),
instructions: v.optional(
v.object({
enabled: v.optional(v.boolean()),
}),
),
skills: v.pipe(
v.optional(
v.object({
enabled: v.pipe(
v.optional(v.union([v.boolean(), v.array(v.string())])),
v.description(
'It can be either a boolean or an array containing the skills that you want to enable',
),
),
}),
),
v.description(
'Configuration for the skills. You can choose if it they should be enabled or not, or specify an array of skill names to enable only specific skills.',
),
skills: v.optional(
v.object({
enabled: v.optional(v.boolean()),
}),
),
});

View File

@@ -39,35 +39,23 @@ export const svelte_plugin: Plugin = async (ctx) => {
input.instructions.push(...instructions_paths.map((file) => join(instructions_dir, file)));
}
const skills_enabled = mcp_config.skills?.enabled;
if (skills_enabled !== false) {
if (mcp_config.skills?.enabled !== false) {
const skills_dir = join(current_dir, 'skills');
if (Array.isArray(skills_enabled)) {
// only add specific skill directories by name
for (const skill_name of skills_enabled) {
const skill_path = join(skills_dir, skill_name);
// @ts-expect-error -- skills is a new opencode feature
input.skills.paths.push(skill_path);
}
} else {
// @ts-expect-error -- skills is a new opencode feature
input.skills.paths.push(skills_dir);
}
// @ts-expect-error -- skills is a new opencode feature
input.skills.paths.push(skills_dir);
}
// if the user doesn't have the MCP server already we add one based on config
if (!input.mcp[svelte_mcp_name]) {
if (!input.mcp[svelte_mcp_name] && mcp_config.mcp?.enabled !== false) {
if (mcp_config.mcp?.type === 'remote') {
input.mcp[svelte_mcp_name] = {
type: 'remote',
url: 'https://mcp.svelte.dev/mcp',
enabled: mcp_config.mcp?.enabled ?? true,
};
} else {
input.mcp[svelte_mcp_name] = {
type: 'local',
command: ['npx', '-y', '@sveltejs/mcp'],
enabled: mcp_config.mcp?.enabled ?? true,
};
}
}

View File

@@ -14,8 +14,7 @@
"type": "boolean"
}
},
"required": [],
"description": "Configuration for the MCP. You can chose if it should be enabled or not and the transport to use 'remote' (default) and 'local'."
"required": []
},
"subagent": {
"type": "object",
@@ -24,8 +23,7 @@
"type": "boolean"
}
},
"required": [],
"description": "Configuration for the subagent. You can choose if it should be enabled or not."
"required": []
},
"instructions": {
"type": "object",
@@ -34,39 +32,16 @@
"type": "boolean"
}
},
"required": [],
"description": "Configuration for the automatic AGENTS.md injection. You can choose if it should be enabled or not."
"required": []
},
"skills": {
"type": "object",
"properties": {
"enabled": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "array",
"items": {
"anyOf": [
{
"enum": [
"svelte-code-writer",
"svelte-core-bestpractices"
]
},
{
"type": "string"
}
]
}
}
],
"description": "It can be either a boolean or an array containing the skills that you want to enable"
"type": "boolean"
}
},
"required": [],
"description": "Configuration for the skills. You can choose if it they should be enabled or not, or specify an array of skill names to enable only specific skills."
"required": []
}
},
"required": [],

View File

@@ -3,33 +3,6 @@ import { config_schema } from '../config.js';
import fs from 'node:fs';
import path from 'node:path';
function get_skill_names(skills_dir: string) {
if (!fs.existsSync(skills_dir)) return [];
return fs
.readdirSync(skills_dir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name);
}
const skills_dir = path.resolve('./skills');
const skill_names = get_skill_names(skills_dir);
const schema = config_schema;
const json_schema = toJsonSchema(schema);
// Post-process: inject skill name suggestions into the items schema.
// This is the JSON Schema equivalent of `"a" | "b" | (string & {})` —
// editors will autocomplete the known names but any string is still valid.
if (skill_names.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const enabled = (json_schema as any).properties?.skills?.properties?.enabled;
if (enabled?.anyOf) {
const array_branch = enabled.anyOf.find((s: Record<string, unknown>) => s.type === 'array');
if (array_branch) {
array_branch.items = {
anyOf: [{ enum: skill_names }, { type: 'string' }],
};
}
}
}
const json_schema = toJsonSchema(config_schema);
fs.writeFileSync(path.resolve('./schema.json'), JSON.stringify(json_schema, null, '\t'));

View File

@@ -0,0 +1,276 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { parseArgs } from 'node:util';
const { values } = parseArgs({
options: {
file: { type: 'string', short: 'f' },
repo: { type: 'string', short: 'r' },
output: { type: 'string', short: 'o' },
},
});
const { file, repo, output } = values;
if (!file || !repo || !output) {
console.error(
'Usage: resolve-references --file <path-or-content> --repo <repo> --output <folder>',
);
process.exit(1);
}
export function remove_llm_ignore_blocks(content: string): string {
return content.replace(/<!--\s*llm-ignore-start\s*-->[\s\S]*?<!--\s*llm-ignore-end\s*-->/g, '');
}
/**
* Determines whether the input string is a file path or raw markdown content.
* If it's a file, reads and returns its content. Otherwise returns the string as-is.
*/
async function get_content(input: string) {
try {
const stat = await fs.stat(input);
if (stat.isFile()) {
return await fs.readFile(input, 'utf-8');
}
} catch {
// not a file path — treat as raw content
}
return input;
}
/**
* Extracts a section from markdown content based on a heading id (hash).
* Finds the heading whose text (lowercased, spaces replaced with `-`) matches
* the hash and returns everything from that heading up to the next heading of
* the same or higher level.
*/
function extract_section(content: string, hash: string) {
const lines = content.split('\n');
let start_index = -1;
let heading_level = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
const heading_match = line.match(/^(#{1,6})\s+(.+)/);
if (!heading_match) continue;
const level = heading_match[1]!.length;
const text = heading_match[2]!;
const slug = text.toLowerCase().replace(/\s+/g, '-');
if (slug === hash.toLowerCase()) {
start_index = i;
heading_level = level;
continue;
}
if (start_index !== -1 && level <= heading_level) {
return lines.slice(start_index, i).join('\n').trim();
}
}
if (start_index !== -1) {
return lines.slice(start_index).join('\n').trim();
}
return content;
}
/**
* Removes the `title`, `skill`, and `NOTE` fields from markdown frontmatter, if present.
* Removes the entire frontmatter block if they were the only fields.
*/
function remove_frontmatter_unneeded_fields(content: string) {
const frontmatter_match = content.match(/^---\n([\s\S]*?)\n---\n?/);
if (!frontmatter_match) return content;
const frontmatter = frontmatter_match[1]!;
const lines = frontmatter.split('\n').filter((line) => !line.match(/^(title|skill|NOTE)\s*:/));
if (lines.length === 0) {
// frontmatter is now empty — remove the whole block
return content.slice(frontmatter_match[0].length);
}
return `---\n${lines.join('\n')}\n---\n` + content.slice(frontmatter_match[0].length);
}
/**
* Derives a file-safe name from a URL path segment.
* e.g. "some/deep/path" -> "path"
*/
function derive_name(link: string) {
const without_hash = link.split('#')[0]!;
const segments = without_hash.split('/').filter(Boolean);
return segments[segments.length - 1] ?? 'reference';
}
const content = remove_llm_ignore_blocks(
remove_frontmatter_unneeded_fields(await get_content(file)),
);
// Match markdown links that are either:
// 1. Relative paths (not starting with http://, https://, mailto:, #, or /)
// 2. Absolute /docs/ paths (e.g. /docs/svelte/each)
const relative_link_regex = /\[([^\]]*)\]\((?!https?:\/\/|mailto:|#|\/)([^)]+)\)/g;
const docs_link_regex = /\[([^\]]*)\]\((\/docs\/[^)]+)\)/g;
interface Link_Info {
full_match: string;
text: string;
href: string;
hash: string | undefined;
clean_path: string;
is_absolute_docs: boolean;
}
const links: Link_Info[] = [];
let match;
while ((match = relative_link_regex.exec(content)) !== null) {
const href = match[2]!;
const hash_index = href.indexOf('#');
const has_hash = hash_index !== -1;
links.push({
full_match: match[0],
text: match[1]!,
href,
hash: has_hash ? href.slice(hash_index + 1) : undefined,
clean_path: has_hash ? href.slice(0, hash_index) : href,
is_absolute_docs: false,
});
}
while ((match = docs_link_regex.exec(content)) !== null) {
const href = match[2]!;
const hash_index = href.indexOf('#');
const has_hash = hash_index !== -1;
links.push({
full_match: match[0],
text: match[1]!,
href,
hash: has_hash ? href.slice(hash_index + 1) : undefined,
clean_path: has_hash ? href.slice(0, hash_index) : href,
is_absolute_docs: true,
});
}
if (links.length === 0) {
console.log('No relative links found in the markdown.');
process.exit(0);
}
console.log(`Found ${links.length} relative link(s) to resolve.`);
const references_dir = path.join(output, 'references');
await fs.mkdir(references_dir, { recursive: true });
let updated_content = content;
// Track names we've already used to avoid collisions
const used_names = new Map<string, number>();
for (const link of links) {
const base_name = derive_name(link.clean_path);
const count = used_names.get(base_name) ?? 0;
used_names.set(base_name, count + 1);
const name = count > 0 ? `${base_name}-${count}` : base_name;
// For absolute /docs/ links, fetch directly from svelte.dev (supports cross-repo links).
// For relative links, prepend /docs/{repo}/.
const url = link.is_absolute_docs
? `https://svelte.dev${link.clean_path}/llms.txt`
: `https://svelte.dev/docs/${repo}/${link.clean_path}/llms.txt`;
console.log(`Fetching: ${url}${link.hash ? ` (section: #${link.hash})` : ''}`);
try {
const response = await fetch(url);
if (!response.ok) {
console.warn(` Warning: ${response.status} ${response.statusText} for ${url}`);
continue;
}
let fetched_content = await response.text();
if (link.hash) {
fetched_content = extract_section(fetched_content, link.hash);
}
const ref_filename = `${name}.md`;
const ref_path = path.join(references_dir, ref_filename);
await fs.writeFile(ref_path, remove_llm_ignore_blocks(remove_cut_preambles(fetched_content)));
console.log(` Saved: references/${ref_filename}`);
// Replace the link in the markdown
const new_link = `[${link.text}](references/${ref_filename})`;
updated_content = updated_content.replace(link.full_match, new_link);
} catch (error) {
console.warn(` Error fetching ${url}:`, error);
}
}
/**
* In fenced code blocks, removes everything from the start of the block
* up to and including a `// ---cut---` comment. If no such comment exists
* the code block is left unchanged.
*/
function remove_cut_preambles(content: string) {
const lines = content.split('\n');
const result: string[] = [];
let in_code_block = false;
let code_block_buffer: string[] = [];
let fence_line = '';
for (const line of lines) {
if (!in_code_block && line.match(/^```\w*$/)) {
in_code_block = true;
fence_line = line;
code_block_buffer = [];
continue;
}
if (in_code_block && line.match(/^```$/)) {
// End of code block — check if there was a cut comment
const cut_index = code_block_buffer.findIndex((l) => l.match(/^\s*\/\/\s*---cut---\s*$/));
result.push(fence_line);
if (cut_index !== -1) {
result.push(...code_block_buffer.slice(cut_index + 1));
} else {
result.push(...code_block_buffer);
}
result.push(line);
in_code_block = false;
code_block_buffer = [];
continue;
}
if (in_code_block) {
code_block_buffer.push(line);
} else {
result.push(line);
}
}
// If file ends mid-code-block, flush as-is
if (in_code_block) {
result.push(fence_line);
result.push(...code_block_buffer);
}
return result.join('\n');
}
// Write the updated markdown content to the output folder
updated_content = remove_cut_preambles(updated_content);
const output_filename = path.join(output, 'SKILL.md');
await fs.writeFile(output_filename, updated_content);
console.log(`\nUpdated markdown written to: ${output_filename}`);