@indiccoder/mentis-cli 1.1.2 → 1.1.4

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.
@@ -31,10 +31,18 @@ export class CommandManager {
31
31
  const discovered: Command[] = [];
32
32
 
33
33
  // Personal commands
34
- discovered.push(...await this.discoverCommandsInDirectory(this.personalCommandsDir, 'personal'));
34
+ try {
35
+ discovered.push(...await this.discoverCommandsInDirectory(this.personalCommandsDir, 'personal'));
36
+ } catch (error: any) {
37
+ console.warn(`Warning: Failed to load personal commands from ${this.personalCommandsDir}: ${error.message}`);
38
+ }
35
39
 
36
40
  // Project commands
37
- discovered.push(...await this.discoverCommandsInDirectory(this.projectCommandsDir, 'project'));
41
+ try {
42
+ discovered.push(...await this.discoverCommandsInDirectory(this.projectCommandsDir, 'project'));
43
+ } catch (error: any) {
44
+ console.warn(`Warning: Failed to load project commands from ${this.projectCommandsDir}: ${error.message}`);
45
+ }
38
46
 
39
47
  // Store commands in map (project commands override personal)
40
48
  for (const command of discovered) {
@@ -85,6 +93,7 @@ export class CommandManager {
85
93
  const commandName = this.getCommandName(commandPath, type);
86
94
 
87
95
  if (!commandName) {
96
+ console.warn(`Warning: Invalid command name in ${commandPath} (skipping)`);
88
97
  return null;
89
98
  }
90
99
 
@@ -107,7 +116,13 @@ export class CommandManager {
107
116
 
108
117
  return command;
109
118
  } catch (error: any) {
110
- console.error(`Error parsing command ${commandPath}: ${error.message}`);
119
+ if (error.code === 'ENOENT') {
120
+ console.warn(`Warning: Command file not found: ${commandPath}`);
121
+ } else if (error.code === 'EACCES') {
122
+ console.warn(`Warning: Permission denied reading command: ${commandPath}`);
123
+ } else {
124
+ console.error(`Error parsing command ${commandPath}: ${error.message}`);
125
+ }
111
126
  return null;
112
127
  }
113
128
  }
@@ -222,7 +237,10 @@ export class CommandManager {
222
237
  const bashRegex = /!`([^`]+)`/g;
223
238
  let bashMatch;
224
239
  while ((bashMatch = bashRegex.exec(content)) !== null) {
225
- bashCommands.push(bashMatch[1]);
240
+ const bashCommand = bashMatch[1].trim();
241
+ if (bashCommand) {
242
+ bashCommands.push(bashCommand);
243
+ }
226
244
  }
227
245
 
228
246
  // Remove bash command markers
@@ -232,7 +250,10 @@ export class CommandManager {
232
250
  const fileRegex = /@([^\s]+)/g;
233
251
  let fileMatch;
234
252
  while ((fileMatch = fileRegex.exec(content)) !== null) {
235
- fileReferences.push(fileMatch[1]);
253
+ const fileRef = fileMatch[1].trim();
254
+ if (fileRef && !fileReferences.includes(fileRef)) {
255
+ fileReferences.push(fileRef);
256
+ }
236
257
  }
237
258
 
238
259
  return { content, bashCommands, fileReferences };
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Tests for CommandManager
3
+ */
4
+
5
+ import { CommandManager } from '../CommandManager';
6
+ import { Command } from '../Command';
7
+
8
+ describe('CommandManager', () => {
9
+ let manager: CommandManager;
10
+
11
+ beforeEach(() => {
12
+ manager = new CommandManager();
13
+ });
14
+
15
+ describe('getAllCommands', () => {
16
+ it('should return empty array initially', () => {
17
+ const commands = manager.getAllCommands();
18
+ expect(commands).toEqual([]);
19
+ });
20
+ });
21
+
22
+ describe('getCommandsContext', () => {
23
+ it('should return empty string when no commands', () => {
24
+ const context = manager.getCommandsContext();
25
+ expect(context).toBe('');
26
+ });
27
+ });
28
+
29
+ describe('parseCommand', () => {
30
+ it('should replace $ARGUMENTS placeholder', async () => {
31
+ const command: Command = {
32
+ name: 'echo',
33
+ type: 'personal',
34
+ path: '/echo.md',
35
+ directory: '/commands',
36
+ description: 'Echo arguments',
37
+ frontmatter: {},
38
+ content: 'You said: $ARGUMENTS',
39
+ hasParameters: true
40
+ };
41
+
42
+ const parsed = await manager.parseCommand(command, ['hello', 'world']);
43
+
44
+ expect(parsed.content).toContain('hello world');
45
+ });
46
+
47
+ it('should replace $1, $2 placeholders', async () => {
48
+ const command: Command = {
49
+ name: 'greet',
50
+ type: 'personal',
51
+ path: '/greet.md',
52
+ directory: '/commands',
53
+ description: 'Greet user',
54
+ frontmatter: {},
55
+ content: 'Hello $1, welcome to $2',
56
+ hasParameters: true
57
+ };
58
+
59
+ const parsed = await manager.parseCommand(command, ['Alice', 'Wonderland']);
60
+
61
+ expect(parsed.content).toContain('Hello Alice');
62
+ expect(parsed.content).toContain('welcome to Wonderland');
63
+ });
64
+
65
+ it('should extract bash commands from content', async () => {
66
+ const command: Command = {
67
+ name: 'run-test',
68
+ type: 'personal',
69
+ path: '/run-test.md',
70
+ directory: '/commands',
71
+ description: 'Run tests',
72
+ frontmatter: {},
73
+ content: 'Run tests with !`npm test`',
74
+ hasParameters: false
75
+ };
76
+
77
+ const parsed = await manager.parseCommand(command, []);
78
+
79
+ expect(parsed.bashCommands).toHaveLength(1);
80
+ expect(parsed.bashCommands[0]).toBe('npm test');
81
+ });
82
+ });
83
+ });
package/src/index.ts CHANGED
@@ -1,13 +1,39 @@
1
1
  #!/usr/bin/env node
2
+
3
+ /**
4
+ * Mentis CLI - An Agentic, Multi-Model CLI Coding Assistant
5
+ *
6
+ * @packageDocumentation
7
+ */
8
+
2
9
  import { ReplManager } from './repl/ReplManager';
3
10
 
11
+ /**
12
+ * CLI options for controlling Mentis behavior
13
+ */
4
14
  interface CliOptions {
15
+ /** Resume from the last saved checkpoint */
5
16
  resume: boolean;
17
+ /** Auto-confirm all prompts (skip confirmations) */
6
18
  yolo: boolean;
19
+ /** Run in headless (non-interactive) mode */
7
20
  headless: boolean;
21
+ /** Prompt to execute in headless mode */
8
22
  headlessPrompt?: string;
9
23
  }
10
24
 
25
+ /**
26
+ * Parse command line arguments
27
+ *
28
+ * @returns Parsed command and options
29
+ *
30
+ * @example
31
+ * ```bash
32
+ * mentis --resume
33
+ * mentis -p "fix the bug"
34
+ * mentis --yolo
35
+ * ```
36
+ */
11
37
  function parseArgs(): { command: string | null, options: CliOptions } {
12
38
  const args = process.argv.slice(2);
13
39
  const options: CliOptions = {
@@ -69,7 +95,12 @@ Commands (in REPL):
69
95
  return { command, options };
70
96
  }
71
97
 
72
- async function main() {
98
+ /**
99
+ * Main entry point for Mentis CLI
100
+ *
101
+ * Parses arguments and starts the REPL or update manager
102
+ */
103
+ async function main(): Promise<void> {
73
104
  const { command, options } = parseArgs();
74
105
 
75
106
  // Handle update command
@@ -85,6 +116,7 @@ async function main() {
85
116
  await repl.start();
86
117
  }
87
118
 
119
+ // Start the application
88
120
  main().catch((error) => {
89
121
  console.error('Fatal error:', error);
90
122
  process.exit(1);
@@ -7,6 +7,7 @@ import { OpenAIClient } from '../llm/OpenAIClient';
7
7
 
8
8
  import { ContextManager } from '../context/ContextManager';
9
9
  import { UIManager } from '../ui/UIManager';
10
+ import { InputBox } from '../ui/InputBox';
10
11
  import { WriteFileTool, ReadFileTool, ListDirTool } from '../tools/FileTools';
11
12
  import { SearchFileTool } from '../tools/SearchTools';
12
13
  import { PersistentShellTool } from '../tools/PersistentShellTool';
@@ -229,53 +230,38 @@ export class ReplManager {
229
230
  } catch (e) { }
230
231
  }
231
232
 
233
+ // Initialize InputBox with history
234
+ const inputBox = new InputBox(commandHistory);
235
+
232
236
  while (true) {
233
- // Minimalist Separator
234
- console.log(chalk.gray('────────────────────────────────────────────────────────────────────────────────'));
235
-
236
- // Hint (Claude style puts it below, we put it above for standard terminal compatibility)
237
- console.log(chalk.dim(' ? for shortcuts'));
238
-
239
- const promptText = `> `; // Clean prompt
240
-
241
- // Use readline for basic input to support history
242
- const answer = await new Promise<string>((resolve) => {
243
- const rl = readline.createInterface({
244
- input: process.stdin,
245
- output: process.stdout,
246
- history: commandHistory,
247
- historySize: 1000,
248
- prompt: promptText
249
- });
237
+ // Calculate context usage for display
238
+ const usage = this.contextVisualizer.calculateUsage(this.history);
250
239
 
251
- rl.prompt();
240
+ // Display enhanced input frame
241
+ inputBox.displayFrame({
242
+ messageCount: this.history.length,
243
+ contextPercent: usage.percentage
244
+ });
252
245
 
253
- rl.on('line', (line) => {
254
- rl.close();
255
- resolve(line);
256
- });
246
+ // Get styled input
247
+ const answer = await inputBox.prompt({
248
+ showHint: this.history.length === 0,
249
+ hint: 'Type your message or /help for commands'
257
250
  });
258
251
 
259
- // Update history manually or grab from rl?
260
- // rl.history gets updated when user hits enter.
261
- // But we closed rl. We should manually save the input to our tracking array and file.
262
252
  const input = answer.trim();
263
253
 
264
254
  if (input) {
265
- // Update in-memory history (for next readline instance)
266
- // Readline history has newest at 0.
267
- // Avoid duplicates if needed, but standard shell keeps them.
268
- if (commandHistory[0] !== input) {
269
- commandHistory.unshift(input);
270
- }
255
+ // Update history via InputBox
256
+ inputBox.addToHistory(input);
271
257
 
272
- // Append to file (as standard log, so append at end)
258
+ // Append to file
273
259
  try {
274
260
  fs.appendFileSync(HISTORY_FILE, input + '\n');
275
261
  } catch (e) { }
276
262
  }
277
263
 
278
- if (!answer.trim()) continue; // Skip empty but allow it to close readline loop
264
+ if (!input) continue;
279
265
 
280
266
  if (input.startsWith('/')) {
281
267
  await this.handleCommand(input);
@@ -1,26 +1,96 @@
1
1
  /**
2
- * Skill data structure for Agent Skills system
3
- * Based on Claude Code's Agent Skills format
2
+ * Agent Skills System
3
+ *
4
+ * Based on Claude Code's Agent Skills format. Skills are reusable AI agent
5
+ * configurations stored as SKILL.md files in dedicated directories.
6
+ *
7
+ * @packageDocumentation
8
+ *
9
+ * @example
10
+ * ```markdown
11
+ * ---
12
+ * name: code-reviewer
13
+ * description: Use when the user asks for a code review. Examines code for bugs, style issues, and improvements.
14
+ * allowed-tools: [Read, Grep, Glob]
15
+ * ---
16
+ *
17
+ * Review the code for...
18
+ * ```
4
19
  */
5
20
 
21
+ /**
22
+ * Core metadata for an Agent Skill
23
+ *
24
+ * @remarks
25
+ * Skills define specialized AI behaviors with optional tool restrictions.
26
+ * The description should include when to use the skill (e.g., "Use when...").
27
+ */
6
28
  export interface SkillMetadata {
7
- name: string; // Lowercase, numbers, hyphens only (max 64 chars)
8
- description: string; // What it does + when to use it (max 1024 chars)
9
- allowedTools?: string[]; // Optional tool restrictions
29
+ /**
30
+ * Lowercase skill name with numbers and hyphens only (max 64 chars)
31
+ *
32
+ * @pattern ^[a-z0-9-]+$
33
+ * @maxLength 64
34
+ */
35
+ name: string;
36
+ /**
37
+ * What the skill does and when to use it (max 1024 chars)
38
+ *
39
+ * @remarks
40
+ * Should include phrases like "Use when..." or "Use for..."
41
+ * to help the AI understand when to invoke this skill.
42
+ *
43
+ * @maxLength 1024
44
+ */
45
+ description: string;
46
+ /**
47
+ * Optional list of tools the skill is allowed to use
48
+ *
49
+ * @remarks
50
+ * If specified, the AI will be restricted to only these tools
51
+ * when executing this skill.
52
+ */
53
+ allowedTools?: string[];
10
54
  }
11
55
 
56
+ /**
57
+ * A fully loaded Agent Skill
58
+ */
12
59
  export interface Skill extends SkillMetadata {
13
- path: string; // Path to SKILL.md
60
+ /** Absolute path to the SKILL.md file */
61
+ path: string;
62
+ /** Whether this is personal, project, or plugin skill */
14
63
  type: 'personal' | 'project' | 'plugin';
15
- content?: string; // Loaded on demand (progressive disclosure)
16
- directory: string; // Path to skill directory (for resolving supporting files)
17
- isValid: boolean; // Whether skill passes validation
18
- errors?: string[]; // Validation errors if any
64
+ /**
65
+ * Full skill content (loaded on demand)
66
+ *
67
+ * @remarks
68
+ * For progressive disclosure, only metadata is loaded initially.
69
+ * The full content is loaded only when the skill is invoked.
70
+ */
71
+ content?: string;
72
+ /** Directory containing the skill (for resolving supporting files) */
73
+ directory: string;
74
+ /** Whether the skill passes validation */
75
+ isValid: boolean;
76
+ /** Validation errors if any */
77
+ errors?: string[];
19
78
  }
20
79
 
80
+ /**
81
+ * YAML frontmatter parsed from SKILL.md
82
+ */
21
83
  export interface SkillFrontmatter {
84
+ /** Skill name */
22
85
  name: string;
86
+ /** Skill description */
23
87
  description: string;
88
+ /**
89
+ * Optional tool restrictions
90
+ *
91
+ * @remarks
92
+ * YAML key uses kebab-case: allowed-tools
93
+ */
24
94
  'allowed-tools'?: string[];
25
95
  }
26
96
 
@@ -28,13 +98,20 @@ export interface SkillFrontmatter {
28
98
  * Validation result for a skill
29
99
  */
30
100
  export interface SkillValidationResult {
101
+ /** Whether the skill is valid */
31
102
  isValid: boolean;
103
+ /** Validation errors that must be fixed */
32
104
  errors: string[];
105
+ /** Warnings that don't block usage */
33
106
  warnings: string[];
34
107
  }
35
108
 
36
109
  /**
37
- * Skill context format for model injection
110
+ * Minimal skill context for model injection
111
+ *
112
+ * @remarks
113
+ * This is the format used when injecting skills into the system prompt.
114
+ * Only name and description are included (progressive disclosure).
38
115
  */
39
116
  export interface SkillContext {
40
117
  name: string;
@@ -45,7 +122,10 @@ export interface SkillContext {
45
122
  * Options for skill discovery
46
123
  */
47
124
  export interface SkillDiscoveryOptions {
125
+ /** Include personal skills from ~/.mentis/skills */
48
126
  includePersonal?: boolean;
127
+ /** Include project skills from .mentis/skills */
49
128
  includeProject?: boolean;
129
+ /** Include plugin skills (not yet implemented) */
50
130
  includePlugin?: boolean;
51
131
  }
@@ -43,11 +43,19 @@ export class SkillsManager {
43
43
  const discovered: Skill[] = [];
44
44
 
45
45
  if (includePersonal) {
46
- discovered.push(...await this.discoverSkillsInDirectory(this.personalSkillsDir, 'personal'));
46
+ try {
47
+ discovered.push(...await this.discoverSkillsInDirectory(this.personalSkillsDir, 'personal'));
48
+ } catch (error: any) {
49
+ console.warn(`Warning: Failed to load personal skills from ${this.personalSkillsDir}: ${error.message}`);
50
+ }
47
51
  }
48
52
 
49
53
  if (includeProject) {
50
- discovered.push(...await this.discoverSkillsInDirectory(this.projectSkillsDir, 'project'));
54
+ try {
55
+ discovered.push(...await this.discoverSkillsInDirectory(this.projectSkillsDir, 'project'));
56
+ } catch (error: any) {
57
+ console.warn(`Warning: Failed to load project skills from ${this.projectSkillsDir}: ${error.message}`);
58
+ }
51
59
  }
52
60
 
53
61
  // Store skills in map for quick lookup
@@ -102,6 +110,7 @@ export class SkillsManager {
102
110
  const frontmatter = this.extractFrontmatter(content);
103
111
 
104
112
  if (!frontmatter) {
113
+ console.warn(`Warning: Invalid or missing frontmatter in ${skillPath} (skipping)`);
105
114
  return null;
106
115
  }
107
116
 
@@ -124,9 +133,22 @@ export class SkillsManager {
124
133
  errors: validation.errors.length > 0 ? validation.errors : undefined
125
134
  };
126
135
 
136
+ // Log validation warnings
137
+ if (validation.warnings.length > 0) {
138
+ for (const warning of validation.warnings) {
139
+ console.warn(`Warning (${skill.name}): ${warning}`);
140
+ }
141
+ }
142
+
127
143
  return skill;
128
144
  } catch (error: any) {
129
- console.error(`Error loading skill metadata from ${skillPath}: ${error.message}`);
145
+ if (error.code === 'ENOENT') {
146
+ console.warn(`Warning: Skill file not found: ${skillPath}`);
147
+ } else if (error.code === 'EACCES') {
148
+ console.warn(`Warning: Permission denied reading skill: ${skillPath}`);
149
+ } else {
150
+ console.error(`Error loading skill metadata from ${skillPath}: ${error.message}`);
151
+ }
130
152
  return null;
131
153
  }
132
154
  }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Tests for SkillsManager
3
+ */
4
+
5
+ import { SkillsManager } from '../SkillsManager';
6
+ import { Skill } from '../Skill';
7
+
8
+ describe('SkillsManager', () => {
9
+ let manager: SkillsManager;
10
+
11
+ beforeEach(() => {
12
+ manager = new SkillsManager();
13
+ });
14
+
15
+ describe('getAllSkills', () => {
16
+ it('should return empty array initially', () => {
17
+ const skills = manager.getAllSkills();
18
+ expect(skills).toEqual([]);
19
+ });
20
+ });
21
+
22
+ describe('getSkill', () => {
23
+ it('should return undefined for non-existent skill', () => {
24
+ const skill = manager.getSkill('non-existent');
25
+ expect(skill).toBeUndefined();
26
+ });
27
+ });
28
+
29
+ describe('getSkillsContext', () => {
30
+ it('should return empty string when no skills', () => {
31
+ const context = manager.getSkillsContext();
32
+ expect(context).toBe('');
33
+ });
34
+ });
35
+
36
+ describe('addSkill', () => {
37
+ it('should add skill to manager', () => {
38
+ const skill: Skill = {
39
+ name: 'test-skill',
40
+ description: 'Test skill',
41
+ path: '/skills/test.md',
42
+ type: 'personal',
43
+ directory: '/skills',
44
+ isValid: true,
45
+ content: 'Test content'
46
+ };
47
+
48
+ manager['skills'].set('test-skill', skill);
49
+ const retrieved = manager.getSkill('test-skill');
50
+
51
+ expect(retrieved).toBeDefined();
52
+ expect(retrieved?.name).toBe('test-skill');
53
+ });
54
+
55
+ it('should return skills context with skills', () => {
56
+ const skill: Skill = {
57
+ name: 'reviewer',
58
+ description: 'Code reviewer skill',
59
+ path: '/skills/reviewer.md',
60
+ type: 'personal',
61
+ directory: '/skills',
62
+ isValid: true,
63
+ content: 'Review code'
64
+ };
65
+
66
+ manager['skills'].set('reviewer', skill);
67
+ const context = manager.getSkillsContext();
68
+
69
+ expect(context).toContain('Available Skills');
70
+ expect(context).toContain('reviewer');
71
+ });
72
+ });
73
+ });