diff --git a/CLAUDE.md b/CLAUDE.md index e81914148..0d1b93140 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,16 @@ + + +# Skills and Tools + +> Auto-generated by `frontmcp skills install` (v1.0.1) — do not edit manually. + +This project uses **FrontMCP skills** installed in `.claude/skills/`. +Before writing code, search the installed skills for relevant guidance: + +When you need to implement something, **read the matching skill first** — it contains patterns, examples, and common mistakes to avoid. + + + # FrontMCP Monorepo - Development Guide ## Repository Structure diff --git a/libs/cli/src/commands/build/exec/__tests__/generate-cli-entry.spec.ts b/libs/cli/src/commands/build/exec/__tests__/generate-cli-entry.spec.ts index 2f2fd338a..ce98a4f4c 100644 --- a/libs/cli/src/commands/build/exec/__tests__/generate-cli-entry.spec.ts +++ b/libs/cli/src/commands/build/exec/__tests__/generate-cli-entry.spec.ts @@ -676,6 +676,7 @@ describe('generateCliEntry', () => { expect(source).toContain("'skills'"); expect(source).toContain("'search [query]'"); expect(source).toContain("'load '"); + expect(source).toContain("'read '"); expect(source).toContain('searchSkills'); expect(source).toContain('loadSkills'); expect(source).toContain('listSkills'); @@ -1074,13 +1075,27 @@ describe('generateCliEntry', () => { }); describe('skills list command', () => { - it('should generate skills list subcommand with listSkills', () => { + it('should generate skills list subcommand with listSkills and next-action hints', () => { const source = generateCliEntry(makeOptions({ schema: makeSchema({ capabilities: { skills: true, jobs: false, workflows: false } }), })); expect(source).toContain("'list'"); expect(source).toContain('listSkills'); + expect(source).toContain('skills search '); + expect(source).toContain('skills read '); + }); + }); + + describe('skills read command', () => { + it('should generate skills read subcommand with loadSkills', () => { + const source = generateCliEntry(makeOptions({ + schema: makeSchema({ capabilities: { skills: true, jobs: false, workflows: false } }), + })); + + expect(source).toContain("'read '"); + expect(source).toContain('loadSkills'); + expect(source).toContain('skills load'); }); }); }); diff --git a/libs/cli/src/commands/build/exec/cli-runtime/generate-cli-entry.ts b/libs/cli/src/commands/build/exec/cli-runtime/generate-cli-entry.ts index 54bf5e6ab..5dc5aa6ce 100644 --- a/libs/cli/src/commands/build/exec/cli-runtime/generate-cli-entry.ts +++ b/libs/cli/src/commands/build/exec/cli-runtime/generate-cli-entry.ts @@ -191,6 +191,16 @@ async function getClient() { return _client; } +async function closeClient() { + if (_client && typeof _client.close === 'function') { + try { await _client.close(); } catch (_) {} + } + _client = null; +} + +// Flag set by long-running commands (serve, daemon) to prevent the footer from calling process.exit(). +var _isLongRunning = false; + var program = new Command(); program .name(${JSON.stringify(appName)}) @@ -539,15 +549,20 @@ skillsCmd if (mode === 'json') { console.log(JSON.stringify(result, null, 2)); } else { - var skills = result.skills || result || []; - if (Array.isArray(skills) && skills.length === 0) { console.log('No skills found.'); return; } - if (Array.isArray(skills)) { - skills.forEach(function(s) { - console.log(' ' + (s.name || s.id || JSON.stringify(s))); - }); - } else { - console.log(JSON.stringify(result, null, 2)); - } + var skills = result.skills || []; + if (skills.length === 0) { console.log('No skills found.'); return; } + console.log('\\n Skills matching "' + (query || '') + '":\\n'); + skills.forEach(function(s) { + var tags = (s.tags || []).slice(0, 3).join(', '); + var score = s.score != null ? ' [score: ' + Number(s.score).toFixed(2) + ']' : ''; + console.log(' ' + (s.name || s.id) + score); + if (s.description) console.log(' ' + s.description.split('. Use when')[0]); + if (tags) console.log(' tags: ' + tags); + console.log(''); + }); + console.log(' ' + skills.length + ' result(s).'); + console.log(" Use '" + program.name() + " skills read ' for full details."); + console.log(" Use '" + program.name() + " skills load ' to load a skill.\\n"); } } catch (err) { console.error('Error:', err.message || err); @@ -577,6 +592,43 @@ skillsCmd } }); +skillsCmd + .command('read ') + .description('Read full details for a skill') + .action(async function(name) { + try { + var client = await getClient(); + var result = await client.loadSkills([name]); + var mode = program.opts().output || 'text'; + if (mode === 'json') { + console.log(JSON.stringify(result, null, 2)); + } else { + var skills = result.skills || []; + if (skills.length === 0) { console.log('Skill "' + name + '" not found.'); return; } + var sk = skills[0]; + console.log('\\n ' + sk.name); + if (sk.description) console.log(' ' + sk.description); + console.log(''); + if (sk.instructions) { + console.log(sk.instructions); + console.log(''); + } + if (sk.tools && sk.tools.length > 0) { + console.log(' Tools (' + sk.tools.length + '):'); + sk.tools.forEach(function(t) { + console.log(' ' + t.name + (t.available ? '' : ' (unavailable)')); + }); + console.log(''); + } + if (result.nextSteps) console.log(' ' + result.nextSteps); + console.log(" Load: " + program.name() + " skills load " + name + '\\n'); + } + } catch (err) { + console.error('Error:', err.message || err); + process.exitCode = 1; + } + }); + skillsCmd .command('list') .description('List available skills') @@ -588,15 +640,17 @@ skillsCmd if (mode === 'json') { console.log(JSON.stringify(result, null, 2)); } else { - var skills = result.skills || result || []; - if (Array.isArray(skills) && skills.length === 0) { console.log('No skills available.'); return; } - if (Array.isArray(skills)) { - skills.forEach(function(s) { - console.log(' ' + (s.name || s.id || JSON.stringify(s))); - }); - } else { - console.log(JSON.stringify(result, null, 2)); - } + var skills = result.skills || []; + if (skills.length === 0) { console.log('No skills available.'); return; } + console.log('\\n Available Skills (' + skills.length + '):\\n'); + skills.forEach(function(s) { + var desc = s.description ? s.description.split('. Use when')[0] : ''; + console.log(' ' + (s.name || s.id)); + if (desc) console.log(' ' + desc); + console.log(''); + }); + console.log(" Use '" + program.name() + " skills search ' for semantic search."); + console.log(" Use '" + program.name() + " skills read ' for full details.\\n"); } } catch (err) { console.error('Error:', err.message || err); @@ -878,7 +932,9 @@ async function getSubscribeClient() { // If connected via daemon, the onNotification/onResourceUpdated are no-ops. // Reconnect via in-process for push support. if (client._isDaemon) { - _client = null; // clear cached daemon client + // Close the daemon client before replacing with in-process client + if (typeof client.close === 'function') { try { await client.close(); } catch (_) {} } + _client = null; var mod = require(SERVER_BUNDLE); var configOrClass = mod.default || mod; var sdk = require('@frontmcp/sdk'); @@ -907,6 +963,7 @@ subscribeCmd process.on('SIGINT', async function() { console.log('\\nUnsubscribing...'); try { await client.unsubscribeResource(uri); } catch (_) { /* ok */ } + await closeClient(); process.exit(0); }); // Keep process alive — setInterval creates an active event loop handle @@ -933,8 +990,9 @@ subscribeCmd console.log(fmt.formatSubscriptionEvent({ type: 'notification', method: notification.method, params: notification.params, timestamp: new Date().toISOString() }, mode)); } }); - process.on('SIGINT', function() { + process.on('SIGINT', async function() { console.log('\\nStopping...'); + await closeClient(); process.exit(0); }); // Keep process alive — setInterval creates an active event loop handle @@ -1081,6 +1139,7 @@ function generateServeCommand(serverBundleFilename: string, selfContained?: bool .description('Start the HTTP/SSE server') .option('-p, --port ', 'Port number', function(v) { return parseInt(v, 10); }) .action(async function(opts) { + _isLongRunning = true; var mod = ${requireExpr}; if (opts.port) process.env.PORT = String(opts.port); // If the bundle exports a start() function (@FrontMcp-decorated class auto-bootstraps), use it @@ -1370,6 +1429,10 @@ ${selfContained ? ` // SEA mode: spawn the binary itself in daemon mode — a env: env });`} + // Close inherited file descriptors in the parent — the child already has its own copy. + fs.closeSync(out); + fs.closeSync(err); + fs.writeFileSync(pidPath, JSON.stringify({ pid: child.pid, socketPath: socketPath, @@ -1453,14 +1516,17 @@ function generateFooter(): string { console.error('Unknown command: ' + args[0]); process.exitCode = 1; }); -program.parseAsync(process.argv).then(function() { - // Use exitCode instead of process.exit() so long-running commands - // (e.g., serve) keep the event loop alive while short-lived commands - // exit naturally when the event loop drains. - process.exitCode = process.exitCode || 0; -}).catch(function(err) { +program.parseAsync(process.argv).then(async function() { + // Long-running commands (serve) set _isLongRunning to keep the event loop alive. + // Short-lived commands close the client and exit explicitly to avoid hanging + // on unclosed handles (file loggers, in-memory transport, etc.). + if (_isLongRunning) return; + await closeClient(); + process.exit(process.exitCode || 0); +}).catch(async function(err) { console.error('Fatal:', err.message || err); - process.exitCode = 1; + await closeClient(); + process.exit(1); });`; } diff --git a/libs/cli/src/commands/skills/catalog.ts b/libs/cli/src/commands/skills/catalog.ts index 286400446..8042169a8 100644 --- a/libs/cli/src/commands/skills/catalog.ts +++ b/libs/cli/src/commands/skills/catalog.ts @@ -7,8 +7,21 @@ import * as fs from 'fs'; import * as path from 'path'; -import { TFIDFVectoria } from 'vectoriadb'; -import type { SkillReferenceEntry } from '@frontmcp/skills'; +import type { TFIDFVectoria } from 'vectoriadb'; + +interface SkillReferenceExample { + name: string; + description: string; + level: 'basic' | 'intermediate' | 'advanced'; + tags: string[]; + features: string[]; +} + +interface SkillReference { + name: string; + description: string; + examples?: SkillReferenceExample[]; +} interface SkillEntry { name: string; @@ -19,7 +32,7 @@ interface SkillEntry { hasResources: boolean; tags: string[]; bundle?: string[]; - references?: SkillReferenceEntry[]; + references?: SkillReference[]; } interface SkillManifest { @@ -237,8 +250,14 @@ export function getCatalogDir(): string { function getSearchIndex(): TFIDFVectoria { if (cachedIndex) return cachedIndex; + // Lazy-load vectoriadb to avoid triggering TF/HF model initialization + // when only loadCatalog() is needed (e.g., skills list command). + const { TFIDFVectoria: TFIDFVectoriaImpl } = require('vectoriadb') as { + TFIDFVectoria: typeof TFIDFVectoria; + }; + const manifest = loadCatalog(); - cachedIndex = new TFIDFVectoria({ + const index = new TFIDFVectoriaImpl({ defaultTopK: 10, defaultSimilarityThreshold: 0.0, }); @@ -249,10 +268,11 @@ function getSearchIndex(): TFIDFVectoria { metadata: { id: skill.name, skill }, })); - cachedIndex.addDocuments(documents); - cachedIndex.reindex(); + index.addDocuments(documents); + index.reindex(); - return cachedIndex; + cachedIndex = index; + return index; } /** diff --git a/libs/cli/src/core/cli.ts b/libs/cli/src/core/cli.ts index 66b7e1489..ebb30c532 100644 --- a/libs/cli/src/core/cli.ts +++ b/libs/cli/src/core/cli.ts @@ -13,12 +13,10 @@ async function main(): Promise { try { const program = createProgram(); await program.parseAsync(process.argv); - process.exitCode = 0; - return; + process.exit(0); } catch (err: unknown) { console.error('\n' + c('red', err instanceof Error ? err.stack || err.message : String(err))); - process.exitCode = 1; - return; + process.exit(1); } } diff --git a/libs/nx-plugin/src/utils/versions.spec.ts b/libs/nx-plugin/src/utils/versions.spec.ts index 4d8e00e92..fdaac9736 100644 --- a/libs/nx-plugin/src/utils/versions.spec.ts +++ b/libs/nx-plugin/src/utils/versions.spec.ts @@ -6,8 +6,13 @@ import { getNxDevDependencies, } from './versions'; +jest.mock('fs', () => ({ + ...jest.requireActual('fs'), + existsSync: jest.fn().mockReturnValue(true), +})); + jest.mock('@nx/devkit', () => ({ - readJsonFile: jest.fn().mockReturnValue({ version: '0.11.1' }), + readJsonFile: jest.fn().mockReturnValue({ name: '@frontmcp/nx', version: '0.11.1' }), })); describe('versions', () => { diff --git a/libs/nx-plugin/src/utils/versions.ts b/libs/nx-plugin/src/utils/versions.ts index f237518c7..ef1df9c80 100644 --- a/libs/nx-plugin/src/utils/versions.ts +++ b/libs/nx-plugin/src/utils/versions.ts @@ -1,17 +1,28 @@ import { readJsonFile } from '@nx/devkit'; import { join } from 'path'; +import { existsSync } from 'fs'; let cachedVersion: string | undefined; function readPluginVersion(): string { if (cachedVersion) return cachedVersion; - const pkgPath = join(__dirname, '..', '..', 'package.json'); - const pkg = readJsonFile<{ version?: unknown }>(pkgPath); - if (!pkg.version || typeof pkg.version !== 'string') { - throw new Error(`@frontmcp/nx package.json at ${pkgPath} is missing a valid "version" field`); + + // Walk up from __dirname to find the @frontmcp/nx package.json. + // In monorepo (src/utils/ or dist/utils/): ../../package.json + // When published to npm (utils/): ../package.json + let dir = __dirname; + for (let i = 0; i < 4; i++) { + dir = join(dir, '..'); + const candidate = join(dir, 'package.json'); + if (!existsSync(candidate)) continue; + const pkg = readJsonFile<{ name?: string; version?: unknown }>(candidate); + if (pkg.name === '@frontmcp/nx' && typeof pkg.version === 'string') { + cachedVersion = pkg.version; + return cachedVersion; + } } - cachedVersion = pkg.version; - return cachedVersion; + + throw new Error(`@frontmcp/nx package.json not found (searched from ${__dirname})`); } export function getFrontmcpVersion(): string { diff --git a/libs/sdk/package.json b/libs/sdk/package.json index 3421fb1bc..64a54f8d7 100644 --- a/libs/sdk/package.json +++ b/libs/sdk/package.json @@ -79,7 +79,7 @@ "@enclave-vm/core": "^2.11.1", "openai": "^4.0.0 || ^5.0.0 || ^6.0.0", "@anthropic-ai/sdk": "^0.30.0 || ^0.78.0", - "@frontmcp/observability": "1.0.0", + "@frontmcp/observability": "1.0.1", "@opentelemetry/api": "^1.9.0", "@opentelemetry/sdk-trace-base": "^1.25.0" }, diff --git a/libs/sdk/src/skill/skill.instance.ts b/libs/sdk/src/skill/skill.instance.ts index 3d7a3312d..81440f1d5 100644 --- a/libs/sdk/src/skill/skill.instance.ts +++ b/libs/sdk/src/skill/skill.instance.ts @@ -1,7 +1,7 @@ // file: libs/sdk/src/skill/skill.instance.ts import { EntryOwnerRef, SkillEntry, SkillKind, SkillRecord, SkillToolRef, normalizeToolRef } from '../common'; -import { SkillContent, SkillReferenceInfo, SkillExampleInfo } from '../common/interfaces'; +import { SkillContent } from '../common/interfaces'; import { SkillVisibility } from '../common/metadata/skill.metadata'; import ProviderRegistry from '../provider/provider.registry'; import { ScopeEntry } from '../common'; diff --git a/nx.json b/nx.json index 8f1906c71..451041b78 100644 --- a/nx.json +++ b/nx.json @@ -90,6 +90,11 @@ } }, "release": { - "projects": ["libs/*", "plugins/*"] + "projects": ["libs/*", "plugins/*"], + "version": { + "generatorOptions": { + "updateDependents": "auto" + } + } } }