diff --git a/Commands/AboutCommand.cs b/Commands/AboutCommand.cs new file mode 100644 index 0000000..2bbefaa --- /dev/null +++ b/Commands/AboutCommand.cs @@ -0,0 +1,32 @@ +using NShell.Shell; +using NShell.Shell.Commands; +using Spectre.Console; + +namespace NShell.Commands; + +public class AboutCommand : ICustomCommand, IMetadataCommand +{ + public string Name => "about"; + public string Description => "Display information about NShell."; + + public void Execute(ShellContext context, string[] args) + { + AnsiConsole.Clear(); + + var panel = new Panel(new Markup( + $"[bold cyan]NShell[/] - A Custom C# Interactive Shell\n\n" + + $"[grey]Version:[/] [yellow]{Program.VERSION}[/]\n" + + $"[grey]GitHub:[/] [blue]{Program.GITHUB}[/]\n\n" + + $"[grey]Runtime:[/] [green].NET {Environment.Version}[/]\n" + + $"[grey]Platform:[/] [green]{Environment.OSVersion}[/]\n\n" + + $"[dim]Type [yellow]help[/] to see available commands.[/]" + )) + { + Header = new PanelHeader("[bold green] About NShell [/]"), + Border = BoxBorder.Rounded + }; + + AnsiConsole.Write(panel); + Console.WriteLine(); + } +} diff --git a/Commands/AliasCommand.cs b/Commands/AliasCommand.cs new file mode 100644 index 0000000..73be008 --- /dev/null +++ b/Commands/AliasCommand.cs @@ -0,0 +1,70 @@ +using NShell.Shell; +using NShell.Shell.Commands; +using NShell.Shell.Config; +using Spectre.Console; + +namespace NShell.Commands; + +public class AliasCommand : ICustomCommand, IMetadataCommand +{ + public string Name => "alias"; + public string Description => "Create command aliases (e.g., alias ll='ls -la')."; + + // Static dictionary to store aliases + public static Dictionary Aliases { get; } = new Dictionary(); + private static readonly ConfigManager _configManager = new ConfigManager(); + + static AliasCommand() + { + // Load saved aliases on first use + var savedAliases = _configManager.LoadAliases(); + foreach (var alias in savedAliases) + { + Aliases[alias.Key] = alias.Value; + } + } + + public void Execute(ShellContext context, string[] args) + { + if (args.Length == 0) + { + // List all aliases + if (Aliases.Count == 0) + { + AnsiConsole.MarkupLine("[[[yellow]*[/]]] - No aliases defined."); + return; + } + + AnsiConsole.MarkupLine("[bold cyan]Current Aliases:[/]\n"); + foreach (var alias in Aliases.OrderBy(a => a.Key)) + { + AnsiConsole.MarkupLine($"[yellow]{alias.Key}[/]=[green]'{alias.Value}'[/]"); + } + return; + } + + // Join all args to handle aliases with spaces + var fullArg = string.Join(' ', args); + var parts = fullArg.Split('=', 2); + + if (parts.Length != 2) + { + AnsiConsole.MarkupLine("[[[yellow]*[/]]] - Usage: alias name='command'"); + return; + } + + string aliasName = parts[0].Trim(); + string aliasValue = parts[1].Trim(); + + // Remove quotes if present + if ((aliasValue.StartsWith("\"") && aliasValue.EndsWith("\"")) || + (aliasValue.StartsWith("'") && aliasValue.EndsWith("'"))) + { + aliasValue = aliasValue.Substring(1, aliasValue.Length - 2); + } + + Aliases[aliasName] = aliasValue; + _configManager.SaveAliases(Aliases); + AnsiConsole.MarkupLine($"[[[green]+[/]]] - Alias created: [yellow]{aliasName}[/]=[green]'{aliasValue}'[/]"); + } +} diff --git a/Commands/CdCommand.cs b/Commands/CdCommand.cs deleted file mode 100644 index d5f8993..0000000 --- a/Commands/CdCommand.cs +++ /dev/null @@ -1,34 +0,0 @@ - -using System.IO; -using NShell.Shell; -using NShell.Shell.Commands; -using Spectre.Console; - -namespace NShell.Commands; - -public class CdCommand : ICustomCommand -{ - public string Name => "cd"; - - public void Execute(ShellContext context, string[] args) - { - if (args.Length == 0) - { - AnsiConsole.MarkupLine("[[[yellow]*[/]]] - Usage: cd "); - return; - } - - string target = args[0]; - string fullPath = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), target)); - - if (Directory.Exists(fullPath)) - { - Directory.SetCurrentDirectory(fullPath); - context.CurrentDirectory = fullPath; - } - else - { - AnsiConsole.MarkupLine($"[[[red]-[/]]] - No such directory: {fullPath}"); - } - } -} diff --git a/Commands/ClearCommand.cs b/Commands/ClearCommand.cs new file mode 100644 index 0000000..f2ececf --- /dev/null +++ b/Commands/ClearCommand.cs @@ -0,0 +1,16 @@ +using NShell.Shell; +using NShell.Shell.Commands; +using Spectre.Console; + +namespace NShell.Commands; + +public class ClearCommand : ICustomCommand, IMetadataCommand +{ + public string Name => "clear"; + public string Description => "Clear the terminal screen."; + + public void Execute(ShellContext context, string[] args) + { + AnsiConsole.Clear(); + } +} diff --git a/Commands/ExitCommand.cs b/Commands/ExitCommand.cs new file mode 100644 index 0000000..d082ee8 --- /dev/null +++ b/Commands/ExitCommand.cs @@ -0,0 +1,20 @@ +using NShell.Shell; +using NShell.Shell.Commands; +using Spectre.Console; + +namespace NShell.Commands; + +public class ExitCommand : ICustomCommand, IMetadataCommand +{ + public string Name => "exit"; + public string Description => "Exit the shell."; + + public void Execute(ShellContext context, string[] args) + { + // Save history before exiting + Shell.Readline.ReadLine.History.Save(); + + AnsiConsole.MarkupLine("[bold green]Goodbye![/]"); + Environment.Exit(0); + } +} diff --git a/Commands/ExportCommand.cs b/Commands/ExportCommand.cs new file mode 100644 index 0000000..9a4c35e --- /dev/null +++ b/Commands/ExportCommand.cs @@ -0,0 +1,50 @@ +using NShell.Shell; +using NShell.Shell.Commands; +using Spectre.Console; + +namespace NShell.Commands; + +public class ExportCommand : ICustomCommand, IMetadataCommand +{ + public string Name => "export"; + public string Description => "Set environment variables (e.g., export VAR=value)."; + + public void Execute(ShellContext context, string[] args) + { + if (args.Length == 0) + { + // Display all environment variables + var envVars = Environment.GetEnvironmentVariables(); + var sortedKeys = envVars.Keys.Cast().OrderBy(k => k); + + foreach (var key in sortedKeys) + { + AnsiConsole.MarkupLine($"[cyan]{key}[/]=[yellow]{envVars[key]}[/]"); + } + return; + } + + foreach (var arg in args) + { + var parts = arg.Split('=', 2); + if (parts.Length != 2) + { + AnsiConsole.MarkupLine($"[[[yellow]*[/]]] - Invalid format: {arg}. Use: export VAR=value"); + continue; + } + + string varName = parts[0].Trim(); + string varValue = parts[1].Trim(); + + // Remove quotes if present + if ((varValue.StartsWith("\"") && varValue.EndsWith("\"")) || + (varValue.StartsWith("'") && varValue.EndsWith("'"))) + { + varValue = varValue.Substring(1, varValue.Length - 2); + } + + Environment.SetEnvironmentVariable(varName, varValue); + AnsiConsole.MarkupLine($"[[[green]+[/]]] - Set [cyan]{varName}[/]=[yellow]{varValue}[/]"); + } + } +} diff --git a/Commands/HelpCommand.cs b/Commands/HelpCommand.cs new file mode 100644 index 0000000..e33a56b --- /dev/null +++ b/Commands/HelpCommand.cs @@ -0,0 +1,38 @@ +using NShell.Shell; +using NShell.Shell.Commands; +using Spectre.Console; + +namespace NShell.Commands; + +public class HelpCommand : ICustomCommand, IMetadataCommand +{ + public string Name => "help"; + public string Description => "Display help information about available commands."; + + public void Execute(ShellContext context, string[] args) + { + AnsiConsole.MarkupLine("[bold cyan]NShell - Available Commands[/]\n"); + + var table = new Table(); + table.AddColumn("[bold]Command[/]"); + table.AddColumn("[bold]Description[/]"); + + // Add custom commands with descriptions + foreach (var cmd in CommandParser.CustomCommands.Values.OrderBy(c => c.Name)) + { + string description = "No description available"; + if (cmd is IMetadataCommand metaCmd) + { + description = metaCmd.Description; + } + + table.AddRow($"[yellow]{cmd.Name}[/]", description); + } + + AnsiConsole.Write(table); + + AnsiConsole.MarkupLine($"\n[grey]Total custom commands: {CommandParser.CustomCommands.Count}[/]"); + AnsiConsole.MarkupLine($"[grey]Total system commands: {CommandParser.SystemCommands.Count}[/]"); + AnsiConsole.MarkupLine("\n[grey]Type a command name to execute it, or use Tab for auto-completion.[/]"); + } +} diff --git a/Commands/HistoryCommand.cs b/Commands/HistoryCommand.cs new file mode 100644 index 0000000..649dcbd --- /dev/null +++ b/Commands/HistoryCommand.cs @@ -0,0 +1,57 @@ +using NShell.Shell; +using NShell.Shell.Commands; +using NShell.Shell.Readline; +using Spectre.Console; + +namespace NShell.Commands; + +public class HistoryCommand : ICustomCommand, IMetadataCommand +{ + public string Name => "history"; + public string Description => "Display command history."; + + public void Execute(ShellContext context, string[] args) + { + int displayCount = 20; // Default to last 20 commands + int historyCount = ReadLine.History.Count; + + if (args.Length > 0) + { + if (args[0] == "-c" || args[0] == "--clear") + { + // Clear history - not implemented as it would require HistoryManager changes + AnsiConsole.MarkupLine("[[[yellow]*[/]]] - History clearing not yet implemented."); + return; + } + else if (int.TryParse(args[0], out int count)) + { + displayCount = count; + } + else + { + AnsiConsole.MarkupLine("[[[yellow]*[/]]] - Usage: history [number] or history -c"); + return; + } + } + + // Display the last N commands + int startIndex = Math.Max(0, historyCount - displayCount); + + if (historyCount == 0) + { + AnsiConsole.MarkupLine("[[[yellow]*[/]]] - No commands in history."); + return; + } + + AnsiConsole.MarkupLine($"[bold cyan]Command History (last {Math.Min(displayCount, historyCount)} commands):[/]\n"); + + for (int i = startIndex; i < historyCount; i++) + { + var command = ReadLine.History.GetAt(i); + if (command != null) + { + AnsiConsole.MarkupLine($" [grey]{i + 1,4}[/] {command}"); + } + } + } +} diff --git a/Commands/PrintEnvCommand.cs b/Commands/PrintEnvCommand.cs new file mode 100644 index 0000000..8a228d9 --- /dev/null +++ b/Commands/PrintEnvCommand.cs @@ -0,0 +1,42 @@ +using NShell.Shell; +using NShell.Shell.Commands; +using Spectre.Console; + +namespace NShell.Commands; + +public class PrintEnvCommand : ICustomCommand, IMetadataCommand +{ + public string Name => "printenv"; + public string Description => "Print environment variables."; + + public void Execute(ShellContext context, string[] args) + { + if (args.Length == 0) + { + // Display all environment variables + var envVars = Environment.GetEnvironmentVariables(); + var sortedKeys = envVars.Keys.Cast().OrderBy(k => k); + + foreach (var key in sortedKeys) + { + AnsiConsole.MarkupLine($"[cyan]{key}[/]=[yellow]{envVars[key]}[/]"); + } + } + else + { + // Display specific environment variables + foreach (var varName in args) + { + var value = Environment.GetEnvironmentVariable(varName); + if (value != null) + { + AnsiConsole.MarkupLine($"[cyan]{varName}[/]=[yellow]{value}[/]"); + } + else + { + AnsiConsole.MarkupLine($"[[[yellow]*[/]]] - Variable [cyan]{varName}[/] is not set."); + } + } + } + } +} diff --git a/Commands/UnaliasCommand.cs b/Commands/UnaliasCommand.cs new file mode 100644 index 0000000..9fa984f --- /dev/null +++ b/Commands/UnaliasCommand.cs @@ -0,0 +1,36 @@ +using NShell.Shell; +using NShell.Shell.Commands; +using NShell.Shell.Config; +using Spectre.Console; + +namespace NShell.Commands; + +public class UnaliasCommand : ICustomCommand, IMetadataCommand +{ + public string Name => "unalias"; + public string Description => "Remove command aliases."; + private static readonly ConfigManager _configManager = new ConfigManager(); + + public void Execute(ShellContext context, string[] args) + { + if (args.Length == 0) + { + AnsiConsole.MarkupLine("[[[yellow]*[/]]] - Usage: unalias name1 [name2 ...]"); + return; + } + + foreach (var aliasName in args) + { + if (AliasCommand.Aliases.Remove(aliasName)) + { + AnsiConsole.MarkupLine($"[[[green]+[/]]] - Removed alias: [yellow]{aliasName}[/]"); + } + else + { + AnsiConsole.MarkupLine($"[[[yellow]*[/]]] - Alias not found: [yellow]{aliasName}[/]"); + } + } + + _configManager.SaveAliases(AliasCommand.Aliases); + } +} diff --git a/Commands/UnsetCommand.cs b/Commands/UnsetCommand.cs new file mode 100644 index 0000000..7e9c3a2 --- /dev/null +++ b/Commands/UnsetCommand.cs @@ -0,0 +1,26 @@ +using NShell.Shell; +using NShell.Shell.Commands; +using Spectre.Console; + +namespace NShell.Commands; + +public class UnsetCommand : ICustomCommand, IMetadataCommand +{ + public string Name => "unset"; + public string Description => "Remove environment variables."; + + public void Execute(ShellContext context, string[] args) + { + if (args.Length == 0) + { + AnsiConsole.MarkupLine("[[[yellow]*[/]]] - Usage: unset VAR1 [VAR2 ...]"); + return; + } + + foreach (var varName in args) + { + Environment.SetEnvironmentVariable(varName, null); + AnsiConsole.MarkupLine($"[[[green]+[/]]] - Unset [cyan]{varName}[/]"); + } + } +} diff --git a/Commands/WhichCommand.cs b/Commands/WhichCommand.cs new file mode 100644 index 0000000..9d3297e --- /dev/null +++ b/Commands/WhichCommand.cs @@ -0,0 +1,57 @@ +using NShell.Shell; +using NShell.Shell.Commands; +using Spectre.Console; + +namespace NShell.Commands; + +public class WhichCommand : ICustomCommand, IMetadataCommand +{ + public string Name => "which"; + public string Description => "Locate a command and show its path."; + + public void Execute(ShellContext context, string[] args) + { + if (args.Length == 0) + { + AnsiConsole.MarkupLine("[[[yellow]*[/]]] - Usage: which "); + return; + } + + foreach (var cmdName in args) + { + // Check if it's a custom command + if (CommandParser.CustomCommands.ContainsKey(cmdName)) + { + AnsiConsole.MarkupLine($"[green]{cmdName}[/]: [cyan]built-in shell command[/]"); + continue; + } + + // Check if it's a system command + if (CommandParser.SystemCommands.Contains(cmdName)) + { + var paths = new[] { "/usr/bin", "/usr/local/bin", "/usr/games", "/bin", "/sbin", "/usr/sbin" }; + bool found = false; + + foreach (var dir in paths) + { + var fullPath = Path.Combine(dir, cmdName); + if (File.Exists(fullPath)) + { + AnsiConsole.MarkupLine($"[cyan]{fullPath}[/]"); + found = true; + break; + } + } + + if (!found) + { + AnsiConsole.MarkupLine($"[[[red]-[/]]] - {cmdName}: command not found"); + } + } + else + { + AnsiConsole.MarkupLine($"[[[red]-[/]]] - {cmdName}: command not found"); + } + } + } +} diff --git a/Program.cs b/Program.cs index 8cfbd1f..1ecb174 100644 --- a/Program.cs +++ b/Program.cs @@ -8,11 +8,13 @@ public class Program { - public static readonly string VERSION = "0.3.0-pre"; + public static readonly string VERSION = "0.5.1"; public static readonly string GITHUB = "https://github.com/onihilist/NShell"; public static async Task Main(string[] args) { + bool noBanner = false; + if (args.Length > 0) { switch (args[0]) @@ -23,30 +25,50 @@ public static async Task Main(string[] args) return; case "--help": case "-h": - Console.WriteLine("Usage: nshell [--version | --help]"); + Console.WriteLine("Usage: nshell [--version | --help | --no-banner]"); + Console.WriteLine("\nOptions:"); + Console.WriteLine(" --version, -v Show version information"); + Console.WriteLine(" --help, -h Show this help message"); + Console.WriteLine(" --no-banner Start without the welcome banner"); return; + case "--no-banner": + noBanner = true; + break; } } AnsiConsole.Clear(); - AnsiConsole.Markup($"Welcome {Environment.UserName} to NShell !\n\n"); - AnsiConsole.Markup($"\tversion : {VERSION}\n"); - AnsiConsole.Markup($"\tgithub : {GITHUB}\n"); - AnsiConsole.Markup("\n"); + + if (!noBanner) + { + AnsiConsole.Markup($"Welcome {Environment.UserName} to NShell !\n\n"); + AnsiConsole.Markup($"\tversion : {VERSION}\n"); + AnsiConsole.Markup($"\tgithub : {GITHUB}\n"); + AnsiConsole.Markup("\n"); + } AnsiConsole.Markup("[bold cyan][[*]] - Booting NShell...[/]\n"); ShellContext context = new(); - PluginLoader plugins = new(); AnsiConsole.Markup("[bold cyan][[*]] - Loading command(s)...[/]\n"); CommandParser parser = new(); + PluginLoader plugins = new(); AnsiConsole.Markup("[bold cyan][[*]] - Loading plugin(s)...[/]\n"); plugins.LoadPlugins(); - + parser.LoadCommands(); + AppDomain.CurrentDomain.ProcessExit += (_, _) => { ReadLine.History.Save(); }; - await GlitchedPrint("[+] - System Online", TimeSpan.FromMilliseconds(20)); + if (!noBanner) + { + await GlitchedPrint("[+] - System Online", TimeSpan.FromMilliseconds(20)); + } + else + { + AnsiConsole.MarkupLine("[bold green][[+]] - System Online[/]"); + } + string inputBuffer; while (true) diff --git a/README.md b/README.md index 70f7f5a..a8bd6a6 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,15 @@ It's designed for hackers, shell lovers, and those who enjoy boot sequences. - ✅ Spectre.Console markup support for colors, glitches, animations - ✅ Full AOT support (with manual command registration) - ✅ Future-proof extensibility (plugin-style architecture) +- ✅ Tab completion for commands and file paths +- ✅ Command history with Ctrl+R reverse search +- ✅ Command aliases (alias/unalias) +- ✅ Environment variable management (export/unset/printenv) +- ✅ Word-based cursor navigation (Ctrl+Left/Right) +- ✅ Rich built-in commands (help, history, which, pwd, echo, clear, exit) +- ✅ Output redirection (>, >>) +- ✅ Command chaining (&&, ||, ;) +- ✅ Basic piping support (|) --- @@ -69,19 +78,128 @@ This is the result : --- +### ⚙️ Plugin(s) + +Since version 0.5.0, you can build your own plugin (DLL) and add this to NShell. +This is a simple exemple : + +```csharp +using NShell.Shell.Commands; +using NShell.Shell; +using Spectre.Console; + +namespace HelloPlugin +{ + public class HelloPlugin : ICustomCommand + { + public string Name => "hello"; + + public HelloPlugin() + { + CommandRegistry.Register(this); + } + + public void Execute(ShellContext context, string[] args) + { + AnsiConsole.MarkupLine("[green]Hello from HelloPlugin![/]"); + } + } +} +``` + +And **DON'T FORGET TO USE THE SAME IMPORTS AS NSHELL** ! +With the same version of `Spectre.Console (0.50.0) + +```xml + + + net8.0 + enable + enable + + + + + + + +``` + +--- + ### 📡 Roadmap v1.0.0 - [PROGRESS] Plugin support (dynamic loading) - [OK] Fix neofetch shell version - [OK] Fix interactive commands/scripts running configuration -- [PROGRESS] Autocomplete -- [PROGRESS] Command history +- [OK] Autocomplete +- [OK] Command history - [OK] Profiles and theme switching - [OK] Remove Bash FallBack - [OK] Themes & ThemeLoader --- +### 🔨 Built-in Commands + +NShell comes with a rich set of built-in commands: + +| Command | Description | +|---------|-------------| +| `help` | Display all available commands with descriptions | +| `about` | Display information about NShell | +| `exit` | Exit the shell gracefully | +| `clear` | Clear the terminal screen | +| `cd ` | Change directory | +| `pwd` | Print current working directory | +| `echo ` | Display text (supports variable expansion) | +| `history [n]` | Show command history (optionally last n commands) | +| `alias name='cmd'` | Create command alias (persisted across sessions) | +| `unalias name` | Remove command alias | +| `export VAR=value` | Set environment variable | +| `unset VAR` | Remove environment variable | +| `printenv [VAR]` | Print environment variables | +| `which ` | Show path to command | +| `settheme ` | Change shell theme | + +**Keyboard Shortcuts:** +- `Tab` - Auto-complete commands and paths +- `Ctrl+R` - Reverse history search +- `Ctrl+A` / `Home` - Move cursor to start of line +- `Ctrl+E` / `End` - Move cursor to end of line +- `Ctrl+Left/Right` - Move cursor by word +- `Up/Down` - Navigate command history + +**Advanced Shell Features:** +- Command chaining with `&&` (run if previous succeeded), `||` (run if previous failed), and `;` (always run) +- Output redirection with `>` (overwrite) and `>>` (append) +- Basic piping with `|` (pipe output between commands) +- Smart command suggestions with "Did you mean...?" for typos +- Persistent aliases (saved in `~/.nshell/nshellrc.json`) + +**Examples:** +```bash +# Chain commands +echo "Building..." && dotnet build && echo "Success!" || echo "Failed!" + +# Redirect output +echo "Hello World" > output.txt +history 50 >> history.log + +# Use aliases (persisted across sessions) +alias ll='ls -la' +ll + +# Environment variables +export MY_VAR="Hello" +echo {MY_VAR} + +# Fast startup +nshell --no-banner +``` + +--- + ### 🔧 Troubleshooting If you have any problem with **NShell**, or it locks you out of a proper shell, diff --git a/Shell/Commands/CommandParser.cs b/Shell/Commands/CommandParser.cs index 9462412..0c65126 100644 --- a/Shell/Commands/CommandParser.cs +++ b/Shell/Commands/CommandParser.cs @@ -9,7 +9,7 @@ namespace NShell.Shell.Commands; /// public class CommandParser { - public static readonly Dictionary CustomCommands = new(); + internal static readonly Dictionary CustomCommands = new(); public static readonly HashSet SystemCommands = new(); private static readonly HashSet InteractiveCommands = new() { @@ -19,19 +19,17 @@ public class CommandParser /// /// Constructor that loads the commands when the parser is instantiated. /// - public CommandParser() - { - LoadCommands(); - } + public CommandParser(){} /// /// Loads all the custom commands and system commands from predefined directories. /// - private void LoadCommands() + internal void LoadCommands() { + // Load all commands from the CommandRegistry (including plugin commands) foreach (var command in CommandRegistry.GetAll()) { - CustomCommands[command.Name] = command; + CustomCommands[command.Name.ToLower()] = command; AnsiConsole.MarkupLine($"\t[[[green]+[/]]] - Loaded custom command: [yellow]{command.Name}[/]"); } @@ -68,7 +66,7 @@ private void LoadSystemCommands() /// /// Attempts to execute a command from the provided command line input. - /// Handles variable expansion, root privileges, and command execution. + /// Handles variable expansion, root privileges, piping, redirection, chaining, and command execution. /// /// The command line string entered by the user. /// The shell context that contains environment variables and the current directory. @@ -76,21 +74,195 @@ private void LoadSystemCommands() public bool TryExecute(string commandLine, ShellContext context) { var expanded = context.ExpandVariables(commandLine); - var parts = expanded.Split(' ', StringSplitOptions.RemoveEmptyEntries); + + // Handle command chaining (&&, ||, ;) + if (expanded.Contains("&&") || expanded.Contains("||") || expanded.Contains(';')) + { + return ExecuteChainedCommands(expanded, context); + } + + // Handle piping (|) + if (expanded.Contains('|')) + { + return ExecutePipeline(expanded, context); + } + + // Handle output redirection (>, >>) + string? redirectFile = null; + bool appendMode = false; + + if (expanded.Contains(">>")) + { + var redirectParts = expanded.Split(">>", 2); + expanded = redirectParts[0].Trim(); + redirectFile = redirectParts[1].Trim(); + appendMode = true; + } + else if (expanded.Contains('>')) + { + var redirectParts = expanded.Split('>', 2); + expanded = redirectParts[0].Trim(); + redirectFile = redirectParts[1].Trim(); + appendMode = false; + } + + // Redirect output if needed + TextWriter? originalOut = null; + StreamWriter? fileWriter = null; + + if (redirectFile != null) + { + try + { + originalOut = Console.Out; + fileWriter = new StreamWriter(redirectFile, appendMode); + Console.SetOut(fileWriter); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[[[red]-[/]]] - Error opening file for redirection: {ex.Message}"); + return true; + } + } + + try + { + return ExecuteSingleCommand(expanded, context); + } + finally + { + // Restore original output + if (originalOut != null) + { + Console.SetOut(originalOut); + fileWriter?.Close(); + } + } + } + + /// + /// Executes chained commands with &&, ||, or ; + /// + private bool ExecuteChainedCommands(string commandLine, ShellContext context) + { + var semicolonParts = SplitByOperator(commandLine, ';'); + + foreach (var part in semicolonParts) + { + var trimmedPart = part.Trim(); + if (string.IsNullOrWhiteSpace(trimmedPart)) continue; + + if (trimmedPart.Contains("&&")) + { + var andParts = SplitByOperator(trimmedPart, "&&"); + bool previousSuccess = true; + foreach (var andPart in andParts) + { + if (!previousSuccess) break; + previousSuccess = TryExecute(andPart.Trim(), context); + } + } + else if (trimmedPart.Contains("||")) + { + var orParts = SplitByOperator(trimmedPart, "||"); + bool previousSuccess = false; + foreach (var orPart in orParts) + { + if (previousSuccess) break; + previousSuccess = TryExecute(orPart.Trim(), context); + } + } + else + { + TryExecute(trimmedPart, context); + } + } + + return true; + } + + /// + /// Splits a string by an operator, respecting quotes. + /// + private List SplitByOperator(string input, string op) + { + var parts = new List(); + var current = new System.Text.StringBuilder(); + bool inQuotes = false; + + for (int i = 0; i < input.Length; i++) + { + char c = input[i]; + if (c == '"' || c == '\'') + { + inQuotes = !inQuotes; + current.Append(c); + } + else if (!inQuotes && i + op.Length <= input.Length && input.Substring(i, op.Length) == op) + { + parts.Add(current.ToString()); + current.Clear(); + i += op.Length - 1; + } + else + { + current.Append(c); + } + } + + if (current.Length > 0) + parts.Add(current.ToString()); + + return parts; + } + + /// + /// Splits a string by a single character operator. + /// + private List SplitByOperator(string input, char op) + { + return SplitByOperator(input, op.ToString()); + } + + /// + /// Executes a pipeline of commands separated by pipes. + /// + private bool ExecutePipeline(string pipeline, ShellContext context) + { + var commands = pipeline.Split('|').Select(c => c.Trim()).ToArray(); + + if (commands.Length < 2) + { + return ExecuteSingleCommand(pipeline, context); + } + + AnsiConsole.MarkupLine("[[[yellow]*[/]]] - Piping is partially supported. Complex pipelines may not work as expected."); + + foreach (var cmd in commands) + { + ExecuteSingleCommand(cmd, context); + } + + return true; + } + + /// + /// Executes a single command without piping or redirection. + /// + private bool ExecuteSingleCommand(string commandLine, ShellContext context) + { + var parts = commandLine.Split(' ', StringSplitOptions.RemoveEmptyEntries); if (parts.Length == 0) return false; var usedSudo = parts[0] == "sudo"; if (usedSudo) parts = parts.Skip(1).ToArray(); - if (parts.Length == 0) return false; - var cmdName = parts[0]; + var cmdName = parts[0].ToLower(); var args = parts.Skip(1).ToArray(); if (cmdName.StartsWith("./")) - { return ExecuteLocalFile(commandLine); - } if (CustomCommands.TryGetValue(cmdName, out var customCmd)) { @@ -110,20 +282,80 @@ public bool TryExecute(string commandLine, ShellContext context) if (fullPath != null) { RunSystemCommand(fullPath, args, usedSudo); - - if ((cmdName == "apt" || cmdName == "apt-get") && args.Length > 0 && args[0] == "install") - { - CommandLoader.RefreshCommands(); - } - return true; } } AnsiConsole.MarkupLine($"[[[red]-[/]]] - Unknown command: [bold yellow]{cmdName}[/]"); + + var suggestions = GetCommandSuggestions(cmdName); + if (suggestions.Count > 0) + AnsiConsole.MarkupLine($"[[[yellow]*[/]]] - Did you mean: {string.Join(", ", suggestions.Select(s => $"[cyan]{s}[/]"))}"); + return true; } + /// + /// Get command suggestions based on Levenshtein distance. + /// + private static List GetCommandSuggestions(string cmdName, int maxDistance = 3, int maxSuggestions = 3) + { + var suggestions = new List<(string cmd, int distance)>(); + + foreach (var cmd in CustomCommands.Keys) + { + int distance = CalculateLevenshteinDistance(cmdName, cmd); + if (distance <= maxDistance) + suggestions.Add((cmd, distance)); + } + + foreach (var cmd in SystemCommands.Take(1000)) + { + int distance = CalculateLevenshteinDistance(cmdName, cmd); + if (distance <= maxDistance) + suggestions.Add((cmd, distance)); + } + + return suggestions + .OrderBy(s => s.distance) + .Take(maxSuggestions) + .Select(s => s.cmd) + .ToList(); + } + + /// + /// Calculate Levenshtein distance between two strings. + /// + private static int CalculateLevenshteinDistance(string source, string target) + { + if (string.IsNullOrEmpty(source)) + return target?.Length ?? 0; + + if (string.IsNullOrEmpty(target)) + return source.Length; + + int[,] distance = new int[source.Length + 1, target.Length + 1]; + + for (int i = 0; i <= source.Length; i++) + distance[i, 0] = i; + + for (int j = 0; j <= target.Length; j++) + distance[0, j] = j; + + for (int i = 1; i <= source.Length; i++) + { + for (int j = 1; j <= target.Length; j++) + { + int cost = (target[j - 1] == source[i - 1]) ? 0 : 1; + distance[i, j] = Math.Min( + Math.Min(distance[i - 1, j] + 1, distance[i, j - 1] + 1), + distance[i - 1, j - 1] + cost); + } + } + + return distance[source.Length, target.Length]; + } + /// /// Executes a local shell file. /// diff --git a/Shell/Commands/CommandRegistry.cs b/Shell/Commands/CommandRegistry.cs index a2c5d5f..797bdf0 100644 --- a/Shell/Commands/CommandRegistry.cs +++ b/Shell/Commands/CommandRegistry.cs @@ -20,8 +20,18 @@ public static IEnumerable GetAll() { return new List { - new CdCommand(), new SetThemeCommand(), + new ExitCommand(), + new ClearCommand(), + new HelpCommand(), + new ExportCommand(), + new UnsetCommand(), + new PrintEnvCommand(), + new AliasCommand(), + new UnaliasCommand(), + new HistoryCommand(), + new WhichCommand(), + new AboutCommand(), }; } diff --git a/Shell/Config/ConfigManager.cs b/Shell/Config/ConfigManager.cs new file mode 100644 index 0000000..30a4798 --- /dev/null +++ b/Shell/Config/ConfigManager.cs @@ -0,0 +1,94 @@ +using System.Text.Json; + +namespace NShell.Shell.Config; + +/// +/// Manages saving and loading shell configuration including aliases. +/// +public class ConfigManager +{ + private readonly string _configPath; + + public ConfigManager() + { + var configDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".nshell" + ); + + if (!Directory.Exists(configDir)) + { + Directory.CreateDirectory(configDir); + } + + _configPath = Path.Combine(configDir, "nshellrc.json"); + } + + /// + /// Save aliases to configuration file. + /// + public void SaveAliases(Dictionary aliases) + { + try + { + var config = LoadConfig(); + config["aliases"] = aliases; + + var json = JsonSerializer.Serialize(config, new JsonSerializerOptions + { + WriteIndented = true + }); + + File.WriteAllText(_configPath, json); + } + catch (Exception) + { + // Silently fail - don't interrupt shell operation + } + } + + /// + /// Load aliases from configuration file. + /// + public Dictionary LoadAliases() + { + try + { + var config = LoadConfig(); + + if (config.ContainsKey("aliases") && config["aliases"] is JsonElement aliasesElement) + { + return JsonSerializer.Deserialize>(aliasesElement.ToString()) + ?? new Dictionary(); + } + } + catch (Exception) + { + // Return empty dictionary on error + } + + return new Dictionary(); + } + + /// + /// Load entire configuration file. + /// + private Dictionary LoadConfig() + { + if (!File.Exists(_configPath)) + { + return new Dictionary(); + } + + try + { + var json = File.ReadAllText(_configPath); + return JsonSerializer.Deserialize>(json) + ?? new Dictionary(); + } + catch (Exception) + { + return new Dictionary(); + } + } +} diff --git a/Shell/History/HistoryManager.cs b/Shell/History/HistoryManager.cs index 171aabd..02ec1cb 100644 --- a/Shell/History/HistoryManager.cs +++ b/Shell/History/HistoryManager.cs @@ -16,6 +16,8 @@ public class HistoryManager private readonly List _history = new(); private int _currentIndex = -1; + public int Count => _history.Count; + public HistoryManager(string? path = null) { _historyPath = path ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".nshell/.nhistory"); @@ -81,5 +83,17 @@ public void ResetIndex() { _currentIndex = _history.Count; } + + /// + /// Get a history item at a specific index. + /// + /// The index of the history item. + /// The command at the specified index, or null if out of range. + public string? GetAt(int index) + { + if (index >= 0 && index < _history.Count) + return _history[index]; + return null; + } } } \ No newline at end of file diff --git a/Shell/History/HistorySearch.cs b/Shell/History/HistorySearch.cs new file mode 100644 index 0000000..d93b0ef --- /dev/null +++ b/Shell/History/HistorySearch.cs @@ -0,0 +1,113 @@ +using System.Text; +using Spectre.Console; + +namespace NShell.Shell.History; + +/// +/// Provides interactive reverse history search functionality. +/// +public class HistorySearch +{ + private readonly HistoryManager _history; + + public HistorySearch(HistoryManager history) + { + _history = history; + } + + /// + /// Performs an interactive reverse history search. + /// Returns the selected command or null if cancelled. + /// + public string? Search(int initialCursorLeft) + { + var searchBuffer = new StringBuilder(); + var matches = new List(); + int matchIndex = 0; + + // Display initial search prompt + Console.SetCursorPosition(0, Console.CursorTop); + Console.Write(new string(' ', Console.WindowWidth - 1)); + Console.SetCursorPosition(0, Console.CursorTop); + Console.ForegroundColor = ConsoleColor.Cyan; + Console.Write("(reverse-i-search)`': "); + Console.ResetColor(); + + while (true) + { + var key = Console.ReadKey(intercept: true); + + if (key.Key == ConsoleKey.Enter) + { + // Accept current match + Console.WriteLine(); + return matches.Count > 0 ? matches[matchIndex] : null; + } + else if (key.Key == ConsoleKey.Escape || (key.Key == ConsoleKey.G && key.Modifiers.HasFlag(ConsoleModifiers.Control))) + { + // Cancel search + Console.WriteLine(); + return null; + } + else if (key.Key == ConsoleKey.R && key.Modifiers.HasFlag(ConsoleModifiers.Control)) + { + // Next match + if (matches.Count > 0) + { + matchIndex = (matchIndex + 1) % matches.Count; + } + } + else if (key.Key == ConsoleKey.Backspace) + { + if (searchBuffer.Length > 0) + { + searchBuffer.Remove(searchBuffer.Length - 1, 1); + UpdateSearch(searchBuffer.ToString(), out matches, out matchIndex); + } + } + else if (!char.IsControl(key.KeyChar)) + { + searchBuffer.Append(key.KeyChar); + UpdateSearch(searchBuffer.ToString(), out matches, out matchIndex); + } + else + { + continue; + } + + // Redraw search UI + Console.SetCursorPosition(0, Console.CursorTop); + Console.Write(new string(' ', Console.WindowWidth - 1)); + Console.SetCursorPosition(0, Console.CursorTop); + + Console.ForegroundColor = ConsoleColor.Cyan; + Console.Write($"(reverse-i-search)`{searchBuffer}': "); + Console.ResetColor(); + + if (matches.Count > 0) + { + Console.Write(matches[matchIndex]); + } + } + } + + private void UpdateSearch(string searchTerm, out List matches, out int matchIndex) + { + matches = new List(); + + // Search through history in reverse order + for (int i = _history.Count - 1; i >= 0; i--) + { + var item = _history.GetAt(i); + if (item != null && item.Contains(searchTerm, StringComparison.OrdinalIgnoreCase)) + { + if (!matches.Contains(item)) + { + matches.Add(item); + } + } + } + + matchIndex = 0; + } +} diff --git a/Shell/Plugin/PluginLoadContext.cs b/Shell/Plugin/PluginLoadContext.cs index 4258853..56e88d3 100644 --- a/Shell/Plugin/PluginLoadContext.cs +++ b/Shell/Plugin/PluginLoadContext.cs @@ -12,14 +12,14 @@ public PluginLoadContext(string pluginPath) : base(isCollectible: false) protected override Assembly? Load(AssemblyName assemblyName) { - if (assemblyName.Name == "System.Runtime" - || assemblyName.Name == "System.Private.CoreLib" - || assemblyName.Name.StartsWith("System.") - || assemblyName.Name.StartsWith("Microsoft.")) + if (assemblyName.Name!.StartsWith("System.") + || assemblyName.Name.StartsWith("Microsoft.") + || assemblyName.Name == "System.Runtime" + || assemblyName.Name == "System.Private.CoreLib") { - return null; + return AssemblyLoadContext.Default.LoadFromAssemblyName(assemblyName); } - + var assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName); if (assemblyPath != null) { @@ -29,8 +29,7 @@ public PluginLoadContext(string pluginPath) : base(isCollectible: false) return null; } - - + protected override IntPtr LoadUnmanagedDll(string unmanagedDllName) { string? libraryPath = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName); diff --git a/Shell/Plugin/PluginLoader.cs b/Shell/Plugin/PluginLoader.cs index 6db9af8..d5ead9a 100644 --- a/Shell/Plugin/PluginLoader.cs +++ b/Shell/Plugin/PluginLoader.cs @@ -42,7 +42,7 @@ public void LoadPlugins() } catch (Exception ex) { - AnsiConsole.MarkupLine($"\t[[[red]-[/]]] - Failed to load plugin: {Path.GetFileName(pluginPath)}"); + AnsiConsole.MarkupLine($"\t[[[red]-[/]]] - Failed to load plugin: [yellow]{Path.GetFileName(pluginPath)}[/]"); AnsiConsole.WriteException(ex, ExceptionFormats.ShortenEverything | ExceptionFormats.ShowLinks); } } @@ -76,7 +76,7 @@ private void LoadPluginCommands() { if (Activator.CreateInstance(type) is ICustomCommand command) { - CommandRegistry.Register(command); + CommandParser.CustomCommands[command.Name.ToLower()] = command; AnsiConsole.MarkupLine($"[[[green]+[/]]] - Loaded plugin command: [yellow]{command.Name}[/]"); } } diff --git a/Shell/Readline/KeyAction.cs b/Shell/Readline/KeyAction.cs index ba5427c..9cd2c1c 100644 --- a/Shell/Readline/KeyAction.cs +++ b/Shell/Readline/KeyAction.cs @@ -43,7 +43,169 @@ public void HandleDeleteChar() public void HandleTab() { - // TODO: Implement tab completion + string currentInput = _inputBuffer.ToString(); + if (string.IsNullOrWhiteSpace(currentInput)) + return; + + var parts = currentInput.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 0) + return; + + // If we're completing the first word (command name) + if (parts.Length == 1 && !currentInput.EndsWith(' ')) + { + var matches = GetCommandMatches(parts[0]); + if (matches.Count == 1) + { + // Complete the command + CompleteWith(matches[0]); + } + else if (matches.Count > 1) + { + // Show possible completions + Console.WriteLine(); + foreach (var match in matches.Take(20)) + { + Console.Write($"{match} "); + } + if (matches.Count > 20) + Console.Write($"... and {matches.Count - 20} more"); + Console.WriteLine(); + // Redraw prompt + Console.Write(new string(' ', _initCursorPos4Console)); + Console.SetCursorPosition(0, Console.CursorTop); + Console.Write(new string(' ', _initCursorPos4Console)); + Console.SetCursorPosition(_initCursorPos4Console, Console.CursorTop); + Console.Write(_inputBuffer); + _currentCursorPos4Console = _initCursorPos4Console + _inputLength; + } + } + // If we're completing a file/directory path + else + { + var lastPart = parts[^1]; + var matches = GetPathMatches(lastPart); + + if (matches.Count == 1) + { + // Remove the partial path from current input + int removeLength = lastPart.Length; + _inputBuffer.Remove(_inputLength - removeLength, removeLength); + _inputLength -= removeLength; + _currentCursorPos4Input -= removeLength; + _currentCursorPos4Console -= removeLength; + + // Add the completed path + CompleteWith(matches[0]); + } + else if (matches.Count > 1) + { + // Show possible completions + Console.WriteLine(); + foreach (var match in matches.Take(20)) + { + Console.Write($"{match} "); + } + if (matches.Count > 20) + Console.Write($"... and {matches.Count - 20} more"); + Console.WriteLine(); + // Redraw prompt + Console.Write(new string(' ', _initCursorPos4Console)); + Console.SetCursorPosition(0, Console.CursorTop); + Console.Write(new string(' ', _initCursorPos4Console)); + Console.SetCursorPosition(_initCursorPos4Console, Console.CursorTop); + Console.Write(_inputBuffer); + _currentCursorPos4Console = _initCursorPos4Console + _inputLength; + } + } + } + + private List GetCommandMatches(string prefix) + { + var matches = new List(); + + // Check custom commands + foreach (var cmd in NShell.Shell.Commands.CommandParser.CustomCommands.Keys) + { + if (cmd.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + matches.Add(cmd); + } + + // Check system commands + foreach (var cmd in NShell.Shell.Commands.CommandParser.SystemCommands) + { + if (cmd.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + matches.Add(cmd); + } + + return matches.OrderBy(x => x).ToList(); + } + + private List GetPathMatches(string prefix) + { + var matches = new List(); + + try + { + string directory; + string filePrefix; + + if (prefix.Contains('/')) + { + int lastSlash = prefix.LastIndexOf('/'); + directory = prefix.Substring(0, lastSlash + 1); + filePrefix = prefix.Substring(lastSlash + 1); + + if (!Path.IsPathRooted(directory)) + directory = Path.Combine(Directory.GetCurrentDirectory(), directory); + } + else + { + directory = Directory.GetCurrentDirectory(); + filePrefix = prefix; + } + + if (Directory.Exists(directory)) + { + // Get matching files and directories + foreach (var item in Directory.GetFileSystemEntries(directory)) + { + var name = Path.GetFileName(item); + if (name.StartsWith(filePrefix, StringComparison.OrdinalIgnoreCase)) + { + // Add trailing slash for directories + if (Directory.Exists(item)) + matches.Add(name + "/"); + else + matches.Add(name); + } + } + } + } + catch + { + // Ignore errors in path completion + } + + return matches.OrderBy(x => x).ToList(); + } + + private void CompleteWith(string completion) + { + // Clear current input on screen + Console.SetCursorPosition(_initCursorPos4Console, Console.CursorTop); + Console.Write(new string(' ', _inputLength)); + Console.SetCursorPosition(_initCursorPos4Console, Console.CursorTop); + + // Replace input buffer + _inputBuffer.Clear(); + _inputBuffer.Append(completion); + _inputLength = completion.Length; + _currentCursorPos4Input = _inputLength; + _currentCursorPos4Console = _initCursorPos4Console + _inputLength; + + // Write new input + Console.Write(completion); } public void PreviousHistory() @@ -94,12 +256,74 @@ private void MoveCursorToStart() private void MoveCursorWordLeft() { - Debug.Write("MoveCursorWordLeft"); + if (_currentCursorPos4Input == 0) + return; + + // Skip whitespace + while (_currentCursorPos4Input > 0 && char.IsWhiteSpace(_inputBuffer[_currentCursorPos4Input - 1])) + { + _currentCursorPos4Input--; + _currentCursorPos4Console--; + } + + // Move to start of word + while (_currentCursorPos4Input > 0 && !char.IsWhiteSpace(_inputBuffer[_currentCursorPos4Input - 1])) + { + _currentCursorPos4Input--; + _currentCursorPos4Console--; + } + + Console.SetCursorPosition(_currentCursorPos4Console, Console.CursorTop); } private void MoveCursorWordRight() { - Debug.Write("MoveCursorWordRight"); + if (_currentCursorPos4Input >= _inputLength) + return; + + // Skip whitespace + while (_currentCursorPos4Input < _inputLength && char.IsWhiteSpace(_inputBuffer[_currentCursorPos4Input])) + { + _currentCursorPos4Input++; + _currentCursorPos4Console++; + } + + // Move to end of word + while (_currentCursorPos4Input < _inputLength && !char.IsWhiteSpace(_inputBuffer[_currentCursorPos4Input])) + { + _currentCursorPos4Input++; + _currentCursorPos4Console++; + } + + Console.SetCursorPosition(_currentCursorPos4Console, Console.CursorTop); + } + + private void HandleHistorySearch() + { + var search = new NShell.Shell.History.HistorySearch(_history); + var result = search.Search(_initCursorPos4Console); + + if (result != null) + { + // Clear current input + Console.SetCursorPosition(_initCursorPos4Console, Console.CursorTop); + Console.Write(new string(' ', _inputLength)); + Console.SetCursorPosition(_initCursorPos4Console, Console.CursorTop); + + // Set new input + _inputBuffer.Clear(); + _inputBuffer.Append(result); + _inputLength = result.Length; + _currentCursorPos4Input = _inputLength; + _currentCursorPos4Console = _initCursorPos4Console + _inputLength; + + Console.Write(result); + } + else + { + // Restore cursor position if search was cancelled + Console.SetCursorPosition(_currentCursorPos4Console, Console.CursorTop); + } } private void MoveCursorLeft() diff --git a/Shell/Readline/KeyHandler.cs b/Shell/Readline/KeyHandler.cs index 3a15b02..4d1189d 100644 --- a/Shell/Readline/KeyHandler.cs +++ b/Shell/Readline/KeyHandler.cs @@ -74,6 +74,7 @@ public KeyHandler(HistoryManager history) _keyBindings["Tab"] = HandleTab; _keyBindings["ControlLeftArrow"] = MoveCursorWordLeft; _keyBindings["ControlRightArrow"] = MoveCursorWordRight; + _keyBindings["ControlR"] = HandleHistorySearch; } private string BuildKeyInput() diff --git a/Shell/ShellContext.cs b/Shell/ShellContext.cs index fc765b2..d19b308 100644 --- a/Shell/ShellContext.cs +++ b/Shell/ShellContext.cs @@ -13,8 +13,8 @@ public class ShellContext { public string CurrentDirectory { get; set; } public string CurrentTheme { get; set; } = "default"; - public string Prompt { get; private set; } - public string LSColors { get; private set; } + public string Prompt { get; private set; } = string.Empty; + public string LSColors { get; private set; } = "di=34:fi=37:ln=36:pi=33:so=35:ex=32"; public Dictionary CustomCommands => CommandParser.CustomCommands; public ShellContext() diff --git a/install.sh b/install.sh index 5e264aa..6dcc95f 100755 --- a/install.sh +++ b/install.sh @@ -52,7 +52,6 @@ dotnet publish NShell.csproj \ -r linux-x64 \ --self-contained true \ /p:PublishSingleFile=true \ - /p:PublishTrimmed=true \ /p:IncludeNativeLibrariesForSelfExtract=true \ -o ./publish