Compare commits

..

16 Commits

Author SHA1 Message Date
paoloricciuti
6bbecaaf05 chore: update docs and schema 2026-03-10 23:16:59 +01:00
paoloricciuti
91b89d3191 fix: expand to other agents props and remove unneeded variant 2026-03-10 23:13:30 +01:00
paoloricciuti
f0754909d9 fix: better type 2026-03-10 17:44:50 +01:00
paoloricciuti
d2711aa57d fix: regenerate schema 2026-03-10 17:41:45 +01:00
paoloricciuti
2071d91e54 Merge remote-tracking branch 'origin/main' into fix/merge-user-svelte-agent-config 2026-03-10 17:40:56 +01:00
Paolo Ricciuti
5cd99d8234 feat: allow enabling a specific skill in opencode plugin (#174) 2026-03-10 17:38:04 +01:00
Paolo Ricciuti
024dd1f640 chore: delete bun.lock 2026-03-10 11:21:24 +01:00
Paolo Ricciuti
e9f19199cb feat: add sync skill docs (#172) 2026-03-09 17:49:18 +01:00
Momochiro
a83d466155 refactor: move agent config inside subagent.agents
Consolidates agent configuration under subagent.agents to avoid
confusion between separate 'subagent' and 'agent' config keys.
Users now configure agents via subagent.agents instead of a
top-level agent key.
2026-03-08 18:01:27 +01:00
Momochiro
94953dd315 refactor: read agent names dynamically from tools/agents folder
Instead of hardcoding the list of known agents, discover them by reading
.md files from the tools/agents directory. This follows the same pattern
used for skill discovery and makes it easier to add new agents.
2026-03-08 17:56:56 +01:00
Momochiro
c4e3cf1cf7 fix: agent config only from svelte.json, not opencode.json
svelte.json is the sole source of truth for svelte agent configuration.
2026-03-08 10:02:57 +01:00
Momochiro
bc1e6b1583 fix: give svelte.json agent config priority over opencode.json
svelte.json is the canonical place for svelte-specific configuration,
so it should take precedence over any agent config in opencode.json.
2026-03-08 10:01:38 +01:00
Momochiro
e718b6fe7f fix: make agent config description generic
Don't mention specific agent names in the description, similar to how
skills schema doesn't mention specific skill names. Intellisense for
known agents is still provided via the schema generator.
2026-03-08 09:20:21 +01:00
Momochiro
d62a0982ae fix: remove unnecessary skills logic from schema generator
Only agent configuration is being added, not skills.
Took inspiration from PR #174 for the intellisense pattern only.
2026-03-08 08:48:28 +01:00
Momochiro
9eb7cf834b feat: add model and variant support to svelte.json config
Allows users to configure model and variant for the svelte-file-editor
agent directly in svelte.json instead of opencode.json.

Users can now set:
{
  "model": "anthropic/claude-sonnet-4-20250514",
  "variant": "coding"
}

in ~/.config/opencode/svelte.json or project-local .opencode/svelte.json

Fixes #175
2026-03-07 21:47:38 +01:00
Momochiro
04877c1bb9 fix: merge user-configured svelte-file-editor agent settings
Previously, the plugin unconditionally overwrote the svelte-file-editor
agent configuration, preventing users from customizing settings like
model, variant, or other agent properties.

Now user configuration is merged with plugin defaults, with user config
taking precedence. This allows users to override model/variant in their
opencode.json:

{
  "agent": {
    "svelte-file-editor": {
      "model": "anthropic/claude-sonnet-4-20250514",
      "variant": "coding"
    }
  }
}

Fixes #175
2026-03-07 20:16:37 +01:00
8 changed files with 522 additions and 8 deletions

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

@@ -29,7 +29,15 @@ The default configuration for the Svelte OpenCode plugin looks like this...
"enabled": true
},
"subagent": {
"enabled": true
"enabled": true,
"agents": {
"svelte-file-editor": {
"model": "other-model", // defaults to the same as main agent,
"temperature": 1, // default to unset
"top_p": 0.7, // default to unset,
"maxSteps": 20 // default to unlimited
}
}
},
"skills": {
"enabled": true // it can also be an array of all the skills to enable like ['svelte-core-bestpractices']

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

@@ -4,6 +4,28 @@ import { homedir } from 'os';
import { join } from 'path';
import * as v from 'valibot';
// Schema for individual agent configuration
const agent_config_schema = v.object({
model: v.pipe(
v.optional(v.string()),
v.description('Model identifier for the agent (e.g., "anthropic/claude-sonnet-4-20250514")'),
),
temperature: v.pipe(
v.optional(v.number()),
v.description('Temperature setting for the agent (e.g., 0.7)'),
),
top_p: v.pipe(
v.optional(v.number()),
v.description(
'Control response diversity with the top_p option. Alternative to temperature for controlling randomness.',
),
),
maxSteps: v.pipe(
v.optional(v.number()),
v.description('Maximum number of steps the agent can take (e.g., 10)'),
),
});
const default_config = {
mcp: {
type: 'remote' as 'remote' | 'local',
@@ -11,6 +33,7 @@ const default_config = {
},
subagent: {
enabled: true,
agents: {} as Record<string, v.InferInput<typeof agent_config_schema>>,
},
instructions: {
enabled: true,
@@ -36,6 +59,7 @@ export const config_schema = v.object({
v.optional(
v.object({
enabled: v.optional(v.boolean()),
agents: v.optional(v.record(v.string(), agent_config_schema)),
}),
),
v.description('Configuration for the subagent. You can choose if it should be enabled or not.'),
@@ -49,9 +73,6 @@ export const config_schema = v.object({
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.',
),
),
skills: v.pipe(
v.optional(
@@ -138,8 +159,12 @@ function merge_with_defaults(user_config: Partial<McpConfig>): McpConfig {
...user_config.mcp,
},
subagent: {
...default_config.subagent,
enabled: default_config.subagent.enabled,
...user_config.subagent,
agents: {
...default_config.subagent.agents,
...user_config.subagent?.agents,
},
},
instructions: {
...default_config.instructions,
@@ -177,7 +202,11 @@ export function get_mcp_config(ctx: PluginInput) {
if (parsed.success) {
merged = {
mcp: { ...merged.mcp, ...parsed.output.mcp },
subagent: { ...merged.subagent, ...parsed.output.subagent },
subagent: {
...merged.subagent,
...parsed.output.subagent,
agents: { ...merged.subagent?.agents, ...parsed.output.subagent?.agents },
},
instructions: { ...merged.instructions, ...parsed.output.instructions },
skills: { ...merged.skills, ...parsed.output.skills },
};

View File

@@ -73,7 +73,7 @@ export const svelte_plugin: Plugin = async (ctx) => {
}
if (mcp_config.subagent?.enabled !== false) {
// we add the editor subagent that will be used when editing Svelte files to prevent wasting context on the main agent
input.agent['svelte-file-editor'] = {
const default_config: (typeof input.agent)[string] = {
color: '#ff3e00',
mode: 'subagent',
prompt: `You are a Svelte 5 expert responsible for writing, editing, and validating Svelte components and modules. You have access to the Svelte MCP server which provides documentation and code analysis tools. Always use the tools from the svelte MCP server to fetch documentation with \`get_documentation\` and validating the code with \`svelte_autofixer\`. If the autofixer returns any issue or suggestions try to solve them.
@@ -150,6 +150,27 @@ After completing your work, provide:
[`${svelte_mcp_name}_*`]: true,
},
};
// Get per-agent config from svelte.json (if any)
const svelte_file_editor_config = mcp_config.subagent?.agents?.['svelte-file-editor'];
// Configure agent from svelte.json only
// Priority: svelte.json agent config > defaults
input.agent['svelte-file-editor'] = {
...default_config,
...(svelte_file_editor_config?.model !== undefined && {
model: svelte_file_editor_config.model,
}),
...(svelte_file_editor_config?.temperature !== undefined && {
temperature: svelte_file_editor_config.temperature,
}),
...(svelte_file_editor_config?.maxSteps !== undefined && {
maxSteps: svelte_file_editor_config.maxSteps,
}),
...(svelte_file_editor_config?.top_p !== undefined && {
top_p: svelte_file_editor_config.top_p,
}),
};
}
},
};

View File

@@ -22,6 +22,43 @@
"properties": {
"enabled": {
"type": "boolean"
},
"agents": {
"type": "object",
"propertyNames": {
"anyOf": [
{
"enum": [
"svelte-file-editor"
]
},
{
"type": "string"
}
]
},
"additionalProperties": {
"type": "object",
"properties": {
"model": {
"type": "string",
"description": "Model identifier for the agent (e.g., \"anthropic/claude-sonnet-4-20250514\")"
},
"temperature": {
"type": "number",
"description": "Temperature setting for the agent (e.g., 0.7)"
},
"top_p": {
"type": "number",
"description": "Control response diversity with the top_p option. Alternative to temperature for controlling randomness."
},
"maxSteps": {
"type": "number",
"description": "Maximum number of steps the agent can take (e.g., 10)"
}
},
"required": []
}
}
},
"required": [],

View File

@@ -3,6 +3,15 @@ import { config_schema } from '../config.js';
import fs from 'node:fs';
import path from 'node:path';
// Read agent names from tools/agents/*.md files
function get_agent_names(agents_dir: string) {
if (!fs.existsSync(agents_dir)) return [];
return fs
.readdirSync(agents_dir, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
.map((entry) => entry.name.replace(/\.md$/, ''));
}
function get_skill_names(skills_dir: string) {
if (!fs.existsSync(skills_dir)) return [];
return fs
@@ -32,4 +41,20 @@ if (skill_names.length > 0) {
}
}
// Post-process: inject known agent names for intellisense
// This is the JSON Schema equivalent of `"a" | "b" | (string & {})` —
// editors will autocomplete the known names but any string is still valid.
const agents_dir = path.resolve('../../tools/agents');
const agent_names = get_agent_names(agents_dir);
if (agent_names.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const agents = (json_schema as any).properties?.subagent?.properties?.agents;
if (agents) {
agents.propertyNames = {
anyOf: [{ enum: agent_names }, { type: 'string' }],
};
}
}
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}`);