morpheus-cli 0.7.2 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,6 +22,7 @@ It runs as a daemon and orchestrates LLMs, MCP tools, DevKit tools, memory, and
22
22
  - `Sati`: long-term memory retrieval/evaluation.
23
23
  - `Trinity`: database specialist. Executes queries, introspects schemas, and manages registered databases (PostgreSQL, MySQL, SQLite, MongoDB).
24
24
  - `Chronos`: temporal scheduler. Runs Oracle prompts on a recurring or one-time schedule.
25
+ - `Keymaker`: skill executor. Runs user-defined skills with full tool access (DevKit + MCP + internal tools).
25
26
 
26
27
  ## Installation
27
28
 
@@ -441,6 +442,124 @@ Precedence order:
441
442
  3. `zaion.yaml`
442
443
  4. Defaults
443
444
 
445
+ ## Skills
446
+
447
+ Skills are user-defined capabilities that extend Morpheus. Each skill is a folder in `~/.morpheus/skills/` containing a single `SKILL.md` file with YAML frontmatter for metadata.
448
+
449
+ ### Execution Modes
450
+
451
+ Skills support two execution modes:
452
+
453
+ - **`sync`** (default) - Executes immediately via `skill_execute`, returns result inline
454
+ - **`async`** - Runs as background task via `skill_delegate`, notifies when complete
455
+
456
+ ### Creating a Skill
457
+
458
+ ```bash
459
+ mkdir -p ~/.morpheus/skills/my-skill
460
+ ```
461
+
462
+ **SKILL.md:**
463
+ ```markdown
464
+ ---
465
+ name: my-skill
466
+ description: Brief description of what this skill does
467
+ version: 1.0.0
468
+ author: your-name
469
+ enabled: true
470
+ execution_mode: sync
471
+ tags:
472
+ - automation
473
+ - example
474
+ examples:
475
+ - "Example prompt that triggers this skill"
476
+ ---
477
+
478
+ # My Skill
479
+
480
+ You are an expert at [domain]. Follow these instructions...
481
+
482
+ ## Your Task
483
+ [Instructions for Keymaker to execute]
484
+ ```
485
+
486
+ ### Async Skill Example
487
+
488
+ For long-running tasks like deployments or builds:
489
+
490
+ ```markdown
491
+ ---
492
+ name: deploy-staging
493
+ description: Deploy application to staging environment
494
+ execution_mode: async
495
+ tags:
496
+ - deployment
497
+ - devops
498
+ ---
499
+
500
+ # Deploy to Staging
501
+
502
+ Execute a full deployment to the staging environment...
503
+ ```
504
+
505
+ ### Using Skills
506
+
507
+ Once a skill is loaded, Oracle will automatically suggest delegating matching tasks to Keymaker:
508
+
509
+ **Sync Skills (immediate result):**
510
+ ```
511
+ User: "Review the code in src/auth.ts"
512
+ Oracle: [executes code-reviewer skill via skill_execute]
513
+ Keymaker: [returns detailed review immediately]
514
+ ```
515
+
516
+ **Async Skills (background task):**
517
+ ```
518
+ User: "Deploy to staging"
519
+ Oracle: [delegates via skill_delegate, task queued]
520
+ Morpheus: [notifies via Telegram/Discord when complete]
521
+ ```
522
+
523
+ ### Managing Skills
524
+
525
+ **CLI Commands:**
526
+ ```bash
527
+ # View loaded skills
528
+ morpheus skills
529
+
530
+ # Reload skills from disk
531
+ morpheus skills --reload
532
+ ```
533
+
534
+ **API Endpoints:**
535
+ ```
536
+ GET /api/skills - List all skills
537
+ GET /api/skills/:name - Get skill details
538
+ POST /api/skills/reload - Reload from filesystem
539
+ POST /api/skills/:name/enable - Enable skill
540
+ POST /api/skills/:name/disable - Disable skill
541
+ ```
542
+
543
+ **Telegram/Discord Commands:**
544
+ ```
545
+ /skills - List skills
546
+ /skill_reload - Reload skills
547
+ /skill_enable <name> - Enable a skill
548
+ /skill_disable <name> - Disable a skill
549
+ ```
550
+
551
+ ### Sample Skills
552
+
553
+ Example skills are available in `examples/skills/`:
554
+ - `code-reviewer` - Reviews code for issues and best practices (sync)
555
+ - `git-helper` - Assists with Git operations (sync)
556
+ - `deploy-staging` - Deploy to staging environment (async)
557
+
558
+ Copy them to your skills directory:
559
+ ```bash
560
+ cp -r examples/skills/* ~/.morpheus/skills/
561
+ ```
562
+
444
563
  ## MCP Configuration
