@indiccoder/mentis-cli 1.0.9 → 1.1.1

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,280 @@
1
+ /**
2
+ * CommandManager - Discover, parse, and execute custom slash commands
3
+ */
4
+
5
+ import * as fs from 'fs';
6
+ import * as path from 'path';
7
+ import * as os from 'os';
8
+ import { glob } from 'fast-glob';
9
+ import YAML from 'yaml';
10
+ import {
11
+ Command,
12
+ CommandFrontmatter,
13
+ CommandExecutionContext,
14
+ ParsedCommand
15
+ } from './Command';
16
+
17
+ export class CommandManager {
18
+ private commands: Map<string, Command> = new Map();
19
+ private personalCommandsDir: string;
20
+ private projectCommandsDir: string;
21
+
22
+ constructor(cwd: string = process.cwd()) {
23
+ this.personalCommandsDir = path.join(os.homedir(), '.mentis', 'commands');
24
+ this.projectCommandsDir = path.join(cwd, '.mentis', 'commands');
25
+ }
26
+
27
+ /**
28
+ * Discover all commands from configured directories
29
+ */
30
+ async discoverCommands(): Promise<Command[]> {
31
+ const discovered: Command[] = [];
32
+
33
+ // Personal commands
34
+ discovered.push(...await this.discoverCommandsInDirectory(this.personalCommandsDir, 'personal'));
35
+
36
+ // Project commands
37
+ discovered.push(...await this.discoverCommandsInDirectory(this.projectCommandsDir, 'project'));
38
+
39
+ // Store commands in map (project commands override personal)
40
+ for (const command of discovered) {
41
+ this.commands.set(command.name, command);
42
+ }
43
+
44
+ return Array.from(this.commands.values());
45
+ }
46
+
47
+ /**
48
+ * Discover commands in a specific directory
49
+ */
50
+ private async discoverCommandsInDirectory(dir: string, type: 'personal' | 'project'): Promise<Command[]> {
51
+ if (!fs.existsSync(dir)) {
52
+ return [];
53
+ }
54
+
55
+ const commands: Command[] = [];
56
+
57
+ try {
58
+ // Find all .md files in subdirectories
59
+ const commandFiles = await glob('**/*.md', {
60
+ cwd: dir,
61
+ absolute: true,
62
+ onlyFiles: true
63
+ });
64
+
65
+ for (const commandFile of commandFiles) {
66
+ const command = await this.parseCommandFile(commandFile, type);
67
+ if (command) {
68
+ commands.push(command);
69
+ }
70
+ }
71
+ } catch (error: any) {
72
+ console.error(`Error discovering commands in ${dir}: ${error.message}`);
73
+ }
74
+
75
+ return commands;
76
+ }
77
+
78
+ /**
79
+ * Parse a command file
80
+ */
81
+ private async parseCommandFile(commandPath: string, type: 'personal' | 'project'): Promise<Command | null> {
82
+ try {
83
+ const content = fs.readFileSync(commandPath, 'utf-8');
84
+ const frontmatter = this.extractFrontmatter(content);
85
+ const commandName = this.getCommandName(commandPath, type);
86
+
87
+ if (!commandName) {
88
+ return null;
89
+ }
90
+
91
+ // Get namespace (subdirectory)
92
+ const relativePath = path.relative(type === 'personal' ? this.personalCommandsDir : this.projectCommandsDir, commandPath);
93
+ const namespace = path.dirname(relativePath) !== '.' ? path.dirname(relativePath) : '';
94
+
95
+ const description = frontmatter.description || this.extractFirstLine(content);
96
+
97
+ const command: Command = {
98
+ name: commandName,
99
+ type,
100
+ path: commandPath,
101
+ directory: path.dirname(commandPath),
102
+ frontmatter,
103
+ content: content,
104
+ description: namespace ? `${description} (${type}:${namespace})` : description,
105
+ hasParameters: content.includes('$1') || content.includes('$ARGUMENTS')
106
+ };
107
+
108
+ return command;
109
+ } catch (error: any) {
110
+ console.error(`Error parsing command ${commandPath}: ${error.message}`);
111
+ return null;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Get command name from file path
117
+ */
118
+ private getCommandName(commandPath: string, type: 'personal' | 'project'): string | null {
119
+ const relativePath = path.relative(type === 'personal' ? this.personalCommandsDir : this.projectCommandsDir, commandPath);
120
+ const nameWithExt = path.basename(relativePath); // e.g., "review.md"
121
+ const name = nameWithExt.replace(/\.md$/, ''); // e.g., "review"
122
+
123
+ if (!name || name.startsWith('.')) {
124
+ return null;
125
+ }
126
+
127
+ return name;
128
+ }
129
+
130
+ /**
131
+ * Extract YAML frontmatter from markdown content
132
+ */
133
+ private extractFrontmatter(content: string): CommandFrontmatter {
134
+ const frontmatterRegex = /^---\s*\n([\s\S]*?)\n---\s*\n/;
135
+ const match = content.match(frontmatterRegex);
136
+
137
+ if (!match) {
138
+ return {};
139
+ }
140
+
141
+ try {
142
+ const parsed = YAML.parse(match[1]) as CommandFrontmatter;
143
+ return parsed;
144
+ } catch (error) {
145
+ return {};
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Extract first line of content as description
151
+ */
152
+ private extractFirstLine(content: string): string {
153
+ // Remove frontmatter
154
+ const withoutFrontmatter = content.replace(/^---\s*\n[\s\S]*?\n---\s*\n/, '');
155
+
156
+ // Get first non-empty line
157
+ const lines = withoutFrontmatter.split('\n');
158
+ for (const line of lines) {
159
+ const trimmed = line.trim();
160
+ if (trimmed && !trimmed.startsWith('#')) {
161
+ return trimmed;
162
+ }
163
+ }
164
+
165
+ return 'No description';
166
+ }
167
+
168
+ /**
169
+ * Get command by name
170
+ */
171
+ getCommand(name: string): Command | undefined {
172
+ return this.commands.get(name);
173
+ }
174
+
175
+ /**
176
+ * Get all commands
177
+ */
178
+ getAllCommands(): Command[] {
179
+ return Array.from(this.commands.values());
180
+ }
181
+
182
+ /**
183
+ * Get commands context for system prompt injection
184
+ */
185
+ getCommandsContext(): string {
186
+ const commands = this.getAllCommands();
187
+
188
+ if (commands.length === 0) {
189
+ return '';
190
+ }
191
+
192
+ const context = commands.map(cmd => {
193
+ let line = `/${cmd.name}`;
194
+ if (cmd.frontmatter['argument-hint']) {
195
+ line += ` ${cmd.frontmatter['argument-hint']}`;
196
+ }
197
+ line += `: ${cmd.description.replace(/\s*\([^)]+\)/, '')}`;
198
+ return line;
199
+ }).join('\n');
200
+
201
+ return `Available Custom Commands:\n${context}`;
202
+ }
203
+
204
+ /**
205
+ * Parse command content and execute substitutions
206
+ */
207
+ async parseCommand(command: Command, args: string[]): Promise<ParsedCommand> {
208
+ let content = command.content;
209
+ const bashCommands: string[] = [];
210
+ const fileReferences: string[] = [];
211
+
212
+ // Remove frontmatter from content
213
+ content = content.replace(/^---\s*\n[\s\S]*?\n---\s*\n/, '');
214
+
215
+ // Substitute $1, $2, etc.
216
+ content = this.substitutePositionalArgs(content, args);
217
+
218
+ // Substitute $ARGUMENTS
219
+ content = this.substituteAllArgs(content, args);
220
+
221
+ // Extract and collect bash commands (!`cmd`)
222
+ const bashRegex = /!`([^`]+)`/g;
223
+ let bashMatch;
224
+ while ((bashMatch = bashRegex.exec(content)) !== null) {
225
+ bashCommands.push(bashMatch[1]);
226
+ }
227
+
228
+ // Remove bash command markers
229
+ content = content.replace(/!`[^`]+`/g, '[BASH_OUTPUT]');
230
+
231
+ // Extract and collect file references (@file)
232
+ const fileRegex = /@([^\s]+)/g;
233
+ let fileMatch;
234
+ while ((fileMatch = fileRegex.exec(content)) !== null) {
235
+ fileReferences.push(fileMatch[1]);
236
+ }
237
+
238
+ return { content, bashCommands, fileReferences };
239
+ }
240
+
241
+ /**
242
+ * Substitute positional arguments ($1, $2, etc.)
243
+ */
244
+ private substitutePositionalArgs(content: string, args: string[]): string {
245
+ return content.replace(/\$(\d+)/g, (match, index) => {
246
+ const argIndex = parseInt(index) - 1;
247
+ return args[argIndex] || '';
248
+ });
249
+ }
250
+
251
+ /**
252
+ * Substitute $ARGUMENTS placeholder
253
+ */
254
+ private substituteAllArgs(content: string, args: string[]): string {
255
+ return content.replace(/\$ARGUMENTS/g, args.join(' '));
256
+ }
257
+
258
+ /**
259
+ * Ensure commands directories exist
260
+ */
261
+ ensureDirectoriesExist(): void {
262
+ if (!fs.existsSync(this.personalCommandsDir)) {
263
+ fs.mkdirSync(this.personalCommandsDir, { recursive: true });
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Get personal commands directory path
269
+ */
270
+ getPersonalCommandsDir(): string {
271
+ return this.personalCommandsDir;
272
+ }
273
+
274
+ /**
275
+ * Get project commands directory path
276
+ */
277
+ getProjectCommandsDir(): string {
278
+ return this.projectCommandsDir;
279
+ }
280
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * SlashCommandTool - Tool for model-invoked custom command execution
3
+ * The model can call this tool when it determines a custom command should be run
4
+ */
5
+
6
+ import { Tool } from '../tools/Tool';
7
+ import { CommandManager } from './CommandManager';
8
+ import { Command } from './Command';
9
+ import * as fs from 'fs';
10
+ import { exec } from 'child_process';
11
+ import { promisify } from 'util';
12
+
13
+ const execAsync = promisify(exec);
14
+
15
+ interface SlashCommandArgs {
16
+ name: string;
17
+ arguments?: string[];
18
+ }
19
+
20
+ export class SlashCommandTool implements Tool {
21
+ name = 'slash_command';
22
+ description = 'Execute a custom slash command. Available commands can be seen in /help. Example: slash_command({ name: "review", arguments: ["src/file.ts"] })';
23
+ parameters = {
24
+ type: 'object',
25
+ properties: {
26
+ name: {
27
+ type: 'string',
28
+ description: 'The name of the custom command to execute'
29
+ },
30
+ arguments: {
31
+ type: 'array',
32
+ items: { type: 'string' },
33
+ description: 'Arguments to pass to the command'
34
+ }
35
+ },
36
+ required: ['name']
37
+ };
38
+
39
+ private commandManager: CommandManager;
40
+
41
+ constructor(commandManager: CommandManager) {
42
+ this.commandManager = commandManager;
43
+ }
44
+
45
+ async execute(args: SlashCommandArgs): Promise<string> {
46
+ const { name, arguments: cmdArgs = [] } = args;
47
+
48
+ if (!name) {
49
+ return 'Error: Command name is required';
50
+ }
51
+
52
+ const command = this.commandManager.getCommand(name);
53
+ if (!command) {
54
+ const availableCommands = this.commandManager.getAllCommands().map(c => c.name).join(', ');
55
+ return `Error: Command "${name}" not found. Available commands: ${availableCommands || 'none'}`;
56
+ }
57
+
58
+ try {
59
+ // Parse command and handle substitutions
60
+ const parsed = await this.commandManager.parseCommand(command, cmdArgs);
61
+ let content = parsed.content;
62
+
63
+ // Execute bash commands and collect results
64
+ const bashResults: string[] = [];
65
+ for (const bashCmd of parsed.bashCommands) {
66
+ try {
67
+ const result = await execAsync(bashCmd);
68
+ bashResults.push(`${bashCmd}\n${result.stdout}`);
69
+ } catch (error: any) {
70
+ bashResults.push(`${bashCmd}\nError: ${error.message}`);
71
+ }
72
+ }
73
+
74
+ // Substitute bash outputs into content
75
+ let bashIndex = 0;
76
+ content = content.replace(/\[BASH_OUTPUT\]/g, () => {
77
+ return bashResults[bashIndex++] || '';
78
+ });
79
+
80
+ // Read file references
81
+ const fileContents: string[] = [];
82
+ for (const filePath of parsed.fileReferences) {
83
+ try {
84
+ const fileContent = fs.readFileSync(filePath, 'utf-8');
85
+ fileContents.push(`\n=== File: ${filePath} ===\n${fileContent}\n=== End of ${filePath} ===\n`);
86
+ } catch (error: any) {
87
+ fileContents.push(`\n=== File: ${filePath} ===\nError: ${error.message}\n=== End of ${filePath} ===\n`);
88
+ }
89
+ }
90
+
91
+ // Substitute file references
92
+ let fileIndex = 0;
93
+ content = content.replace(/@[^\s]+/g, () => {
94
+ return fileContents[fileIndex++] || '';
95
+ });
96
+
97
+ return `# Executing: /${name}${cmdArgs.length ? ' ' + cmdArgs.join(' ') : ''}\n\n${content}`;
98
+ } catch (error: any) {
99
+ return `Error executing command "${name}": ${error.message}`;
100
+ }
101
+ }
102
+ }
103
+
104
+ /**
105
+ * ListCommandsTool - Tool for listing custom commands
106
+ */
107
+ export class ListCommandsTool implements Tool {
108
+ name = 'list_commands';
109
+ description = 'List all available custom slash commands with their descriptions';
110
+ parameters = {
111
+ type: 'object',
112
+ properties: {},
113
+ required: []
114
+ };
115
+
116
+ private commandManager: CommandManager;
117
+
118
+ constructor(commandManager: CommandManager) {
119
+ this.commandManager = commandManager;
120
+ }
121
+
122
+ async execute(): Promise<string> {
123
+ const commands = this.commandManager.getAllCommands();
124
+
125
+ if (commands.length === 0) {
126
+ return 'No custom commands available. Add commands to ~/.mentis/commands/ or .mentis/commands/';
127
+ }
128
+
129
+ let response = `# Custom Commands (${commands.length})\n\n`;
130
+
131
+ // Group by namespace
132
+ const grouped = new Map<string, Command[]>();
133
+ for (const cmd of commands) {
134
+ const ns = cmd.description.match(/\(([^)]+)\)/)?.[1] || cmd.type;
135
+ if (!grouped.has(ns)) {
136
+ grouped.set(ns, []);
137
+ }
138
+ grouped.get(ns)!.push(cmd);
139
+ }
140
+
141
+ for (const [namespace, cmds] of grouped) {
142
+ response += `## ${namespace}\n\n`;
143
+ for (const cmd of cmds) {
144
+ const params = cmd.frontmatter['argument-hint'] ? ` ${cmd.frontmatter['argument-hint']}` : '';
145
+ response += `**/${cmd.name}${params}**\n`;
146
+ response += `${cmd.description}\n\n`;
147
+ }
148
+ }
149
+
150
+ return response;
151
+ }
152
+ }
package/src/index.ts CHANGED
@@ -1,15 +1,75 @@
1
1
  #!/usr/bin/env node
2
2
  import { ReplManager } from './repl/ReplManager';
3
3
 
4
+ interface CliOptions {
5
+ resume: boolean;
6
+ yolo: boolean;
7
+ }
8
+
9
+ function parseArgs(): { command: string | null, options: CliOptions } {
10
+ const args = process.argv.slice(2);
11
+ const options: CliOptions = {
12
+ resume: false,
13
+ yolo: false
14
+ };
15
+
16
+ let command: string | null = null;
17
+
18
+ for (const arg of args) {
19
+ switch (arg) {
20
+ case 'update':
21
+ command = 'update';
22
+ break;
23
+ case '--resume':
24
+ options.resume = true;
25
+ break;
26
+ case '--yolo':
27
+ options.yolo = true;
28
+ break;
29
+ case '-h':
30
+ case '--help':
31
+ console.log(`
32
+ Mentis CLI - AI Coding Assistant
33
+
34
+ Usage:
35
+ mentis Start interactive REPL
36
+ mentis update Update to latest version
37
+ mentis --resume Resume last session
38
+ mentis --yolo Auto-confirm mode (skip confirmations)
39
+
40
+ Options:
41
+ --resume Load latest checkpoint on start
42
+ --yolo Skip all confirmation prompts
43
+ -h, --help Show this help message
44
+
45
+ Commands (in REPL):
46
+ /help Show all available commands
47
+ /resume Resume last session
48
+ /init Initialize project with .mentis.md
49
+ /skills <list|show|create|validate> Manage Agent Skills
50
+ /commands <list|create|validate> Manage Custom Commands
51
+ `);
52
+ process.exit(0);
53
+ break;
54
+ }
55
+ }
56
+
57
+ return { command, options };
58
+ }
59
+
4
60
  async function main() {
5
- if (process.argv.includes('update')) {
61
+ const { command, options } = parseArgs();
62
+
63
+ // Handle update command
64
+ if (command === 'update') {
6
65
  const { UpdateManager } = require('./utils/UpdateManager');
7
66
  const updater = new UpdateManager();
8
67
  await updater.checkAndPerformUpdate(true);
9
68
  return;
10
69
  }
11
70
 
12
- const repl = new ReplManager();
71
+ // Start REPL with options
72
+ const repl = new ReplManager(options);
13
73
  await repl.start();
14
74
  }
15
75