morpheus-cli 0.7.1 → 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.
@@ -0,0 +1,145 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { Keymaker, executeKeymakerTask } from '../keymaker.js';
3
+ import { SkillRegistry } from '../skills/registry.js';
4
+ // Mock all heavy dependencies
5
+ vi.mock('../skills/registry.js', () => ({
6
+ SkillRegistry: {
7
+ getInstance: vi.fn(),
8
+ },
9
+ }));
10
+ vi.mock('../../config/manager.js', () => ({
11
+ ConfigManager: {
12
+ getInstance: vi.fn(() => ({
13
+ get: vi.fn(() => ({
14
+ llm: {
15
+ provider: 'openai',
16
+ model: 'gpt-4o',
17
+ temperature: 0.7,
18
+ },
19
+ keymaker: {
20
+ provider: 'openai',
21
+ model: 'gpt-4o',
22
+ personality: 'versatile_specialist',
23
+ },
24
+ })),
25
+ })),
26
+ },
27
+ }));
28
+ vi.mock('../display.js', () => ({
29
+ DisplayManager: {
30
+ getInstance: vi.fn(() => ({
31
+ log: vi.fn(),
32
+ })),
33
+ },
34
+ }));
35
+ vi.mock('../../devkit/index.js', () => ({
36
+ buildDevKit: vi.fn(() => [
37
+ { name: 'fs_read', description: 'Read file' },
38
+ { name: 'shell_exec', description: 'Execute shell command' },
39
+ ]),
40
+ }));
41
+ vi.mock('../tools/factory.js', () => ({
42
+ Construtor: {
43
+ create: vi.fn(() => Promise.resolve([
44
+ { name: 'mcp_tool', description: 'MCP tool' },
45
+ ])),
46
+ },
47
+ }));
48
+ vi.mock('../tools/index.js', () => ({
49
+ morpheusTools: [
50
+ { name: 'morpheus_tool', description: 'Internal tool' },
51
+ ],
52
+ }));
53
+ vi.mock('../providers/factory.js', () => ({
54
+ ProviderFactory: {
55
+ createBare: vi.fn(() => Promise.resolve({
56
+ invoke: vi.fn(),
57
+ })),
58
+ },
59
+ }));
60
+ vi.mock('../memory/sqlite.js', () => {
61
+ return {
62
+ SQLiteChatMessageHistory: class MockSQLiteChatMessageHistory {
63
+ addMessage = vi.fn();
64
+ close = vi.fn();
65
+ },
66
+ };
67
+ });
68
+ describe('Keymaker', () => {
69
+ const mockRegistry = {
70
+ get: vi.fn(),
71
+ getContent: vi.fn(),
72
+ };
73
+ beforeEach(() => {
74
+ vi.clearAllMocks();
75
+ SkillRegistry.getInstance.mockReturnValue(mockRegistry);
76
+ mockRegistry.get.mockReturnValue({
77
+ name: 'test-skill',
78
+ description: 'A test skill',
79
+ tags: ['test'],
80
+ enabled: true,
81
+ });
82
+ mockRegistry.getContent.mockReturnValue('# Test Skill\n\nInstructions here.');
83
+ });
84
+ describe('constructor', () => {
85
+ it('should create instance with skill name and content', () => {
86
+ const keymaker = new Keymaker('test-skill', '# Instructions');
87
+ expect(keymaker).toBeInstanceOf(Keymaker);
88
+ });
89
+ it('should accept custom config', () => {
90
+ const customConfig = {
91
+ llm: { provider: 'anthropic', model: 'claude-3' },
92
+ };
93
+ const keymaker = new Keymaker('test-skill', '# Instructions', customConfig);
94
+ expect(keymaker).toBeInstanceOf(Keymaker);
95
+ });
96
+ });
97
+ describe('initialize()', () => {
98
+ it('should initialize agent with all tools', async () => {
99
+ const { ProviderFactory } = await import('../providers/factory.js');
100
+ const { buildDevKit } = await import('../../devkit/index.js');
101
+ const { Construtor } = await import('../tools/factory.js');
102
+ const keymaker = new Keymaker('test-skill', '# Instructions');
103
+ await keymaker.initialize();
104
+ expect(buildDevKit).toHaveBeenCalled();
105
+ expect(Construtor.create).toHaveBeenCalled();
106
+ expect(ProviderFactory.createBare).toHaveBeenCalled();
107
+ // Verify tools were combined
108
+ const createBareCall = ProviderFactory.createBare.mock.calls[0];
109
+ const tools = createBareCall[1];
110
+ // Should have DevKit (2) + MCP (1) + Morpheus (1) = 4 tools
111
+ expect(tools.length).toBe(4);
112
+ });
113
+ });
114
+ });
115
+ describe('executeKeymakerTask', () => {
116
+ const mockRegistry = {
117
+ get: vi.fn(),
118
+ getContent: vi.fn(),
119
+ };
120
+ beforeEach(() => {
121
+ vi.clearAllMocks();
122
+ SkillRegistry.getInstance.mockReturnValue(mockRegistry);
123
+ });
124
+ it('should throw error when SKILL.md not found', async () => {
125
+ mockRegistry.getContent.mockReturnValue(null);
126
+ await expect(executeKeymakerTask('missing-skill', 'do something')).rejects.toThrow('SKILL.md not found for skill: missing-skill');
127
+ });
128
+ it('should create and execute keymaker with skill content', async () => {
129
+ mockRegistry.getContent.mockReturnValue('# Test Instructions\n\nDo this.');
130
+ mockRegistry.get.mockReturnValue({
131
+ name: 'test-skill',
132
+ description: 'Test skill',
133
+ enabled: true,
134
+ });
135
+ const { ProviderFactory } = await import('../providers/factory.js');
136
+ ProviderFactory.createBare.mockResolvedValue({
137
+ invoke: vi.fn().mockResolvedValue({
138
+ messages: [{ content: 'Task completed successfully.' }],
139
+ }),
140
+ });
141
+ const result = await executeKeymakerTask('test-skill', 'do the task');
142
+ expect(mockRegistry.getContent).toHaveBeenCalledWith('test-skill');
143
+ expect(result).toBe('Task completed successfully.');
144
+ });
145
+ });
@@ -77,50 +77,68 @@ function intervalToCron(expression) {
77
77
  `Supported formats: "every N minutes/hours/days/weeks", "every minute/hour/day/week", ` +
78
78
  `"every monday [at 9am]", "every monday and friday at 18:30", "every weekday", "every weekend", "daily", "weekly".`);