445
564
 
446
565
  Configure MCP servers in `~/.morpheus/mcps.json`.
@@ -56,6 +56,24 @@ const SLASH_COMMANDS = [
56
56
  .setDescription('Delete a Chronos job')
57
57
  .addStringOption(opt => opt.setName('id').setDescription('Job ID').setRequired(true))
58
58
  .setDMPermission(true),
59
+ new SlashCommandBuilder()
60
+ .setName('skills')
61
+ .setDescription('List all available skills')
62
+ .setDMPermission(true),
63
+ new SlashCommandBuilder()
64
+ .setName('skill_reload')
65
+ .setDescription('Reload skills from filesystem')
66
+ .setDMPermission(true),
67
+ new SlashCommandBuilder()
68
+ .setName('skill_enable')
69
+ .setDescription('Enable a skill')
70
+ .addStringOption(opt => opt.setName('name').setDescription('Skill name').setRequired(true))
71
+ .setDMPermission(true),
72
+ new SlashCommandBuilder()
73
+ .setName('skill_disable')
74
+ .setDescription('Disable a skill')
75
+ .addStringOption(opt => opt.setName('name').setDescription('Skill name').setRequired(true))
76
+ .setDMPermission(true),
59
77
  ].map(cmd => cmd.toJSON());
60
78
  // ─── Adapter ──────────────────────────────────────────────────────────────────
61
79
  export class DiscordAdapter {
@@ -337,6 +355,18 @@ export class DiscordAdapter {
337
355
  case 'chronos_delete':
338
356
  await this.cmdChronosDelete(interaction);
339
357
  break;
358
+ case 'skills':
359
+ await this.cmdSkills(interaction);
360
+ break;
361
+ case 'skill_reload':
362
+ await this.cmdSkillReload(interaction);
363
+ break;
364
+ case 'skill_enable':
365
+ await this.cmdSkillEnable(interaction);
366
+ break;
367
+ case 'skill_disable':
368
+ await this.cmdSkillDisable(interaction);
369
+ break;
340
370
  }
341
371
  }
342
372
  async cmdHelp(interaction) {
@@ -356,6 +386,12 @@ export class DiscordAdapter {
356
386
  '`/chronos_enable id:` — Enable a job',
357
387
  '`/chronos_delete id:` — Delete a job',
358
388
  '',
389
+ '**Skills**',
390
+ '`/skills` — List all available skills',
391
+ '`/skill_reload` — Reload skills from filesystem',
392
+ '`/skill_enable name:` — Enable a skill',
393
+ '`/skill_disable name:` — Disable a skill',
394
+ '',
359
395
  'You can also send text or voice messages to chat with the Oracle.',
360
396
  ].join('\n');
361
397
  await interaction.reply({ content });
@@ -548,6 +584,79 @@ export class DiscordAdapter {
548
584
  await interaction.reply({ content: `Error: ${err.message}` });
549
585
  }
550
586
  }
