diff --git a/CLAUDE.md b/CLAUDE.md index e81914148..bce28ca05 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,16 @@ +# Skills and Tools + +This project uses **FrontMCP skills** installed in `.claude/skills/`. +Before writing code, search the installed skills for relevant guidance: + +- **Building components** (tools, resources, prompts, plugins, adapters) — check `frontmcp-development` +- **Testing** — check `frontmcp-testing` +- **Configuration** (auth, CORS, transport, sessions) — check `frontmcp-config` +- **Deployment** (Docker, Vercel, Lambda, Cloudflare) — check `frontmcp-deployment` +- **Production readiness** (security, performance, reliability) — check `frontmcp-production-readiness` + +When you need to implement something, **read the matching skill first** — it contains patterns, examples, verification checklists, and common mistakes to avoid. + # FrontMCP Monorepo - Development Guide ## Repository Structure diff --git a/eslint.config.mjs b/eslint.config.mjs index f41f1739f..fb8e39313 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,7 +5,22 @@ export default [ ...nx.configs['flat/typescript'], ...nx.configs['flat/javascript'], { - ignores: ['**/dist', '**/*.d.ts', '**/*.d.ts.map', '**/fixture/libs', 'apps/e2e/**'], + ignores: [ + '**/dist', + '**/*.d.ts', + '**/*.d.ts.map', + '**/fixture/libs', + 'apps/e2e/**', + '**/*.spec.ts', + '**/*.spec.tsx', + '**/*.test.ts', + '**/*.test.tsx', + '**/*.e2e.spec.ts', + '**/*.perf.spec.ts', + '**/*.pw.spec.ts', + '**/__tests__/**', + '**/__test-utils__/**', + ], }, { files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], diff --git a/libs/auth/src/machine-id/machine-id.ts b/libs/auth/src/machine-id/machine-id.ts index a2641e2fa..878c975e0 100644 --- a/libs/auth/src/machine-id/machine-id.ts +++ b/libs/auth/src/machine-id/machine-id.ts @@ -32,7 +32,7 @@ function isDevPersistenceEnabled(): boolean { function resolveMachineIdPath(): string { if (isBrowser()) return DEFAULT_MACHINE_ID_PATH; // Lazy-load path to avoid browser bundling issues - // eslint-disable-next-line @typescript-eslint/no-require-imports + const path = require('path') as typeof import('path'); const machineIdPath = getEnv('MACHINE_ID_PATH') ?? DEFAULT_MACHINE_ID_PATH; return path.isAbsolute(machineIdPath) ? machineIdPath : path.resolve(getCwd(), machineIdPath); @@ -77,7 +77,7 @@ function saveMachineIdAsync(machineId: string): void { } const machineIdPath = resolveMachineIdPath(); - // eslint-disable-next-line @typescript-eslint/no-require-imports + const path = require('path') as typeof import('path'); const dir = path.dirname(machineIdPath); diff --git a/libs/cli/src/commands/build/exec/cli-runtime/daemon-client.ts b/libs/cli/src/commands/build/exec/cli-runtime/daemon-client.ts index 835c45ccf..d2d01cf25 100644 --- a/libs/cli/src/commands/build/exec/cli-runtime/daemon-client.ts +++ b/libs/cli/src/commands/build/exec/cli-runtime/daemon-client.ts @@ -10,7 +10,7 @@ * TypeScript-only constructs at runtime (the .ts extension is for build-time only). */ -/* eslint-disable @typescript-eslint/no-require-imports */ + /** * Generate the daemon-client JavaScript source code (CJS module). diff --git a/libs/cli/src/commands/scaffold/create.ts b/libs/cli/src/commands/scaffold/create.ts index d47102d08..5f212be18 100644 --- a/libs/cli/src/commands/scaffold/create.ts +++ b/libs/cli/src/commands/scaffold/create.ts @@ -16,6 +16,7 @@ import { } from '@frontmcp/utils'; import { runInit } from '../../core/tsconfig'; import { getSelfVersion } from '../../core/version'; +import { buildSkillsSection } from '../skills/install'; import { clack } from '../../shared/prompts'; // Inline skill manifest types to avoid build dependency on @frontmcp/skills source interface SkillCatalogEntry { @@ -308,16 +309,19 @@ LICENSE // jest.e2e.config.ts and tsconfig.e2e.json removed — `frontmcp test` auto-generates the correct config -function generateClaudeMd(projectName: string, pm: PackageManager): string { +function generateClaudeMd( + projectName: string, + pm: PackageManager, + skillEntries: { name: string; description: string }[], +): string { const cfg = PM_CONFIG[pm]; + const version = getSelfVersion(); + const skillsBlock = buildSkillsSection(version, skillEntries); return `# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -# Skills and Tools -Use frontmcp skills for code planning, generation, and testing. - - +${skillsBlock} ## Project Overview TypeScript MCP (Model Context Protocol) server built with the **FrontMCP** framework. Uses decorator-based architecture with \`@FrontMcp\`, \`@App\`, and \`@Tool\` decorators from \`@frontmcp/sdk\`. Requires Node >= 22. @@ -1397,6 +1401,38 @@ async function collectOptions(projectArg?: string, flags?: CreateFlags): Promise }; } +async function loadSkillEntriesForClaudeMd( + options: Pick, +): Promise<{ name: string; description: string }[]> { + const bundle = options.skillsBundle ?? 'recommended'; + if (bundle === 'none') return []; + + try { + const catalogDir = path.resolve(__dirname, '..', '..', '..', '..', 'skills', 'catalog'); + const manifestPath = path.join(catalogDir, 'skills-manifest.json'); + + let manifestContent: string; + if (await fileExists(manifestPath)) { + manifestContent = await readFile(manifestPath); + } else { + const require_ = createRequire(__filename); + const pkgManifest = require_.resolve('@frontmcp/skills/catalog/skills-manifest.json'); + manifestContent = await readFile(pkgManifest); + } + const manifest = JSON.parse(manifestContent) as SkillManifest; + const target = options.deploymentTarget; + return manifest.skills + .filter((s) => { + const targetMatch = s.targets.includes('all') || s.targets.includes(target); + const bundleMatch = s.bundle?.includes(bundle); + return targetMatch && bundleMatch; + }) + .map((s) => ({ name: s.name, description: s.description })); + } catch { + return []; + } +} + async function scaffoldSkills(targetDir: string, options: CreateOptions): Promise { const bundle = options.skillsBundle ?? 'recommended'; if (bundle === 'none') return; @@ -1650,7 +1686,12 @@ async function scaffoldProject(options: CreateOptions): Promise { await scaffoldFileIfMissing(targetDir, path.join(targetDir, 'README.md'), generateReadme(options)); // CLAUDE.md and AGENTS.md for AI coding assistants - await scaffoldFileIfMissing(targetDir, path.join(targetDir, 'CLAUDE.md'), generateClaudeMd(pkgName, packageManager)); + const skillEntries = await loadSkillEntriesForClaudeMd(options); + await scaffoldFileIfMissing( + targetDir, + path.join(targetDir, 'CLAUDE.md'), + generateClaudeMd(pkgName, packageManager, skillEntries), + ); await scaffoldFileIfMissing(targetDir, path.join(targetDir, 'AGENTS.md'), generateAgentsMd(pkgName, packageManager)); // Initialize git repository diff --git a/libs/cli/src/commands/skills/__tests__/install.spec.ts b/libs/cli/src/commands/skills/__tests__/install.spec.ts new file mode 100644 index 000000000..205d7e8db --- /dev/null +++ b/libs/cli/src/commands/skills/__tests__/install.spec.ts @@ -0,0 +1,221 @@ +/** + * Unit tests for skills install CLAUDE.md generation + */ + +import { buildSkillsSection, ensureClaudeMdSkillsInstructions } from '../install'; + +// Mock dependencies +jest.mock('../../../core/version', () => ({ + getSelfVersion: () => '1.0.0-test', +})); + +jest.mock('../catalog', () => ({ + loadCatalog: () => ({ + version: 1, + skills: [ + { name: 'skill-alpha', description: 'Use when you want to build alpha components', tags: [], category: 'dev' }, + { name: 'skill-beta', description: 'Testing helpers and utilities', tags: [], category: 'test' }, + ], + }), + getCatalogDir: () => '/mock/catalog', +})); + +let mockFiles: Record = {}; + +jest.mock('@frontmcp/utils', () => ({ + fileExists: jest.fn(async (p: string) => p in mockFiles), + readFile: jest.fn(async (p: string) => mockFiles[p] ?? ''), + writeFile: jest.fn(async (p: string, content: string) => { + mockFiles[p] = content; + }), + ensureDir: jest.fn(), + cp: jest.fn(), +})); + +/** Helper: mark both catalog skills as installed under cwd */ +function mockInstalledSkills(cwd: string) { + mockFiles[`${cwd}/.claude/skills/skill-alpha/SKILL.md`] = '# skill-alpha'; + mockFiles[`${cwd}/.claude/skills/skill-beta/SKILL.md`] = '# skill-beta'; +} + +beforeEach(() => { + mockFiles = {}; + jest.clearAllMocks(); +}); + +describe('buildSkillsSection', () => { + const skills = [ + { name: 'frontmcp-development', description: 'Use when you want to create tools, resources, and prompts' }, + { name: 'frontmcp-testing', description: 'Testing utilities and e2e helpers' }, + ]; + + it('should generate section with version markers', () => { + const section = buildSkillsSection('1.2.3', skills); + + expect(section).toContain(''); + expect(section).toContain(''); + }); + + it('should include auto-generated attribution with version', () => { + const section = buildSkillsSection('1.2.3', skills); + + expect(section).toContain('Auto-generated by `frontmcp skills install` (v1.2.3)'); + expect(section).toContain('do not edit manually'); + }); + + it('should list all skills from input', () => { + const section = buildSkillsSection('1.0.0', skills); + + expect(section).toContain('**frontmcp-development**'); + expect(section).toContain('**frontmcp-testing**'); + }); + + it('should strip "Use when you want to" prefix from descriptions', () => { + const section = buildSkillsSection('1.0.0', skills); + + expect(section).toContain('create tools, resources, and prompts'); + expect(section).not.toContain('Use when you want to create'); + }); + + it('should include the Skills and Tools heading', () => { + const section = buildSkillsSection('1.0.0', skills); + + expect(section).toContain('# Skills and Tools'); + }); + + it('should handle empty skills list', () => { + const section = buildSkillsSection('1.0.0', []); + + expect(section).toContain(''); + expect(section).not.toContain('- **'); + }); +}); + +describe('ensureClaudeMdSkillsInstructions', () => { + it('should create new CLAUDE.md with installed skills only', async () => { + mockInstalledSkills('/test/project'); + + await ensureClaudeMdSkillsInstructions('/test/project'); + + const content = mockFiles['/test/project/CLAUDE.md']; + expect(content).toBeDefined(); + expect(content).toContain(''); + expect(content).toContain(''); + expect(content).toContain('**skill-alpha**'); + expect(content).toContain('**skill-beta**'); + }); + + it('should only list installed skills, not full catalog', async () => { + // Only install skill-alpha, not skill-beta + mockFiles['/test/project/.claude/skills/skill-alpha/SKILL.md'] = '# skill-alpha'; + + await ensureClaudeMdSkillsInstructions('/test/project'); + + const content = mockFiles['/test/project/CLAUDE.md']; + expect(content).toContain('**skill-alpha**'); + expect(content).not.toContain('**skill-beta**'); + }); + + it('should replace existing marker-bounded block on re-run', async () => { + mockInstalledSkills('/test/project'); + mockFiles['/test/project/CLAUDE.md'] = [ + '# My Project', + '', + '', + '# Skills and Tools', + 'Old content here', + '', + '', + '## Other Section', + 'User content preserved.', + ].join('\n'); + + await ensureClaudeMdSkillsInstructions('/test/project'); + + const content = mockFiles['/test/project/CLAUDE.md']; + expect(content).toContain(''); + expect(content).not.toContain('v0.9.0'); + expect(content).not.toContain('Old content here'); + expect(content).toContain('**skill-alpha**'); + expect(content).toContain('## Other Section'); + expect(content).toContain('User content preserved.'); + }); + + it('should migrate legacy "# Skills and Tools" header to marker format', async () => { + mockInstalledSkills('/test/project'); + mockFiles['/test/project/CLAUDE.md'] = [ + '# Skills and Tools', + 'Some old hardcoded content.', + '', + '# Other Heading', + 'User content here.', + ].join('\n'); + + await ensureClaudeMdSkillsInstructions('/test/project'); + + const content = mockFiles['/test/project/CLAUDE.md']; + expect(content).toContain(''); + expect(content).toContain(''); + expect(content).not.toContain('Some old hardcoded content'); + expect(content).toContain('# Other Heading'); + expect(content).toContain('User content here.'); + }); + + it('should migrate legacy "# Skills and Tools" when next heading is ## level', async () => { + mockInstalledSkills('/test/project'); + mockFiles['/test/project/CLAUDE.md'] = [ + '# Skills and Tools', + 'Some old hardcoded content.', + '', + '## Other Heading', + 'User content here.', + ].join('\n'); + + await ensureClaudeMdSkillsInstructions('/test/project'); + + const content = mockFiles['/test/project/CLAUDE.md']; + expect(content).toContain(''); + expect(content).toContain(''); + expect(content).not.toContain('Some old hardcoded content'); + expect(content).toContain('## Other Heading'); + expect(content).toContain('User content here.'); + }); + + it('should prepend block when CLAUDE.md exists without any skills section', async () => { + mockInstalledSkills('/test/project'); + mockFiles['/test/project/CLAUDE.md'] = ['# My Project', '', '## Commands', 'Run yarn dev.'].join('\n'); + + await ensureClaudeMdSkillsInstructions('/test/project'); + + const content = mockFiles['/test/project/CLAUDE.md']; + // Skills block should be at the top + expect(content.indexOf('/g) ?? []).length; + expect(startCount).toBe(1); + expect(endCount).toBe(1); + }); + + it('should produce empty skills list when no skills are installed', async () => { + // No skills installed (no SKILL.md files in mockFiles) + await ensureClaudeMdSkillsInstructions('/test/project'); + + const content = mockFiles['/test/project/CLAUDE.md']; + expect(content).toContain(''; +/** Legacy marker from older versions (used for migration). */ +const LEGACY_SKILLS_MARKER = '# Skills and Tools'; -/** Minimal CLAUDE.md content that instructs Claude to use installed skills. */ -const CLAUDE_MD_SKILLS_SECTION = `# Skills and Tools +/** + * Build a short summary from a skill description. + * Takes the first sentence or truncates at ~80 chars. + */ +function summarizeDescription(description: string): string { + // Take text before the first period that ends a sentence + const firstSentence = description.replace(/\. .+$/, ''); + // Remove the "Use when you want to" / "Use when you need to" prefix + const cleaned = firstSentence + .replace(/^Use when you (?:want|need) to\s+/i, '') + .replace(/^Use (?:before|when)\s+/i, ''); + // Truncate if still long + if (cleaned.length > 90) { + return cleaned.slice(0, 87) + '...'; + } + return cleaned; +} -This project uses **FrontMCP skills** installed in \`.claude/skills/\`. -Before writing code, search the installed skills for relevant guidance: +/** + * Build the skills CLAUDE.md section dynamically from the manifest. + */ +export function buildSkillsSection(version: string, skills: { name: string; description: string }[]): string { + const lines: string[] = []; + + lines.push(`${SKILLS_BLOCK_START} v${version} -->`); + lines.push('# Skills and Tools'); + lines.push(''); + lines.push(`> Auto-generated by \`frontmcp skills install\` (v${version}) — do not edit manually.`); + lines.push(''); + lines.push('This project uses **FrontMCP skills** installed in `.claude/skills/`.'); + lines.push('Before writing code, search the installed skills for relevant guidance:'); + lines.push(''); + + for (const skill of skills) { + const summary = summarizeDescription(skill.description); + lines.push(`- **${skill.name}** — ${summary}`); + } -- **Building components** (tools, resources, prompts, plugins, adapters) — check \`frontmcp-development\` -- **Testing** — check \`frontmcp-testing\` -- **Configuration** (auth, CORS, transport, sessions) — check \`frontmcp-config\` -- **Deployment** (Docker, Vercel, Lambda, Cloudflare) — check \`frontmcp-deployment\` -- **Production readiness** (security, performance, reliability) — check \`frontmcp-production-readiness\` + lines.push(''); + lines.push( + 'When you need to implement something, **read the matching skill first** — it contains patterns, examples, and common mistakes to avoid.', + ); + lines.push(SKILLS_BLOCK_END); + lines.push(''); -When you need to implement something, **read the matching skill first** — it contains patterns, examples, verification checklists, and common mistakes to avoid. -`; + return lines.join('\n'); +} export interface InstallOptions { provider?: 'claude' | 'codex'; @@ -35,26 +73,63 @@ export interface InstallOptions { } /** - * Ensure CLAUDE.md exists and contains skills usage instructions. - * If the file doesn't exist, creates it with the skills section. - * If it exists but lacks the skills marker, prepends the section. + * Ensure CLAUDE.md exists and contains an up-to-date skills section. + * + * - If markers exist: replaces the block between them (handles version updates). + * - If legacy `# Skills and Tools` header exists (no markers): replaces it. + * - If neither exists: prepends the block. + * - If file doesn't exist: creates it with the block. */ -async function ensureClaudeMdSkillsInstructions(cwd: string): Promise { +export async function ensureClaudeMdSkillsInstructions(cwd: string): Promise { + const manifest = loadCatalog(); + const version = getSelfVersion(); + // Only include skills that are actually installed (not the full catalog) + const installedSkills = ( + await Promise.all( + manifest.skills.map(async (skill) => { + const skillMdPath = path.join(cwd, '.claude', 'skills', skill.name, 'SKILL.md'); + return (await fileExists(skillMdPath)) ? { name: skill.name, description: skill.description } : undefined; + }), + ) + ).filter((s): s is { name: string; description: string } => s !== undefined); + const section = buildSkillsSection(version, installedSkills); const claudeMdPath = path.join(cwd, 'CLAUDE.md'); if (await fileExists(claudeMdPath)) { - const content = await readFile(claudeMdPath); - if (content.includes(SKILLS_MARKER)) { - // Already has skills instructions — nothing to do - return; + let content = await readFile(claudeMdPath); + + if (content.includes(SKILLS_BLOCK_START)) { + // Replace existing marker-bounded block + const startIdx = content.indexOf(SKILLS_BLOCK_START); + const endIdx = content.indexOf(SKILLS_BLOCK_END, startIdx); + if (endIdx !== -1) { + const before = content.slice(0, startIdx); + const after = content.slice(endIdx + SKILLS_BLOCK_END.length); + // Trim leading newlines from after to avoid accumulating blank lines + content = before + section + after.replace(/^\n+/, '\n'); + } else { + // Malformed: start marker without end — replace from start marker to end of file section + content = content.slice(0, startIdx) + section + '\n'; + } + } else if (content.includes(LEGACY_SKILLS_MARKER)) { + // Migrate legacy format: replace from the legacy header to the next markdown heading + const legacyIdx = content.indexOf(LEGACY_SKILLS_MARKER); + const before = content.slice(0, legacyIdx); + const afterLegacy = content.slice(legacyIdx + LEGACY_SKILLS_MARKER.length); + // Find the next markdown heading (# at start of line) after the legacy header + const nextHeadingMatch = afterLegacy.match(/\n(?=#{1,6}\s)/); + const after = + nextHeadingMatch && nextHeadingMatch.index !== undefined ? afterLegacy.slice(nextHeadingMatch.index) : ''; + content = before + section + after; + } else { + // No existing skills section — prepend + content = section + '\n' + content; } - // Exists but missing skills section — prepend it - const updated = CLAUDE_MD_SKILLS_SECTION + '\n' + content; - await writeFile(claudeMdPath, updated); + + await writeFile(claudeMdPath, content); console.log(`${c('green', '✓')} Updated ${c('cyan', 'CLAUDE.md')} with skills usage instructions`); } else { - // Create new CLAUDE.md with skills section - await writeFile(claudeMdPath, CLAUDE_MD_SKILLS_SECTION); + await writeFile(claudeMdPath, section); console.log(`${c('green', '✓')} Created ${c('cyan', 'CLAUDE.md')} with skills usage instructions`); } } diff --git a/libs/cli/src/core/__tests__/help.spec.ts b/libs/cli/src/core/__tests__/help.spec.ts index ef5a06561..509d3e0f6 100644 --- a/libs/cli/src/core/__tests__/help.spec.ts +++ b/libs/cli/src/core/__tests__/help.spec.ts @@ -55,7 +55,7 @@ describe('customizeHelp', () => { it('should list all commands in help output', () => { const help = getHelpOutput(); // Strip ANSI escape codes before line matching - // eslint-disable-next-line no-control-regex + const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, ''); const helpLines = help.split(/\r?\n/).map((l) => stripAnsi(l).trim()); diff --git a/libs/nx-plugin/src/generators/workspace/workspace.spec.ts b/libs/nx-plugin/src/generators/workspace/workspace.spec.ts index 40d49d870..1e1f68f23 100644 --- a/libs/nx-plugin/src/generators/workspace/workspace.spec.ts +++ b/libs/nx-plugin/src/generators/workspace/workspace.spec.ts @@ -160,7 +160,6 @@ describe('workspace generator', () => { let execSyncMock: jest.SpyInstance; beforeEach(() => { - // eslint-disable-next-line @typescript-eslint/no-require-imports const cp = require('child_process'); execSyncMock = jest.spyOn(cp, 'execSync').mockImplementation(() => Buffer.from('')); }); diff --git a/libs/sdk/src/plugin/__tests__/plugin.registry.spec.ts b/libs/sdk/src/plugin/__tests__/plugin.registry.spec.ts index a7af43386..a6a6cb955 100644 --- a/libs/sdk/src/plugin/__tests__/plugin.registry.spec.ts +++ b/libs/sdk/src/plugin/__tests__/plugin.registry.spec.ts @@ -6,6 +6,7 @@ import 'reflect-metadata'; import PluginRegistry, { PluginScopeInfo } from '../plugin.registry'; import { FlowCtxOf } from '../../common/interfaces'; import { FrontMcpPlugin } from '../../common/decorators/plugin.decorator'; +import { FrontMcpProvider } from '../../common/decorators/provider.decorator'; import { FlowHooksOf } from '../../common/decorators/hook.decorator'; import { createClassProvider } from '../../__test-utils__/fixtures/provider.fixtures'; import { createProviderRegistryWithScope, createMockScope } from '../../__test-utils__/fixtures/scope.fixtures'; @@ -519,6 +520,48 @@ describe('PluginRegistry', () => { expect(plugin).toBeInstanceOf(DynamicPlugin); expect(typeof plugin.get).toBe('function'); }); + + it('should not throw ProviderNotRegisteredError when decorator providers are used with DynamicPlugin.init pattern', async () => { + @FrontMcpProvider({ name: 'ExportedService' }) + class ExportedService { + getValue() { + return 'test'; + } + } + + @FrontMcpPlugin({ + name: 'PluginWithExportedProvider', + description: 'Plugin that declares providers and exports in decorator', + providers: [ExportedService], + exports: [ExportedService], + }) + class PluginWithExportedProvider { + get: any; + } + + const providers = await createProviderRegistryWithScope(); + + // Simulates what DynamicPlugin.init({}) returns: + // providers: [] is empty dynamic providers that must NOT clobber decorator providers + const registry = new PluginRegistry(providers, [ + { + provide: PluginWithExportedProvider, + useValue: new PluginWithExportedProvider(), + providers: [], + name: 'PluginWithExportedProvider', + }, + ]); + + // This used to throw: ProviderNotRegisteredError: Provider "ExportedService" is not registered + await expect(registry.ready).resolves.not.toThrow(); + + const plugins = registry.getPlugins(); + expect(plugins).toHaveLength(1); + + // Exported provider should be accessible from the parent providers registry + const exportedInstance = providers.get(ExportedService); + expect(exportedInstance).toBeInstanceOf(ExportedService); + }); }); describe('Plugin Scope', () => { diff --git a/libs/sdk/src/plugin/__tests__/plugin.utils.spec.ts b/libs/sdk/src/plugin/__tests__/plugin.utils.spec.ts index 9c8ec4a44..bf3cf39af 100644 --- a/libs/sdk/src/plugin/__tests__/plugin.utils.spec.ts +++ b/libs/sdk/src/plugin/__tests__/plugin.utils.spec.ts @@ -5,6 +5,7 @@ import 'reflect-metadata'; import { normalizePlugin, collectPluginMetadata, pluginDiscoveryDeps } from '../plugin.utils'; import { FrontMcpPlugin } from '../../common/decorators/plugin.decorator'; +import { FrontMcpProvider } from '../../common/decorators/provider.decorator'; import { PluginKind } from '../../common/records'; import { createValueProvider } from '../../__test-utils__/fixtures/provider.fixtures'; @@ -286,6 +287,90 @@ describe('Plugin Utils', () => { expect((record as any).providers).toBeDefined(); expect((record as any).providers).toHaveLength(1); }); + + it('should preserve decorator providers when inline providers is empty (DynamicPlugin.init regression)', () => { + @FrontMcpProvider({ name: 'DecoratorService' }) + class DecoratorService {} + + @FrontMcpPlugin({ + name: 'PluginWithDecoratorProviders', + description: 'Plugin that declares providers in decorator', + providers: [DecoratorService], + exports: [DecoratorService], + }) + class PluginWithDecoratorProviders {} + + // Simulates what DynamicPlugin.init({}) returns + const plugin = { + provide: PluginWithDecoratorProviders, + useValue: new PluginWithDecoratorProviders(), + providers: [], // empty dynamic providers from init() + }; + + const record = normalizePlugin(plugin); + + expect(record.kind).toBe(PluginKind.VALUE); + // Decorator providers must NOT be overwritten by empty inline providers + expect(record.metadata.providers).toEqual([DecoratorService]); + expect(record.metadata.exports).toEqual([DecoratorService]); + // Dynamic providers stored separately on rec.providers + expect((record as any).providers).toEqual([]); + }); + + it('should keep decorator providers separate from dynamic providers', () => { + @FrontMcpProvider({ name: 'StaticService' }) + class StaticService {} + + @FrontMcpProvider({ name: 'DynamicService' }) + class DynamicService {} + + @FrontMcpPlugin({ + name: 'PluginWithStaticProviders', + description: 'Plugin with static providers in decorator', + providers: [StaticService], + }) + class PluginWithStaticProviders {} + + const plugin = { + provide: PluginWithStaticProviders, + useValue: new PluginWithStaticProviders(), + providers: [DynamicService], // dynamic providers from init() + }; + + const record = normalizePlugin(plugin); + + // Decorator providers preserved in metadata + expect(record.metadata.providers).toEqual([StaticService]); + // Dynamic providers stored separately + expect((record as any).providers).toEqual([DynamicService]); + }); + + it('should still allow scope override while preserving decorator providers', () => { + @FrontMcpProvider({ name: 'SomeService' }) + class SomeService {} + + @FrontMcpPlugin({ + name: 'ScopedPlugin', + description: 'Plugin with app scope in decorator', + providers: [SomeService], + scope: 'app', + }) + class ScopedPlugin {} + + const plugin = { + provide: ScopedPlugin, + useValue: new ScopedPlugin(), + providers: [], + scope: 'server', // inline scope override + }; + + const record = normalizePlugin(plugin); + + // Scope override still works + expect(record.metadata.scope).toBe('server'); + // Decorator providers NOT clobbered + expect(record.metadata.providers).toEqual([SomeService]); + }); }); describe('Error Handling', () => { diff --git a/libs/sdk/src/plugin/plugin.utils.ts b/libs/sdk/src/plugin/plugin.utils.ts index 8f5ef65c5..f7ed3a3ec 100644 --- a/libs/sdk/src/plugin/plugin.utils.ts +++ b/libs/sdk/src/plugin/plugin.utils.ts @@ -36,8 +36,12 @@ export function normalizePlugin(item: PluginType): PluginRecord { } // Merge inline metadata with decorator metadata (inline takes precedence) // This ensures scope and other fields from inline config override decorators + // Exclude 'providers' from inline metadata: dynamic providers are stored + // separately in rec.providers and must NOT override the decorator's + // metadata.providers (which lists the plugin's own registered providers). const decoratorMetadata = collectPluginMetadata(useClass as PluginType); - const mergedMetadata = { ...decoratorMetadata, ...metadata }; + const { providers: _dynamicProviders, ...inlineMetadata } = metadata as Record; + const mergedMetadata = { ...decoratorMetadata, ...inlineMetadata }; return { kind: PluginKind.CLASS, provide, @@ -65,8 +69,12 @@ export function normalizePlugin(item: PluginType): PluginRecord { throw new InvalidUseValueError('plugin', tokenName(provide)); } // Merge inline metadata with decorator metadata (inline takes precedence) + // Exclude 'providers' from inline metadata: dynamic providers are stored + // separately in rec.providers and must NOT override the decorator's + // metadata.providers (which lists the plugin's own registered providers). const decoratorMetadata = collectPluginMetadata(useValue.constructor); - const mergedMetadata = { ...decoratorMetadata, ...metadata }; + const { providers: _dynamicProviders, ...inlineMetadata } = metadata as Record; + const mergedMetadata = { ...decoratorMetadata, ...inlineMetadata }; return { kind: PluginKind.VALUE, provide, diff --git a/libs/skills/catalog/frontmcp-development/references/create-tool.md b/libs/skills/catalog/frontmcp-development/references/create-tool.md index 61e45cf0e..f9d63386c 100644 --- a/libs/skills/catalog/frontmcp-development/references/create-tool.md +++ b/libs/skills/catalog/frontmcp-development/references/create-tool.md @@ -209,6 +209,26 @@ async execute(input: { data: string }) { ## Error Handling +**Do NOT wrap `execute()` in try/catch.** The framework's tool execution flow automatically catches exceptions, formats error responses, and triggers error hooks. Only use `this.fail(err)` for **business-logic errors** (validation failures, not-found, permission denied). Let infrastructure errors (network, database) propagate naturally. + +```typescript +// WRONG — never do this: +async execute(input) { + try { + const result = await someOperation(); + return result; + } catch (err) { + this.fail(err instanceof Error ? err : new Error(String(err))); + } +} + +// CORRECT — let the framework handle errors: +async execute(input) { + const result = await someOperation(); // errors propagate to framework + return result; +} +``` + Use `this.fail(err)` to abort execution and trigger the error flow. The method throws internally and never returns. ```typescript diff --git a/libs/skills/catalog/frontmcp-guides/references/example-task-manager.md b/libs/skills/catalog/frontmcp-guides/references/example-task-manager.md index 15dc0d0eb..6f88807dc 100644 --- a/libs/skills/catalog/frontmcp-guides/references/example-task-manager.md +++ b/libs/skills/catalog/frontmcp-guides/references/example-task-manager.md @@ -309,21 +309,18 @@ export class UpdateTaskTool extends ToolContext { this.fail(new Error('Authentication required')); } - try { - const updated = await store.update(input.id, userId, { - ...(input.status && { status: input.status }), - ...(input.priority && { priority: input.priority }), - }); - - return { - id: updated.id, - title: updated.title, - priority: updated.priority, - status: updated.status, - }; - } catch (err) { - this.fail(new Error(`Failed to update task: ${String(err)}`)); - } + // No try/catch needed — the framework handles errors in the tool execution flow. + const updated = await store.update(input.id, userId, { + ...(input.status && { status: input.status }), + ...(input.priority && { priority: input.priority }), + }); + + return { + id: updated.id, + title: updated.title, + priority: updated.priority, + status: updated.status, + }; } } ``` @@ -358,12 +355,9 @@ export class DeleteTaskTool extends ToolContext { this.fail(new Error('Authentication required')); } - try { - await store.delete(input.id, userId); - return { deleted: true, id: input.id }; - } catch (err) { - this.fail(new Error(`Failed to delete task: ${String(err)}`)); - } + // No try/catch needed — the framework handles errors in the tool execution flow. + await store.delete(input.id, userId); + return { deleted: true, id: input.id }; } } ``` diff --git a/libs/skills/catalog/frontmcp-guides/references/example-weather-api.md b/libs/skills/catalog/frontmcp-guides/references/example-weather-api.md index 96fc8b998..f255e088d 100644 --- a/libs/skills/catalog/frontmcp-guides/references/example-weather-api.md +++ b/libs/skills/catalog/frontmcp-guides/references/example-weather-api.md @@ -99,12 +99,9 @@ export class GetWeatherTool extends ToolContext { async execute(input: { city: string; units: 'celsius' | 'fahrenheit' }) { const url = `https://api.weather.example.com/v1/current?city=${encodeURIComponent(input.city)}&units=${input.units}`; - let response: Response; - try { - response = await this.fetch(url); - } catch (err) { - this.fail(new Error(`Weather API unreachable: ${String(err)}`)); - } + // No try/catch needed — the framework's tool execution flow handles errors automatically. + // Use this.fail() only for business-logic errors (e.g., invalid response). + const response = await this.fetch(url); if (!response.ok) { this.fail(new Error(`Weather API error: ${response.status} ${response.statusText}`)); diff --git a/libs/skills/catalog/frontmcp-testing/references/setup-testing.md b/libs/skills/catalog/frontmcp-testing/references/setup-testing.md index 230dc3a5f..d4cea8c10 100644 --- a/libs/skills/catalog/frontmcp-testing/references/setup-testing.md +++ b/libs/skills/catalog/frontmcp-testing/references/setup-testing.md @@ -316,6 +316,8 @@ test('prompts return well-formed messages', async ({ mcp }) => { For more control, use `McpTestClient` and `TestServer` directly: +> **Note:** `npx tsx src/main.ts` is correct **only inside E2E tests** (the test framework uses it internally via `resolveServerCommand`). For development and running the server outside of tests, always use `frontmcp dev` (or your package.json `dev` script). Never run `npx tsx src/main.ts` directly for development. + ```typescript // advanced.e2e.spec.ts import { McpTestClient, TestServer } from '@frontmcp/testing'; diff --git a/libs/ui/src/renderer/common/lazy-import.ts b/libs/ui/src/renderer/common/lazy-import.ts index 17072d787..c1c7cdfc6 100644 --- a/libs/ui/src/renderer/common/lazy-import.ts +++ b/libs/ui/src/renderer/common/lazy-import.ts @@ -69,7 +69,6 @@ export interface LazyImport { * Uses Function constructor so TypeScript doesn't try to resolve the module. */ export function runtimeImport(specifier: string): Promise> { - // eslint-disable-next-line @typescript-eslint/no-implied-eval const dynamicImport = new Function('s', 'return import(s)') as (s: string) => Promise>; return dynamicImport(specifier); } diff --git a/libs/ui/src/renderer/react/index.ts b/libs/ui/src/renderer/react/index.ts index d6ca178b7..16f4c0e5a 100644 --- a/libs/ui/src/renderer/react/index.ts +++ b/libs/ui/src/renderer/react/index.ts @@ -184,7 +184,6 @@ function ReactJsxView({ source, className }: ReactJsxViewProps): React.ReactElem const argNames = Object.keys(modules); const argValues = argNames.map((n) => modules[n]); - // eslint-disable-next-line @typescript-eslint/no-implied-eval const factory = new Function(...argNames, code) as (...args: unknown[]) => React.ComponentType; const Comp = factory(...argValues); diff --git a/libs/uipack/src/component/transpiler.ts b/libs/uipack/src/component/transpiler.ts index aefc67aa8..16b68f4ba 100644 --- a/libs/uipack/src/component/transpiler.ts +++ b/libs/uipack/src/component/transpiler.ts @@ -22,7 +22,7 @@ import type { TransformOptions, BuildOptions } from 'esbuild'; */ export function transpileReactSource(source: string, filename?: string): string { // Lazy-require esbuild to avoid import errors in browser builds - // eslint-disable-next-line @typescript-eslint/no-require-imports + const esbuild = require('esbuild') as typeof import('esbuild'); const loader: TransformOptions['loader'] = filename?.endsWith('.tsx') ? 'tsx' : 'jsx'; @@ -59,7 +59,6 @@ export function bundleFileSource( resolveDir: string, componentName: string, ): { code: string } { - // eslint-disable-next-line @typescript-eslint/no-require-imports const esbuild = require('esbuild') as typeof import('esbuild'); const mountCode = ` diff --git a/libs/utils/src/crypto/index.ts b/libs/utils/src/crypto/index.ts index 7199807f2..0edd266cc 100644 --- a/libs/utils/src/crypto/index.ts +++ b/libs/utils/src/crypto/index.ts @@ -37,11 +37,10 @@ export function rsaVerify( signature: Buffer | Uint8Array, ): Promise { if (isNode()) { - // eslint-disable-next-line @typescript-eslint/no-require-imports return Promise.resolve(require('./node').rsaVerify(jwtAlg, data, publicJwk, signature) as boolean); } // Browser: use WebCrypto async API - // eslint-disable-next-line @typescript-eslint/no-require-imports + const { rsaVerifyBrowser } = require('./browser'); return rsaVerifyBrowser(jwtAlg, data, publicJwk, signature) as Promise; } diff --git a/libs/utils/src/crypto/secret-persistence/persistence.ts b/libs/utils/src/crypto/secret-persistence/persistence.ts index ad8cba676..5eaec9690 100644 --- a/libs/utils/src/crypto/secret-persistence/persistence.ts +++ b/libs/utils/src/crypto/secret-persistence/persistence.ts @@ -18,7 +18,7 @@ import { getEnv, getCwd } from '#env'; // Lazy-load path module to avoid pulling it into browser bundles. // All functions that use path are Node-only (filesystem operations). -// eslint-disable-next-line @typescript-eslint/no-require-imports + function getPath(): typeof import('path') { return require('path'); } diff --git a/libs/utils/src/event-emitter/browser-event-emitter.ts b/libs/utils/src/event-emitter/browser-event-emitter.ts index c13a37cf1..c3ec360fe 100644 --- a/libs/utils/src/event-emitter/browser-event-emitter.ts +++ b/libs/utils/src/event-emitter/browser-event-emitter.ts @@ -5,7 +5,6 @@ * emit, on, off, removeAllListeners, setMaxListeners, listenerCount. */ -// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type type Listener = Function; export class EventEmitter {