79
79
  }
80
+ /**
81
+ * Creates a Date in UTC from a local time in a specific timezone.
82
+ * E.g., createDateInTimezone(2026, 2, 26, 23, 0, 'America/Sao_Paulo') returns
83
+ * a Date representing 23:00 BRT = 02:00 UTC (next day).
84
+ */
85
+ function createDateInTimezone(year, month, day, hour, minute, timezone) {
86
+ // Create a candidate UTC date
87
+ const candidateUtc = Date.UTC(year, month - 1, day, hour, minute, 0, 0);
88
+ // Get the offset at that moment for the timezone
89
+ const offsetMs = ianaToOffsetMinutes(timezone, new Date(candidateUtc)) * 60_000;
90
+ // Subtract offset: if BRT is -180 min (-3h), local 23:00 = UTC 23:00 - (-3h) = UTC 02:00
91
+ return new Date(candidateUtc - offsetMs);
92
+ }
93
+ /**
94
+ * Gets the current date components (year, month, day) in a specific timezone.
95
+ */
96
+ function getDatePartsInTimezone(date, timezone) {
97
+ const formatter = new Intl.DateTimeFormat('en-CA', { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit' });
98
+ const parts = formatter.formatToParts(date);
99
+ return {
100
+ year: parseInt(parts.find(p => p.type === 'year').value, 10),
101
+ month: parseInt(parts.find(p => p.type === 'month').value, 10),
102
+ day: parseInt(parts.find(p => p.type === 'day').value, 10),
103
+ };
104
+ }
80
105
  /**
81
106
  * Parses Portuguese time expressions and converts to ISO 8601 format.
82
107
  * Handles patterns like "às 15h", "hoje às 15:30", "amanhã às 9h".
83
108
  */
84
109
  function parsePortugueseTimeExpression(expression, refDate, timezone) {
85
110
  const lower = expression.toLowerCase().trim();
111
+ const { year, month, day } = getDatePartsInTimezone(refDate, timezone);
86
112
  // Pattern: "às 15h", "as 15h", "às 15:30", "as 15:30"
87
113
  const timeOnlyMatch = lower.match(/^(?:à|a)s?\s+(\d{1,2})(?::(\d{2}))?h?$/);
88
114
  if (timeOnlyMatch) {
89
- let hour = parseInt(timeOnlyMatch[1], 10);
115
+ const hour = parseInt(timeOnlyMatch[1], 10);
90
116
  const minute = timeOnlyMatch[2] ? parseInt(timeOnlyMatch[2], 10) : 0;
91
- // Create date by setting hours in the target timezone
92
- // We use Intl.DateTimeFormat to properly handle timezone
93
- const targetDate = new Date();
94
- const tzDate = new Date(targetDate.toLocaleString('en-US', { timeZone: timezone }));
95
- tzDate.setHours(hour, minute, 0, 0);
117
+ let result = createDateInTimezone(year, month, day, hour, minute, timezone);
96
118
  // If time is in the past today, schedule for tomorrow
97
- if (tzDate.getTime() <= refDate.getTime()) {
98
- tzDate.setDate(tzDate.getDate() + 1);
119
+ if (result.getTime() <= refDate.getTime()) {
120
+ result = createDateInTimezone(year, month, day + 1, hour, minute, timezone);
99
121
  }
100
- return tzDate;
122
+ return result;
101
123
  }
102
124
  // Pattern: "hoje às 15h", "hoje as 15:30"
103
125
  const todayMatch = lower.match(/^hoje\s+(?:à|a)s?\s+(\d{1,2})(?::(\d{2}))?h?$/);
104
126
  if (todayMatch) {
105
- let hour = parseInt(todayMatch[1], 10);
127
+ const hour = parseInt(todayMatch[1], 10);
106
128
  const minute = todayMatch[2] ? parseInt(todayMatch[2], 10) : 0;
107
- const tzDate = new Date(new Date().toLocaleString('en-US', { timeZone: timezone }));
108
- tzDate.setHours(hour, minute, 0, 0);
129
+ const result = createDateInTimezone(year, month, day, hour, minute, timezone);
109
130
  // If already passed, return null (can't schedule in the past)
110
- if (tzDate.getTime() <= refDate.getTime()) {
131
+ if (result.getTime() <= refDate.getTime()) {
111
132
  return null;
112
133
  }
113
- return tzDate;
134
+ return result;
114
135
  }
115
136
  // Pattern: "amanhã às 15h", "amanha as 15:30", "amanhã às 15h da tarde"
116
- const tomorrowMatch = lower.match(/^amanhã(?:ã)?\s+(?:à|a)s?\s+(\d{1,2})(?::(\d{2}))?h?(?:\s+(?:da|do)\s+(?:manhã|tarde|noite))?$/);
137
+ const tomorrowMatch = lower.match(/^amanh[aã]\s+(?:à|a)s?\s+(\d{1,2})(?::(\d{2}))?h?(?:\s+(?:da|do)\s+(?:manh[aã]|tarde|noite))?$/);
117
138
  if (tomorrowMatch) {
118
- let hour = parseInt(tomorrowMatch[1], 10);
139
+ const hour = parseInt(tomorrowMatch[1], 10);
119
140
  const minute = tomorrowMatch[2] ? parseInt(tomorrowMatch[2], 10) : 0;
120
- const tzDate = new Date(new Date().toLocaleString('en-US', { timeZone: timezone }));
121
- tzDate.setDate(tzDate.getDate() + 1);
122
- tzDate.setHours(hour, minute, 0, 0);
123
- return tzDate;
141
+ return createDateInTimezone(year, month, day + 1, hour, minute, timezone);
124
142
  }
125
143
  // Pattern: "daqui a X minutos/horas/dias"
126
144
  const relativeMatch = lower.match(/^daqui\s+a\s+(\d+)\s+(minutos?|horas?|dias?|semanas?)$/);
@@ -0,0 +1,162 @@
1
+ import { HumanMessage, SystemMessage, AIMessage } from "@langchain/core/messages";
2
+ import { ConfigManager } from "../config/manager.js";
3
+ import { ProviderFactory } from "./providers/factory.js";
4
+ import { ProviderError } from "./errors.js";
5
+ import { DisplayManager } from "./display.js";
6
+ import { buildDevKit } from "../devkit/index.js";
7
+ import { Construtor } from "./tools/factory.js";
8
+ import { morpheusTools } from "./tools/index.js";
9
+ import { SkillRegistry } from "./skills/registry.js";
10
+ import { TaskRequestContext } from "./tasks/context.js";
11
+ import { SQLiteChatMessageHistory } from "./memory/sqlite.js";
12
+ /**
13
+ * Keymaker is a specialized agent for executing skills.
14
+ * "The one who opens any door" - has access to ALL tools:
15
+ * - DevKit (filesystem, shell, git, browser, network, processes, packages, system)
16
+ * - MCP tools (all configured MCP servers)
17
+ * - Morpheus internal tools
18
+ *
19
+ * Keymaker is instantiated per-task with a specific skill's SKILL.md as context.
20
+ * It executes the skill instructions autonomously and returns the result.
21
+ */
22
+ export class Keymaker {
23
+ agent;
24
+ config;
25
+ display = DisplayManager.getInstance();
26
+ skillName;
27
+ skillContent;
28
+ constructor(skillName, skillContent, config) {
29
+ this.skillName = skillName;
30
+ this.skillContent = skillContent;
31
+ this.config = config || ConfigManager.getInstance().get();
32
+ }
33
+ async initialize() {
34
+ const keymakerConfig = this.config.keymaker || this.config.llm;
35
+ const personality = this.config.keymaker?.personality || 'versatile_specialist';
36
+ // Build DevKit tools (filesystem, shell, git, browser, network, etc.)
37
+ const working_dir = process.cwd();
38
+ const timeout_ms = 30_000;
39
+ await import("../devkit/index.js");
40
+ const devKitTools = buildDevKit({
41
+ working_dir,
42
+ allowed_commands: [], // no restriction
43
+ timeout_ms,
44
+ });
45
+ // Load MCP tools from configured servers
46
+ const mcpTools = await Construtor.create();
47
+ // Combine all tools
48
+ const tools = [...devKitTools, ...mcpTools, ...morpheusTools];
49
+ this.display.log(`Keymaker initialized for skill "${this.skillName}" with ${tools.length} tools (personality: ${personality})`, { source: "Keymaker" });
50
+ try {
51
+ this.agent = await ProviderFactory.createBare(keymakerConfig, tools);
52
+ }
53
+ catch (err) {
54
+ throw new ProviderError(keymakerConfig.provider, err, "Keymaker agent initialization failed");
55
+ }
56
+ }
57
+ /**
58
+ * Execute the skill with the given objective.
59
+ * @param objective User's task description
60
+ * @param taskContext Context for routing responses
61
+ */
62
+ async execute(objective, taskContext) {
63
+ if (!this.agent) {
64
+ await this.initialize();
65
+ }
66
+ this.display.log(`Keymaker executing skill "${this.skillName}": ${objective.slice(0, 80)}...`, { source: "Keymaker" });
67
+ const personality = this.config.keymaker?.personality || 'versatile_specialist';
68
+ const registry = SkillRegistry.getInstance();
69
+ const skill = registry.get(this.skillName);
70
+ const systemMessage = new SystemMessage(`
71
+ You are Keymaker, ${personality === 'versatile_specialist' ? 'a versatile specialist who can open any door' : personality}, executing the "${this.skillName}" skill.
72
+
73
+ You have access to ALL tools:
74
+ - Filesystem: read, write, list, delete, copy, move files and directories
75
+ - Shell: execute commands, spawn processes
76
+ - Git: clone, commit, push, pull, branch, diff
77
+ - Network: HTTP requests, health checks
78
+ - Browser: navigate, screenshot, extract content
79
+ - MCP tools: all configured MCP server tools
80
+ - System: CPU, memory, disk info
81
+
82
+ ## Skill: ${skill?.description || this.skillName}
83
+ ${skill?.tags?.length ? `Tags: ${skill.tags.join(', ')}` : ''}
84
+
85
+ ## Skill Instructions
86
+ ${this.skillContent}
87
+
88
+ ## Your Objective
89
+ ${objective}
90
+
91
+ IMPORTANT:
92
+ 1. Follow the skill instructions carefully to accomplish the objective.
93
+ 2. Be thorough and autonomous. Use the tools at your disposal.
94
+ 3. If you encounter errors, try alternative approaches.
95
+ 4. Provide a clear summary of what was accomplished.
96
+ 5. Respond in the same language as the objective.
97
+
98
+ CRITICAL — NEVER FABRICATE DATA:
99
+ - If none of your available tools can retrieve the requested information, state this clearly.
100
+ - NEVER generate fake data, fake IDs, fake results of any kind.
101
+ - An honest "I cannot do this" is always correct. A fabricated answer is never acceptable.
102
+ `);
103
+ const userMessage = new HumanMessage(objective);
104
+ const messages = [systemMessage, userMessage];
105
+ try {
106
+ const invokeContext = {
107
+ origin_channel: taskContext?.origin_channel ?? "api",
108
+ session_id: taskContext?.session_id ?? "keymaker",
109
+ origin_message_id: taskContext?.origin_message_id,
110
+ origin_user_id: taskContext?.origin_user_id,
111
+ };
112
+ const response = await TaskRequestContext.run(invokeContext, () => this.agent.invoke({ messages }));
113
+ const lastMessage = response.messages[response.messages.length - 1];
114
+ const content = typeof lastMessage.content === "string"
115
+ ? lastMessage.content
116
+ : JSON.stringify(lastMessage.content);
117
+ // Persist message with token usage metadata (like Trinity/Neo/Apoc)
118
+ const keymakerConfig = this.config.keymaker || this.config.llm;
119
+ const targetSession = taskContext?.session_id ?? "keymaker";
120
+ const history = new SQLiteChatMessageHistory({ sessionId: targetSession });
121
+ try {
122
+ const persisted = new AIMessage(content);
123
+ persisted.usage_metadata =
124
+ lastMessage.usage_metadata ??
125
+ lastMessage.response_metadata?.usage ??
126
+ lastMessage.response_metadata?.tokenUsage ??
127
+ lastMessage.usage;
128
+ persisted.provider_metadata = {
129
+ provider: keymakerConfig.provider,
130
+ model: keymakerConfig.model,
131
+ };
132
+ await history.addMessage(persisted);
133
+ }
134
+ finally {
135
+ history.close();
136
+ }
137
+ this.display.log(`Keymaker completed skill "${this.skillName}" execution`, { source: "Keymaker" });
138
+ return content;
139
+ }
140
+ catch (err) {
141
+ this.display.log(`Keymaker execution error: ${err.message}`, { source: "Keymaker", level: "error" });
142
+ throw err;
143
+ }
144
+ }
145
+ }
146
+ /**
147
+ * Factory function to create and execute a Keymaker task.
148
+ * Used by TaskWorker when routing keymaker tasks.
149
+ *
150
+ * @param skillName Name of the skill to execute
151
+ * @param objective User's task description
152
+ * @param taskContext Optional context for routing responses
153
+ */
154
+ export async function executeKeymakerTask(skillName, objective, taskContext) {
155
+ const registry = SkillRegistry.getInstance();
156
+ const skillContent = registry.getContent(skillName);
157
+ if (!skillContent) {
158
+ throw new Error(`SKILL.md not found for skill: ${skillName}`);
159
+ }
160
+ const keymaker = new Keymaker(skillName, skillContent);
161
+ return keymaker.execute(objective, taskContext);
162
+ }
@@ -15,6 +15,7 @@ import { ApocDelegateTool } from "./tools/apoc-tool.js";
15
15
  import { TrinityDelegateTool } from "./tools/trinity-tool.js";
16
16
  import { TaskQueryTool, chronosTools, timeVerifierTool } from "./tools/index.js";
17
17
  import { MCPManager } from "../config/mcp-manager.js";
18
+ import { SkillRegistry, SkillExecuteTool, SkillDelegateTool, updateSkillToolDescriptions } from "./skills/index.js";
18
19
  export class Oracle {
19
20
  provider;
20
21
  config;
@@ -143,7 +144,8 @@ export class Oracle {
143
144
  // Fail-open: Oracle can still initialize even if catalog refresh fails.
144
145
  await Neo.refreshDelegateCatalog().catch(() => { });
145
146
  await Trinity.refreshDelegateCatalog().catch(() => { });
146
- this.provider = await ProviderFactory.create(this.config.llm, [TaskQueryTool, NeoDelegateTool, ApocDelegateTool, TrinityDelegateTool, timeVerifierTool, ...chronosTools]);
147
+ updateSkillToolDescriptions();
148
+ this.provider = await ProviderFactory.create(this.config.llm, [TaskQueryTool, NeoDelegateTool, ApocDelegateTool, TrinityDelegateTool, SkillExecuteTool, SkillDelegateTool, timeVerifierTool, ...chronosTools]);
147
149
  if (!this.provider) {
148
150
  throw new Error("Provider factory returned undefined");
149
151
  }
@@ -291,6 +293,8 @@ good:
291
293
  - Answer directly acknowledging the fact. Do NOT delegate.
292
294
  bad:
293
295
  - delegate to "neo_delegate" or "apoc_delegate" to save the fact. (Sati handles this automatically in the background)
296
+
297
+ ${SkillRegistry.getInstance().getSystemPromptSection()}
294
298
  `);
295
299
  // Load existing history from database in reverse order (most recent first)
296
300
  let previousMessages = await this.history.getMessages();
@@ -538,7 +542,8 @@ Use it to inform your response and tool selection (if needed), but do not assume
538
542
  }
539
543
  await Neo.refreshDelegateCatalog().catch(() => { });
540
544
  await Trinity.refreshDelegateCatalog().catch(() => { });
541
- this.provider = await ProviderFactory.create(this.config.llm, [TaskQueryTool, NeoDelegateTool, ApocDelegateTool, TrinityDelegateTool, ...chronosTools]);
545
+ updateSkillToolDescriptions();
546
+ this.provider = await ProviderFactory.create(this.config.llm, [TaskQueryTool, NeoDelegateTool, ApocDelegateTool, TrinityDelegateTool, SkillExecuteTool, SkillDelegateTool, ...chronosTools]);
542
547
  await Neo.getInstance().reload();
543
548
  this.display.log(`Oracle and Neo tools reloaded`, { source: 'Oracle' });
544
549
  }
@@ -1,10 +1,79 @@
1
1
  import fs from 'fs-extra';
2
+ import path from 'path';
2
3
  import { PATHS } from '../config/paths.js';
3
4
  import { ConfigManager } from '../config/manager.js';
4
5
  import { DEFAULT_MCP_TEMPLATE } from '../types/mcp.js';
5
6
  import chalk from 'chalk';
6
7
  import ora from 'ora';
7
8
  import { migrateConfigFile } from './migration.js';
9
+ const SKILLS_README = `# Morpheus Skills
10
+
11
+ This folder contains custom skills for Morpheus.
12
+
13
+ ## Creating a Skill
14
+
15
+ 1. Create a folder with your skill name (lowercase, hyphens allowed):
16
+ \`\`\`
17
+ mkdir my-skill
18
+ \`\`\`
19
+
20
+ 2. Create \`SKILL.md\` with YAML frontmatter + instructions:
21
+ \`\`\`markdown
22
+ ---
23
+ name: my-skill
24
+ description: What this skill does (max 500 chars)
25
+ execution_mode: sync
26
+ version: 1.0.0
27
+ author: your-name
28
+ tags:
29
+ - category
30
+ examples:
31
+ - "Example request that triggers this skill"
32
+ ---
33
+
34
+ # My Skill
35
+
36
+ Instructions for Keymaker to follow when executing this skill.
37
+
38
+ ## Steps
39
+ 1. First step
40
+ 2. Second step
41
+
42
+ ## Output Format
43
+ How to format the result.
44
+ \`\`\`
45
+
46
+ ## Execution Modes
47
+
48
+ | Mode | Tool | Description |
49
+ |------|------|-------------|
50
+ | sync | skill_execute | Result returned immediately (default) |
51
+ | async | skill_delegate | Runs in background, notifies when done |
52
+
53
+ **sync** (default): Best for quick tasks like code review, analysis.
54
+ **async**: Best for long-running tasks like builds, deployments.
55
+
56
+ ## How It Works
57
+
58
+ - Oracle lists available skills in its system prompt
59
+ - When a request matches a sync skill, Oracle calls \`skill_execute\`
60
+ - When a request matches an async skill, Oracle calls \`skill_delegate\`
61
+ - Keymaker has access to ALL tools (filesystem, shell, git, MCP, databases)
62
+ - Keymaker follows SKILL.md instructions to complete the task
63
+
64
+ ## Frontmatter Schema
65
+
66
+ | Field | Required | Default | Description |
67
+ |-------|----------|---------|-------------|
68
+ | name | Yes | - | Unique identifier (a-z, 0-9, hyphens) |
69
+ | description | Yes | - | Short description (max 500 chars) |
70
+ | execution_mode | No | sync | sync or async |
71
+ | version | No | - | Semver (e.g., 1.0.0) |
72
+ | author | No | - | Your name |
73
+ | enabled | No | true | true/false |
74
+ | tags | No | - | Array of tags (max 10) |
75
+ | examples | No | - | Example requests (max 5) |
76
+ `;
8
77
  export async function scaffold() {
9
78
  const spinner = ora('Ensuring Morpheus environment...').start();
10
79
  try {
@@ -15,6 +84,7 @@ export async function scaffold() {
15
84
  fs.ensureDir(PATHS.memory),
16
85
  fs.ensureDir(PATHS.cache),
17
86
  fs.ensureDir(PATHS.commands),
87
+ fs.ensureDir(PATHS.skills),
18
88
  ]);
19
89
  // Migrate config.yaml -> zaion.yaml if needed
20
90
  await migrateConfigFile();
@@ -30,6 +100,11 @@ export async function scaffold() {
30
100
  if (!(await fs.pathExists(PATHS.mcps))) {
31
101
  await fs.writeJson(PATHS.mcps, DEFAULT_MCP_TEMPLATE, { spaces: 2 });
32
102
  }
103
+ // Create skills README if not exists
104
+ const skillsReadme = path.join(PATHS.skills, 'README.md');
105
+ if (!(await fs.pathExists(skillsReadme))) {
106
+ await fs.writeFile(skillsReadme, SKILLS_README, 'utf-8');
107
+ }
33
108
  spinner.succeed('Morpheus environment ready at ' + chalk.cyan(PATHS.root));
34
109
  }
35
110
  catch (error) {