587
+ // ─── Skills Commands ─────────────────────────────────────────────────────
588
+ async cmdSkills(interaction) {
589
+ try {
590
+ const { SkillRegistry } = await import('../runtime/skills/index.js');
591
+ const registry = SkillRegistry.getInstance();
592
+ const skills = registry.getAll();
593
+ if (!skills.length) {
594
+ await interaction.reply({ content: 'No skills found. Add skills to `~/.morpheus/skills/`' });
595
+ return;
596
+ }
597
+ const lines = skills.map(s => {
598
+ const status = s.enabled ? '🟢' : '🔴';
599
+ const tags = s.tags?.length ? ` [${s.tags.join(', ')}]` : '';
600
+ return `${status} **${s.name}**${tags}\n _${s.description.slice(0, 50)}${s.description.length > 50 ? '…' : ''}_`;
601
+ });
602
+ const enabled = skills.filter(s => s.enabled).length;
603
+ await interaction.reply({
604
+ content: `**Skills** (${enabled}/${skills.length} enabled)\n\n${lines.join('\n')}`
605
+ });
606
+ }
607
+ catch (err) {
608
+ await interaction.reply({ content: `Error: ${err.message}` });
609
+ }
610
+ }
611
+ async cmdSkillReload(interaction) {
612
+ try {
613
+ const { SkillRegistry, updateSkillDelegateDescription } = await import('../runtime/skills/index.js');
614
+ const registry = SkillRegistry.getInstance();
615
+ const result = await registry.reload();
616
+ updateSkillDelegateDescription();
617
+ const msg = result.errors.length > 0
618
+ ? `Reloaded ${result.skills.length} skills with ${result.errors.length} error(s).`
619
+ : `Reloaded ${result.skills.length} skill(s).`;
620
+ await interaction.reply({ content: msg });
621
+ }
622
+ catch (err) {
623
+ await interaction.reply({ content: `Error: ${err.message}` });
624
+ }
625
+ }
626
+ async cmdSkillEnable(interaction) {
627
+ const name = interaction.options.getString('name', true);
628
+ try {
629
+ const { SkillRegistry, updateSkillDelegateDescription } = await import('../runtime/skills/index.js');
630
+ const registry = SkillRegistry.getInstance();
631
+ const success = registry.enable(name);
632
+ if (!success) {
633
+ await interaction.reply({ content: `Skill "${name}" not found.` });
634
+ return;
635
+ }
636
+ updateSkillDelegateDescription();
637
+ await interaction.reply({ content: `Skill \`${name}\` enabled.` });
638
+ }
639
+ catch (err) {
640
+ await interaction.reply({ content: `Error: ${err.message}` });
641
+ }
642
+ }
643
+ async cmdSkillDisable(interaction) {
644
+ const name = interaction.options.getString('name', true);
645
+ try {
646
+ const { SkillRegistry, updateSkillDelegateDescription } = await import('../runtime/skills/index.js');
647
+ const registry = SkillRegistry.getInstance();
648
+ const success = registry.disable(name);
649
+ if (!success) {
650
+ await interaction.reply({ content: `Skill "${name}" not found.` });
651
+ return;
652
+ }
653
+ updateSkillDelegateDescription();
654
+ await interaction.reply({ content: `Skill \`${name}\` disabled.` });
655
+ }
656
+ catch (err) {
657
+ await interaction.reply({ content: `Error: ${err.message}` });
658
+ }
659
+ }
551
660
  // ─── Helpers ──────────────────────────────────────────────────────────────
552
661
  isAuthorized(userId) {
553
662
  return this.allowedUsers.includes(userId);
@@ -801,6 +801,22 @@ export class TelegramAdapter {
801
801
  await this.handleChronosDelete(ctx, id);
802
802
  break;
803
803
  }
804
+ case '/skills':
805
+ await this.handleSkillsList(ctx);
806
+ break;
807
+ case '/skill_reload':
808
+ await this.handleSkillsReload(ctx);
809
+ break;
810
+ case '/skill_enable': {
811
+ const name = args[0] ?? '';
812
+ await this.handleSkillEnable(ctx, name);
813
+ break;
814
+ }
815
+ case '/skill_disable': {
816
+ const name = args[0] ?? '';
817
+ await this.handleSkillDisable(ctx, name);
818
+ break;
819
+ }
804
820
  default:
805
821
  await this.handleDefaultCommand(ctx, user, command);
806
822
  }
@@ -1000,6 +1016,84 @@ export class TelegramAdapter {
1000
1016
  }
1001
1017
  }
1002
1018
  // ─── End Chronos ──────────────────────────────────────────────────────────────
