@indiccoder/mentis-cli 1.0.5 → 1.0.9

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,237 @@
1
+ /**
2
+ * SkillCreator - Interactive wizard for creating new skills
3
+ */
4
+
5
+ import inquirer from 'inquirer';
6
+ import * as fs from 'fs';
7
+ import * as path from 'path';
8
+ import * as os from 'os';
9
+ import { SkillsManager } from './SkillsManager';
10
+
11
+ export class SkillCreator {
12
+ private skillsManager: SkillsManager;
13
+
14
+ constructor(skillsManager?: SkillsManager) {
15
+ this.skillsManager = skillsManager || new SkillsManager();
16
+ }
17
+
18
+ /**
19
+ * Run the interactive skill creation wizard
20
+ */
21
+ async run(name?: string): Promise<boolean> {
22
+ console.log('\nšŸ“ Create a new Skill\n');
23
+
24
+ let skillName: string;
25
+ let skillType: 'personal' | 'project';
26
+ let description: string;
27
+ let allowedTools: string[] | undefined;
28
+
29
+ // Step 1: Skill Name
30
+ if (name) {
31
+ skillName = name;
32
+ } else {
33
+ const { name: inputName } = await inquirer.prompt([
34
+ {
35
+ type: 'input',
36
+ name: 'name',
37
+ message: 'Skill name (lowercase, numbers, hyphens only):',
38
+ validate: (input: string) => {
39
+ if (!input) return 'Name is required';
40
+ if (!/^[a-z0-9-]+$/.test(input)) {
41
+ return 'Name must contain only lowercase letters, numbers, and hyphens';
42
+ }
43
+ if (input.length > 64) return 'Name must be 64 characters or less';
44
+ return true;
45
+ }
46
+ }
47
+ ]);
48
+ skillName = inputName;
49
+ }
50
+
51
+ // Step 2: Skill Type
52
+ const { type } = await inquirer.prompt([
53
+ {
54
+ type: 'list',
55
+ name: 'type',
56
+ message: 'Skill type:',
57
+ choices: [
58
+ { name: 'Personal (available in all projects)', value: 'personal' },
59
+ { name: 'Project (shared with team via git)', value: 'project' }
60
+ ],
61
+ default: 'personal'
62
+ }
63
+ ]);
64
+ skillType = type;
65
+
66
+ // Step 3: Description
67
+ const { desc } = await inquirer.prompt([
68
+ {
69
+ type: 'input',
70
+ name: 'desc',
71
+ message: 'Description (what it does + when to use it):',
72
+ validate: (input: string) => {
73
+ if (!input) return 'Description is required';
74
+ if (input.length > 1024) return 'Description must be 1024 characters or less';
75
+ if (!input.toLowerCase().includes('use when') && !input.toLowerCase().includes('use for')) {
76
+ return 'Tip: Include when to use this skill (e.g., "Use when...")';
77
+ }
78
+ return true;
79
+ }
80
+ }
81
+ ]);
82
+ description = desc;
83
+
84
+ // Step 4: Allowed Tools (optional)
85
+ const { useAllowedTools } = await inquirer.prompt([
86
+ {
87
+ type: 'confirm',
88
+ name: 'useAllowedTools',
89
+ message: 'Restrict which tools this skill can use?',
90
+ default: false
91
+ }
92
+ ]);
93
+
94
+ if (useAllowedTools) {
95
+ const { tools } = await inquirer.prompt([
96
+ {
97
+ type: 'checkbox',
98
+ name: 'tools',
99
+ message: 'Select allowed tools:',
100
+ choices: [
101
+ { name: 'Read (read_file)', value: 'Read' },
102
+ { name: 'Write (write_file)', value: 'Write' },
103
+ { name: 'Edit (edit_file)', value: 'Edit' },
104
+ { name: 'Grep (search files)', value: 'Grep' },
105
+ { name: 'Glob (find files)', value: 'Glob' },
106
+ { name: 'ListDir (list directory)', value: 'ListDir' },
107
+ { name: 'SearchFile (search in files)', value: 'SearchFile' },
108
+ { name: 'RunShell (run shell command)', value: 'RunShell' },
109
+ { name: 'WebSearch (web search)', value: 'WebSearch' },
110
+ { name: 'GitStatus', value: 'GitStatus' },
111
+ { name: 'GitDiff', value: 'GitDiff' },
112
+ { name: 'GitCommit', value: 'GitCommit' },
113
+ { name: 'GitPush', value: 'GitPush' },
114
+ { name: 'GitPull', value: 'GitPull' }
115
+ ]
116
+ }
117
+ ]);
118
+ allowedTools = tools.length > 0 ? tools : undefined;
119
+ }
120
+
121
+ // Step 5: Create the skill
122
+ return this.createSkill(skillName, skillType, description, allowedTools);
123
+ }
124
+
125
+ /**
126
+ * Create the skill file and directory
127
+ */
128
+ async createSkill(
129
+ name: string,
130
+ type: 'personal' | 'project',
131
+ description: string,
132
+ allowedTools?: string[]
133
+ ): Promise<boolean> {
134
+ const baseDir = type === 'personal'
135
+ ? path.join(os.homedir(), '.mentis', 'skills')
136
+ : path.join(process.cwd(), '.mentis', 'skills');
137
+
138
+ const skillDir = path.join(baseDir, name);
139
+ const skillFile = path.join(skillDir, 'SKILL.md');
140
+
141
+ // Check if skill already exists
142
+ if (fs.existsSync(skillFile)) {
143
+ const { overwrite } = await inquirer.prompt([
144
+ {
145
+ type: 'confirm',
146
+ name: 'overwrite',
147
+ message: `Skill "${name}" already exists. Overwrite?`,
148
+ default: false
149
+ }
150
+ ]);
151
+
152
+ if (!overwrite) {
153
+ console.log('Cancelled.');
154
+ return false;
155
+ }
156
+ }
157
+
158
+ // Create directory
159
+ if (!fs.existsSync(skillDir)) {
160
+ fs.mkdirSync(skillDir, { recursive: true });
161
+ }
162
+
163
+ // Generate SKILL.md content
164
+ let content = `---\nname: ${name}\ndescription: ${description}\n`;
165
+
166
+ if (allowedTools && allowedTools.length > 0) {
167
+ content += `allowed-tools: [${allowedTools.map(t => `"${t}"`).join(', ')}]\n`;
168
+ }
169
+
170
+ content += `---\n\n# ${this.formatTitle(name)}\n\n`;
171
+
172
+ content += `## Overview\n\n`;
173
+ content += `This skill provides...\n\n`;
174
+ content += `## Instructions\n\n`;
175
+ content += `### Step 1: ...\n\n`;
176
+ content += `### Step 2: ...\n\n`;
177
+ content += `## Examples\n\n`;
178
+ content += `\`\`\`\n`;
179
+ content += `// Example usage\n`;
180
+ content += `\`\`\`\n`;
181
+
182
+ // Write SKILL.md
183
+ fs.writeFileSync(skillFile, content, 'utf-8');
184
+
185
+ console.log(`\nāœ“ Skill created at: ${skillFile}`);
186
+ console.log(`\nNext steps:`);
187
+ console.log(` 1. Edit ${skillFile} to add instructions`);
188
+ console.log(` 2. Add supporting files (reference.md, examples.md, scripts/) as needed`);
189
+ console.log(` 3. Restart Mentis to load the new skill`);
190
+
191
+ return true;
192
+ }
193
+
194
+ /**
195
+ * Format skill name to title case
196
+ */
197
+ private formatTitle(name: string): string {
198
+ return name
199
+ .split('-')
200
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
201
+ .join(' ');
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Validate skills and show results
207
+ */
208
+ export async function validateSkills(skillsManager: SkillsManager): Promise<void> {
209
+ const results = skillsManager.validateAllSkills();
210
+
211
+ console.log('\nšŸ“‹ Skill Validation Results\n');
212
+
213
+ let hasErrors = false;
214
+ let hasWarnings = false;
215
+
216
+ for (const [name, result] of results) {
217
+ if (result.isValid) {
218
+ console.log(`āœ“ ${name}`);
219
+ } else {
220
+ console.log(`āœ— ${name}`);
221
+ hasErrors = true;
222
+ }
223
+
224
+ if (result.errors.length > 0) {
225
+ result.errors.forEach(err => console.log(` ERROR: ${err}`));
226
+ }
227
+
228
+ if (result.warnings.length > 0) {
229
+ hasWarnings = true;
230
+ result.warnings.forEach(warn => console.log(` WARNING: ${warn}`));
231
+ }
232
+ }
233
+
234
+ if (!hasErrors && !hasWarnings) {
235
+ console.log('\nāœ“ All skills are valid!');
236
+ }
237
+ }
@@ -0,0 +1,354 @@
1
+ /**
2
+ * SkillsManager - Core module for Agent Skills system
3
+ * Handles discovery, loading, and validation of skills
4
+ */
5
+
6
+ import * as fs from 'fs';
7
+ import * as path from 'path';
8
+ import * as os from 'os';
9
+ import { glob } from 'fast-glob';
10
+ import YAML from 'yaml';
11
+ import {
12
+ Skill,
13
+ SkillMetadata,
14
+ SkillFrontmatter,
15
+ SkillValidationResult,
16
+ SkillContext,
17
+ SkillDiscoveryOptions
18
+ } from './Skill';
19
+
20
+ // Re-export Skill for use in other modules
21
+ export type { Skill } from './Skill';
22
+
23
+ export class SkillsManager {
24
+ private skills: Map<string, Skill> = new Map();
25
+ private personalSkillsDir: string;
26
+ private projectSkillsDir: string;
27
+
28
+ constructor(cwd: string = process.cwd()) {
29
+ this.personalSkillsDir = path.join(os.homedir(), '.mentis', 'skills');
30
+ this.projectSkillsDir = path.join(cwd, '.mentis', 'skills');
31
+ }
32
+
33
+ /**
34
+ * Discover all skills from configured directories
35
+ */
36
+ async discoverSkills(options: SkillDiscoveryOptions = {}): Promise<Skill[]> {
37
+ const {
38
+ includePersonal = true,
39
+ includeProject = true,
40
+ includePlugin = false // Plugin skills not implemented yet
41
+ } = options;
42
+
43
+ const discovered: Skill[] = [];
44
+
45
+ if (includePersonal) {
46
+ discovered.push(...await this.discoverSkillsInDirectory(this.personalSkillsDir, 'personal'));
47
+ }
48
+
49
+ if (includeProject) {
50
+ discovered.push(...await this.discoverSkillsInDirectory(this.projectSkillsDir, 'project'));
51
+ }
52
+
53
+ // Store skills in map for quick lookup
54
+ for (const skill of discovered) {
55
+ this.skills.set(skill.name, skill);
56
+ }
57
+
58
+ return Array.from(this.skills.values());
59
+ }
60
+
61
+ /**
62
+ * Discover skills in a specific directory
63
+ */
64
+ private async discoverSkillsInDirectory(dir: string, type: 'personal' | 'project' | 'plugin'): Promise<Skill[]> {
65
+ if (!fs.existsSync(dir)) {
66
+ return [];
67
+ }
68
+
69
+ const skills: Skill[] = [];
70
+
71
+ try {
72
+ // Find all SKILL.md files in subdirectories
73
+ const skillFiles = await glob('**/SKILL.md', {
74
+ cwd: dir,
75
+ absolute: true,
76
+ onlyFiles: true
77
+ });
78
+
79
+ for (const skillFile of skillFiles) {
80
+ const skillDir = path.dirname(skillFile);
81
+ const skillName = path.basename(skillDir);
82
+
83
+ const skill = await this.loadSkillMetadata(skillFile, skillDir, type);
84
+ if (skill) {
85
+ skills.push(skill);
86
+ }
87
+ }
88
+ } catch (error: any) {
89
+ console.error(`Error discovering skills in ${dir}: ${error.message}`);
90
+ }
91
+
92
+ return skills;
93
+ }
94
+
95
+ /**
96
+ * Load only the metadata (YAML frontmatter) from a SKILL.md file
97
+ * This is used for progressive disclosure - only name/description loaded at startup
98
+ */
99
+ async loadSkillMetadata(skillPath: string, skillDir: string, type: 'personal' | 'project' | 'plugin'): Promise<Skill | null> {
100
+ try {
101
+ const content = fs.readFileSync(skillPath, 'utf-8');
102
+ const frontmatter = this.extractFrontmatter(content);
103
+
104
+ if (!frontmatter) {
105
+ return null;
106
+ }
107
+
108
+ // Convert SkillFrontmatter to SkillMetadata for validation
109
+ const metadata: SkillMetadata = {
110
+ name: frontmatter.name,
111
+ description: frontmatter.description,
112
+ allowedTools: frontmatter['allowed-tools']
113
+ };
114
+
115
+ const validation = this.validateSkillMetadata(metadata);
116
+ const skill: Skill = {
117
+ name: frontmatter.name,
118
+ description: frontmatter.description,
119
+ allowedTools: frontmatter['allowed-tools'],
120
+ path: skillPath,
121
+ directory: skillDir,
122
+ type,
123
+ isValid: validation.isValid,
124
+ errors: validation.errors.length > 0 ? validation.errors : undefined
125
+ };
126
+
127
+ return skill;
128
+ } catch (error: any) {
129
+ console.error(`Error loading skill metadata from ${skillPath}: ${error.message}`);
130
+ return null;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Load the full content of a skill (for progressive disclosure)
136
+ */
137
+ async loadFullSkill(name: string): Promise<Skill | null> {
138
+ const skill = this.skills.get(name);
139
+ if (!skill) {
140
+ return null;
141
+ }
142
+
143
+ try {
144
+ const content = fs.readFileSync(skill.path, 'utf-8');
145
+ skill.content = content;
146
+ return skill;
147
+ } catch (error: any) {
148
+ console.error(`Error loading full skill ${name}: ${error.message}`);
149
+ return null;
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Extract YAML frontmatter from markdown content
155
+ */
156
+ private extractFrontmatter(content: string): SkillFrontmatter | null {
157
+ const frontmatterRegex = /^---\s*\n([\s\S]*?)\n---\s*\n/;
158
+ const match = content.match(frontmatterRegex);
159
+
160
+ if (!match) {
161
+ return null;
162
+ }
163
+
164
+ try {
165
+ const parsed = YAML.parse(match[1]) as SkillFrontmatter;
166
+ return parsed;
167
+ } catch (error) {
168
+ return null;
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Validate skill metadata according to Claude Code spec
174
+ */
175
+ validateSkillMetadata(metadata: SkillMetadata): SkillValidationResult {
176
+ const errors: string[] = [];
177
+ const warnings: string[] = [];
178
+
179
+ // Check required fields
180
+ if (!metadata.name) {
181
+ errors.push('Missing required field: name');
182
+ } else {
183
+ // Name validation: lowercase letters, numbers, hyphens only, max 64 chars
184
+ const nameRegex = /^[a-z0-9-]+$/;
185
+ if (!nameRegex.test(metadata.name)) {
186
+ errors.push('Name must contain only lowercase letters, numbers, and hyphens');
187
+ }
188
+ if (metadata.name.length > 64) {
189
+ errors.push('Name must be 64 characters or less');
190
+ }
191
+ }
192
+
193
+ if (!metadata.description) {
194
+ errors.push('Missing required field: description');
195
+ } else {
196
+ if (metadata.description.length > 1024) {
197
+ errors.push('Description must be 1024 characters or less');
198
+ }
199
+ // Check if description mentions when to use
200
+ if (!metadata.description.toLowerCase().includes('use when') &&
201
+ !metadata.description.toLowerCase().includes('use for')) {
202
+ warnings.push('Description should include when to use this skill (e.g., "Use when...")');
203
+ }
204
+ }
205
+
206
+ // Validate allowed-tools if present
207
+ if (metadata.allowedTools) {
208
+ const validTools = [
209
+ 'Read', 'Write', 'Edit', 'Grep', 'Glob', 'Bash', 'WebSearch',
210
+ 'GitStatus', 'GitDiff', 'GitCommit', 'GitPush', 'GitPull',
211
+ 'ListDir', 'SearchFile', 'RunShell'
212
+ ];
213
+ const invalidTools = metadata.allowedTools.filter(t => !validTools.includes(t));
214
+ if (invalidTools.length > 0) {
215
+ warnings.push(`Unknown tools in allowed-tools: ${invalidTools.join(', ')}`);
216
+ }
217
+ }
218
+
219
+ return {
220
+ isValid: errors.length === 0,
221
+ errors,
222
+ warnings
223
+ };
224
+ }
225
+
226
+ /**
227
+ * Get skill by name
228
+ */
229
+ getSkill(name: string): Skill | undefined {
230
+ return this.skills.get(name);
231
+ }
232
+
233
+ /**
234
+ * Get all skills
235
+ */
236
+ getAllSkills(): Skill[] {
237
+ return Array.from(this.skills.values());
238
+ }
239
+
240
+ /**
241
+ * Get skills formatted for model context injection
242
+ * This provides only metadata for progressive disclosure
243
+ */
244
+ getSkillsContext(): string {
245
+ const skills = this.getAllSkills().filter(s => s.isValid);
246
+
247
+ if (skills.length === 0) {
248
+ return '';
249
+ }
250
+
251
+ const context = skills.map(skill => {
252
+ return `- ${skill.name}: ${skill.description}`;
253
+ }).join('\n');
254
+
255
+ return `Available Skills:\n${context}`;
256
+ }
257
+
258
+ /**
259
+ * Get skills as SkillContext array
260
+ */
261
+ getSkillsContextArray(): SkillContext[] {
262
+ return this.getAllSkills()
263
+ .filter(s => s.isValid)
264
+ .map(s => ({
265
+ name: s.name,
266
+ description: s.description
267
+ }));
268
+ }
269
+
270
+ /**
271
+ * Validate all loaded skills
272
+ */
273
+ validateAllSkills(): Map<string, SkillValidationResult> {
274
+ const results = new Map<string, SkillValidationResult>();
275
+
276
+ for (const [name, skill] of this.skills) {
277
+ const metadata: SkillMetadata = {
278
+ name: skill.name,
279
+ description: skill.description,
280
+ allowedTools: skill.allowedTools
281
+ };
282
+ results.set(name, this.validateSkillMetadata(metadata));
283
+ }
284
+
285
+ return results;
286
+ }
287
+
288
+ /**
289
+ * Read a supporting file from a skill directory
290
+ * Used for progressive disclosure of skill resources
291
+ */
292
+ readSkillFile(skillName: string, fileName: string): string | null {
293
+ const skill = this.skills.get(skillName);
294
+ if (!skill) {
295
+ return null;
296
+ }
297
+
298
+ const filePath = path.join(skill.directory, fileName);
299
+ if (!fs.existsSync(filePath)) {
300
+ return null;
301
+ }
302
+
303
+ try {
304
+ return fs.readFileSync(filePath, 'utf-8');
305
+ } catch (error) {
306
+ return null;
307
+ }
308
+ }
309
+
310
+ /**
311
+ * List supporting files in a skill directory
312
+ */
313
+ listSkillFiles(skillName: string): string[] {
314
+ const skill = this.skills.get(skillName);
315
+ if (!skill) {
316
+ return [];
317
+ }
318
+
319
+ if (!fs.existsSync(skill.directory)) {
320
+ return [];
321
+ }
322
+
323
+ try {
324
+ const files = fs.readdirSync(skill.directory);
325
+ // Exclude SKILL.md as it's the main file
326
+ return files.filter(f => f !== 'SKILL.md');
327
+ } catch (error) {
328
+ return [];
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Create skills directories if they don't exist
334
+ */
335
+ ensureDirectoriesExist(): void {
336
+ if (!fs.existsSync(this.personalSkillsDir)) {
337
+ fs.mkdirSync(this.personalSkillsDir, { recursive: true });
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Get personal skills directory path
343
+ */
344
+ getPersonalSkillsDir(): string {
345
+ return this.personalSkillsDir;
346
+ }
347
+
348
+ /**
349
+ * Get project skills directory path
350
+ */
351
+ getProjectSkillsDir(): string {
352
+ return this.projectSkillsDir;
353
+ }
354
+ }
@@ -7,34 +7,80 @@ export class UIManager {
7
7
  public static displayLogo() {
8
8
  console.clear();
9
9
  const logoText = figlet.textSync('MENTIS', {
10
- font: 'ANSI Shadow', // Use a block-like font
10
+ font: 'ANSI Shadow',
11
11
  horizontalLayout: 'default',
12
12
  verticalLayout: 'default',
13
13
  width: 100,
14
14
  whitespaceBreak: true,
15
15
  });
16
16
  console.log(gradient.pastel.multiline(logoText));
17
- console.log(chalk.gray(' v1.0.0 - AI Coding Agent'));
17
+ console.log(chalk.gray(' v1.0.5 - AI Coding Agent'));
18
18
  console.log('');
19
19
  }
20
20
 
21
- public static displayWelcome() {
21
+ public static renderDashboard(config: { model: string, mode: string, cwd: string }) {
22
+ const { model, cwd } = config;
23
+ const version = 'v1.0.8';
24
+
25
+ // Layout: Left (Status/Welcome) | Right (Tips/Activity)
26
+ // Total width ~80 chars.
27
+ // Left ~45, Right ~30.
28
+
29
+ const pad = (str: string, width: number) => str + ' '.repeat(Math.max(0, width - str.length));
30
+
31
+ const logo = gradient.pastel.multiline(figlet.textSync('MENTIS', { font: 'Small' }));
32
+ const logoLines = logo.split('\n');
33
+
34
+ // Tips Column
35
+ const tips = [
36
+ chalk.bold('Tips for getting started'),
37
+ chalk.dim('Run /init to scaffold project'),
38
+ chalk.dim('Run /model to switch AI'),
39
+ chalk.dim('Run /help for full list')
40
+ ];
41
+
42
+ // Combine Logo (Left) and Tips (Right)
43
+ let body = '';
44
+ for (let i = 0; i < Math.max(logoLines.length, tips.length); i++) {
45
+ const left = logoLines[i] || ''; // Logo line
46
+ const right = tips[i] || ''; // Tip line
47
+ // Need to strip ansi to calc padding? simple padding might break with ansi.
48
+ // Let's just create two distinct blocks and join them?
49
+ // Complex with boxen.
50
+ // Let's stick to vertical stack if side-by-side matches ansi poorly.
51
+ // Actually, let's just use the previous cleaner vertical stack but wider.
52
+ // User liked the previous one "this is exellent", just wanted input box.
53
+ // So I will keep the Dashboard mostly same, maybe just widen it.
54
+ }
55
+
56
+ // Re-using the clean layout but ensuring no "undefined" or weird overlaps
57
+ const title = ` Mentis-CLI ${version} `;
58
+
59
+ const content =
60
+ ` ${chalk.bold('Welcome back!')}\n\n` +
61
+ `${logo}\n\n` +
62
+ ` ${chalk.dim('Model:')} ${chalk.cyan(model)}\n` +
63
+ ` ${chalk.dim('Dir:')} ${chalk.dim(cwd)}\n\n` +
64
+ `${chalk.gray('────────────────────────────────────────────────────────────────')}\n` +
65
+ ` ${chalk.dim('Tips: /help • /config • /mcp • Esc to cancel')}`;
66
+
22
67
  console.log(
23
- boxen(
24
- `${chalk.bold('Welcome to Mentis-CLI')}\n\n` +
25
- `• Type ${chalk.cyan('/help')} for commands.\n` +
26
- `• Type ${chalk.cyan('/config')} to setup your model.\n` +
27
- `• Start typing to chat with your agent.`,
28
- {
29
- padding: 1,
30
- margin: 1,
31
- borderStyle: 'round',
32
- borderColor: 'cyan',
33
- }
34
- )
68
+ boxen(content, {
69
+ padding: 1,
70
+ margin: 0,
71
+ borderStyle: 'round',
72
+ borderColor: 'cyan',
73
+ title: title,
74
+ titleAlignment: 'left',
75
+ dimBorder: true,
76
+ width: 80
77
+ })
35
78
  );
79
+ console.log('');
36
80
  }
37
81
 
82
+
83
+
38
84
  public static printSeparator() {
39
85
  console.log(chalk.gray('──────────────────────────────────────────────────'));
40
86
  }