1019
+ // ─── Skills Command Handlers ─────────────────────────────────────────────────
1020
+ async handleSkillsList(ctx) {
1021
+ try {
1022
+ const { SkillRegistry } = await import('../runtime/skills/index.js');
1023
+ const registry = SkillRegistry.getInstance();
1024
+ const skills = registry.getAll();
1025
+ if (!skills.length) {
1026
+ await ctx.reply('No skills found. Add skills to `~/.morpheus/skills/`', { parse_mode: 'Markdown' });
1027
+ return;
1028
+ }
1029
+ const lines = skills.map(s => {
1030
+ const status = s.enabled ? '🟢' : '🔴';
1031
+ const tags = s.tags?.length ? ` [${s.tags.join(', ')}]` : '';
1032
+ return `${status} *${s.name}*${tags}\n _${s.description.slice(0, 50)}${s.description.length > 50 ? '…' : ''}_`;
1033
+ });
1034
+ const enabled = skills.filter(s => s.enabled).length;
1035
+ await ctx.reply(`*Skills* (${enabled}/${skills.length} enabled)\n\n${lines.join('\n')}`, { parse_mode: 'Markdown' });
1036
+ }
1037
+ catch (err) {
1038
+ await ctx.reply(`Error: ${err.message}`);
1039
+ }
1040
+ }
1041
+ async handleSkillsReload(ctx) {
1042
+ try {
1043
+ const { SkillRegistry, updateSkillDelegateDescription } = await import('../runtime/skills/index.js');
1044
+ const registry = SkillRegistry.getInstance();
1045
+ const result = await registry.reload();
1046
+ updateSkillDelegateDescription();
1047
+ const msg = result.errors.length > 0
1048
+ ? `Reloaded ${result.skills.length} skills with ${result.errors.length} error(s).`
1049
+ : `Reloaded ${result.skills.length} skill(s).`;
1050
+ await ctx.reply(msg);
1051
+ }
1052
+ catch (err) {
1053
+ await ctx.reply(`Error: ${err.message}`);
1054
+ }
1055
+ }
1056
+ async handleSkillEnable(ctx, name) {
1057
+ if (!name) {
1058
+ await ctx.reply('Usage: /skill_enable <name>');
1059
+ return;
1060
+ }
1061
+ try {
1062
+ const { SkillRegistry, updateSkillDelegateDescription } = await import('../runtime/skills/index.js');
1063
+ const registry = SkillRegistry.getInstance();
1064
+ const success = registry.enable(name);
1065
+ if (!success) {
1066
+ await ctx.reply(`Skill "${name}" not found.`);
1067
+ return;
1068
+ }
1069
+ updateSkillDelegateDescription();
1070
+ await ctx.reply(`Skill \`${name}\` enabled.`, { parse_mode: 'Markdown' });
1071
+ }
1072
+ catch (err) {
1073
+ await ctx.reply(`Error: ${err.message}`);
1074
+ }
1075
+ }
1076
+ async handleSkillDisable(ctx, name) {
1077
+ if (!name) {
1078
+ await ctx.reply('Usage: /skill_disable <name>');
1079
+ return;
1080
+ }
1081
+ try {
1082
+ const { SkillRegistry, updateSkillDelegateDescription } = await import('../runtime/skills/index.js');
1083
+ const registry = SkillRegistry.getInstance();
1084
+ const success = registry.disable(name);
1085
+ if (!success) {
1086
+ await ctx.reply(`Skill "${name}" not found.`);
1087
+ return;
1088
+ }
1089
+ updateSkillDelegateDescription();
1090
+ await ctx.reply(`Skill \`${name}\` disabled.`, { parse_mode: 'Markdown' });
1091
+ }
1092
+ catch (err) {
1093
+ await ctx.reply(`Error: ${err.message}`);
1094
+ }
1095
+ }
1096
+ // ─── End Skills ───────────────────────────────────────────────────────────────
1003
1097
  async handleNewSessionCommand(ctx, user) {
1004
1098
  try {
1005
1099
  await ctx.reply("Are you ready to start a new session\\? Please confirm\\.", {
@@ -22,6 +22,7 @@ import { TaskWorker } from '../../runtime/tasks/worker.js';
22
22
  import { TaskNotifier } from '../../runtime/tasks/notifier.js';
23
23
  import { ChronosWorker } from '../../runtime/chronos/worker.js';
24
24
  import { ChronosRepository } from '../../runtime/chronos/repository.js';
25
+ import { SkillRegistry } from '../../runtime/skills/index.js';
25
26
  // Load .env file explicitly in start command
26
27
  const envPath = path.join(process.cwd(), '.env');
27
28
  if (fs.existsSync(envPath)) {
@@ -126,6 +127,17 @@ export const startCommand = new Command('start')
126
127
  if (options.ui) {
127
128
  display.log(chalk.blue(`Web UI enabled to port ${options.port}`), { source: 'Zaion' });
128
129
  }
130
+ // Initialize SkillRegistry before Oracle (so skills are available in system prompt)
131
+ try {
132
+ const skillRegistry = SkillRegistry.getInstance();
133
+ await skillRegistry.load();
134
+ const loadedSkills = skillRegistry.getAll();
135
+ const enabledCount = skillRegistry.getEnabled().length;
136
+ display.log(chalk.green(`✓ Skills loaded: ${loadedSkills.length} total, ${enabledCount} enabled`), { source: 'Skills' });
137
+ }
138
+ catch (err) {
139
+ display.log(chalk.yellow(`Skills initialization warning: ${err.message}`), { source: 'Skills' });
140
+ }
129
141
  // Initialize Oracle
130
142
  const oracle = new Oracle(config);
131
143
  try {
@@ -14,4 +14,5 @@ export const PATHS = {
14
14
  browser: path.join(MORPHEUS_ROOT, 'cache', 'browser'),
15
15
  commands: path.join(MORPHEUS_ROOT, 'commands'),
16
16
  mcps: path.join(MORPHEUS_ROOT, 'mcps.json'),
17
+ skills: path.join(MORPHEUS_ROOT, 'skills'),
17
18
  };
@@ -28,6 +28,9 @@ export const ApocConfigSchema = LLMConfigSchema.extend({
28
28
  });
29
29
  export const NeoConfigSchema = LLMConfigSchema;
30
30
  export const TrinityConfigSchema = LLMConfigSchema;
31
+ export const KeymakerConfigSchema = LLMConfigSchema.extend({
32
+ skills_dir: z.string().optional(),
33
+ });
31
34
  export const WebhookConfigSchema = z.object({
32
35
  telegram_notify_all: z.boolean().optional(),
33
36
  }).optional();
@@ -47,6 +50,7 @@ export const ConfigSchema = z.object({
47
50
  neo: NeoConfigSchema.optional(),
48
51
  apoc: ApocConfigSchema.optional(),
49
52
  trinity: TrinityConfigSchema.optional(),
53
+ keymaker: KeymakerConfigSchema.optional(),
50
54
  webhooks: WebhookConfigSchema,
51
55
  audio: AudioConfigSchema.default(DEFAULT_CONFIG.audio),
52
56
  memory: z.object({
package/dist/http/api.js CHANGED
@@ -18,6 +18,7 @@ import { Trinity } from '../runtime/trinity.js';
18
18
  import { ChronosRepository } from '../runtime/chronos/repository.js';
19
19
  import { ChronosWorker } from '../runtime/chronos/worker.js';
20
20
  import { createChronosJobRouter, createChronosConfigRouter } from './routers/chronos.js';
21
+ import { createSkillsRouter } from './routers/skills.js';
21
22
  import { getActiveEnvOverrides } from '../config/precedence.js';
22
23
  async function readLastLines(filePath, n) {
23
24
  try {
@@ -41,6 +42,8 @@ export function createApiRouter(oracle, chronosWorker) {
41
42
  router.use('/chronos', createChronosJobRouter(chronosRepo, worker));
42
43
  router.use('/config/chronos', createChronosConfigRouter(worker));
43
44
  }
45
+ // Mount Skills router
46
+ router.use('/skills', createSkillsRouter());
44
47
  // --- Session Management ---
45
48
  router.get('/sessions', async (req, res) => {
46
49
  try {
@@ -0,0 +1,127 @@
1
+ import { Router } from 'express';
2
+ import { SkillRegistry, updateSkillDelegateDescription } from '../../runtime/skills/index.js';
3
+ import { DisplayManager } from '../../runtime/display.js';
4
+ /**
5
+ * Create the skills API router
6
+ *
7
+ * Endpoints:
8
+ * - GET /api/skills - List all skills
9
+ * - GET /api/skills/:name - Get single skill with content
10
+ * - POST /api/skills/reload - Reload skills from filesystem
11
+ * - POST /api/skills/:name/enable - Enable a skill
12
+ * - POST /api/skills/:name/disable - Disable a skill
13
+ */
14
+ export function createSkillsRouter() {
15
+ const router = Router();
16
+ const display = DisplayManager.getInstance();
17
+ // GET /api/skills - List all skills
18
+ router.get('/', (_req, res) => {
19
+ try {
20
+ const registry = SkillRegistry.getInstance();
21
+ const skills = registry.getAll();
22
+ const response = skills.map(skill => ({
23
+ name: skill.name,
24
+ description: skill.description,
25
+ version: skill.version,
26
+ author: skill.author,
27
+ enabled: skill.enabled,
28
+ tags: skill.tags,
29
+ examples: skill.examples,
30
+ path: skill.path,
31
+ }));
32
+ res.json({
33
+ skills: response,
34
+ total: skills.length,
35
+ enabled: skills.filter(s => s.enabled).length,
36
+ });
37
+ }
38
+ catch (err) {
39
+ display.log(`Skills API error: ${err.message}`, { source: 'SkillsAPI', level: 'error' });
40
+ res.status(500).json({ error: err.message });
41
+ }
42
+ });
43
+ // POST /api/skills/reload - Reload from filesystem
44
+ // Must be before /:name routes
45
+ router.post('/reload', async (_req, res) => {
46
+ try {
47
+ const registry = SkillRegistry.getInstance();
48
+ const result = await registry.reload();
49
+ // Update skill_delegate tool description with new skills
50
+ updateSkillDelegateDescription();
51
+ display.log(`Skills reloaded: ${result.skills.length} loaded, ${result.errors.length} errors`, {
52
+ source: 'SkillsAPI',
53
+ });
54
+ res.json({
55
+ success: true,
56
+ loaded: result.skills.length,
57
+ errors: result.errors.map(e => ({
58
+ directory: e.directory,
59
+ message: e.message,
60
+ })),
61
+ });
62
+ }
63
+ catch (err) {
64
+ display.log(`Skills reload error: ${err.message}`, { source: 'SkillsAPI', level: 'error' });
65
+ res.status(500).json({ error: err.message });
66
+ }
67
+ });
68
+ // GET /api/skills/:name - Get single skill with content
69
+ router.get('/:name', (req, res) => {
70
+ try {
71
+ const { name } = req.params;
72
+ const registry = SkillRegistry.getInstance();
73
+ const skill = registry.get(name);
74
+ if (!skill) {
75
+ return res.status(404).json({ error: `Skill "${name}" not found` });
76
+ }
77
+ const content = registry.getContent(name);
78
+ res.json({
79
+ ...skill,
80
+ content: content || null,
81
+ });
82
+ }
83
+ catch (err) {
84
+ display.log(`Skills API error: ${err.message}`, { source: 'SkillsAPI', level: 'error' });
85
+ res.status(500).json({ error: err.message });
86
+ }
87
+ });
88
+ // POST /api/skills/:name/enable - Enable a skill
89
+ router.post('/:name/enable', (req, res) => {
90
+ try {
91
+ const { name } = req.params;
92
+ const registry = SkillRegistry.getInstance();
93
+ const success = registry.enable(name);
94
+ if (!success) {
95
+ return res.status(404).json({ error: `Skill "${name}" not found` });
96
+ }
97
+ // Update skill_delegate tool description
98
+ updateSkillDelegateDescription();
99
+ display.log(`Skill "${name}" enabled`, { source: 'SkillsAPI' });
100
+ res.json({ success: true, name, enabled: true });
101
+ }
102
+ catch (err) {
103
+ display.log(`Skills API error: ${err.message}`, { source: 'SkillsAPI', level: 'error' });
104
+ res.status(500).json({ error: err.message });
105
+ }
106
+ });
107
+ // POST /api/skills/:name/disable - Disable a skill
108
+ router.post('/:name/disable', (req, res) => {
109
+ try {
110
+ const { name } = req.params;
111
+ const registry = SkillRegistry.getInstance();
112
+ const success = registry.disable(name);
113
+ if (!success) {
114
+ return res.status(404).json({ error: `Skill "${name}" not found` });
115
+ }
116
+ // Update skill_delegate tool description
117
+ updateSkillDelegateDescription();
118
+ display.log(`Skill "${name}" disabled`, { source: 'SkillsAPI' });
119
+ res.json({ success: true, name, enabled: false });
120
+ }
121
+ catch (err) {
122
+ display.log(`Skills API error: ${err.message}`, { source: 'SkillsAPI', level: 'error' });
123
+ res.status(500).json({ error: err.message });
124
+ }
125
+ });
126
+ return router;
127
+ }