diff --git a/Alchegos.MCP.csproj b/Alchegos.MCP.csproj index 058d402..f1d6f49 100644 --- a/Alchegos.MCP.csproj +++ b/Alchegos.MCP.csproj @@ -7,13 +7,17 @@ enable Linux - + + + + + diff --git a/Dockerfile b/Dockerfile index 988a721..f3b76dd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,7 @@ WORKDIR /app FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src +COPY NuGet.Config ./ COPY ["*.sln", "."] COPY ["Alchegos.MCP.csproj", "./"] RUN dotnet restore "Alchegos.MCP.csproj" @@ -19,4 +20,14 @@ RUN dotnet publish "./Alchegos.MCP.csproj" -c $BUILD_CONFIGURATION -o /app/publi FROM base AS final WORKDIR /app COPY --from=publish /app/publish . -ENTRYPOINT ["dotnet", "Alchegos.MCP.dll"] +USER root +RUN apt-get update +RUN apt-get install -y --no-install-recommends openssh-client openssh-server +RUN rm -rf /var/lib/apt/lists/* +RUN mkdir -p /var/run/sshd +RUN sed -i 's/#PermitRootLogin.*/PermitRootLogin yes/' /etc/ssh/sshd_config +RUN sed -i 's/#PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh +ENTRYPOINT ["/entrypoint.sh"] diff --git a/Middleware/RequestBodyLoggingMiddleware.cs b/Middleware/RequestBodyLoggingMiddleware.cs new file mode 100644 index 0000000..af6c89e --- /dev/null +++ b/Middleware/RequestBodyLoggingMiddleware.cs @@ -0,0 +1,51 @@ +using System.Text; + +namespace Alchegos.MCP.Middleware +{ + + public class RequestBodyLoggingMiddleware + { + private readonly RequestDelegate _next; + private readonly ILogger _logger; + public RequestBodyLoggingMiddleware(RequestDelegate next, ILogger logger) + { + _next = next; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + if (context.Request.Method == HttpMethods.Post && context.Request.ContentLength > 0) + { + context.Request.EnableBuffering(); + + using var reader = new StreamReader( + context.Request.Body, + encoding: Encoding.UTF8, + detectEncodingFromByteOrderMarks: false, + bufferSize: 4096, + leaveOpen: true); + + var requestBody = await reader.ReadToEndAsync(); + context.Request.Body.Position = 0; + + _logger.LogTrace("Request Body (POST {RequestPath}):\n{RequestBody}", context.Request.Path, requestBody); + + } + + await _next(context); + } + } +} + +namespace Microsoft.AspNetCore.Builder +{ + public static class RequestBodyLoggingMiddlewareExtensions + { + public static IApplicationBuilder UseRequestBodyLogging( + this IApplicationBuilder builder) + { + return builder.UseMiddleware(); + } + } +} \ No newline at end of file diff --git a/NuGet.Config b/NuGet.Config new file mode 100644 index 0000000..e9328aa --- /dev/null +++ b/NuGet.Config @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/Program.cs b/Program.cs index 858a873..356ef9b 100644 --- a/Program.cs +++ b/Program.cs @@ -1,29 +1,19 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using ModelContextProtocol.Server; -using System.ComponentModel; -using System.Diagnostics; -using Alchegos.MCP.Tools; - var builder = WebApplication.CreateBuilder(args); +const int BIND_PORT = 5050; + builder.Services .AddMcpServer() .WithHttpTransport() .WithToolsFromAssembly(); -builder.WebHost.ConfigureKestrel(options => options.ListenAnyIP(5050)); +builder.WebHost.ConfigureKestrel(options => options.ListenAnyIP(BIND_PORT)); +//builder.Services.AddHttpLogging(o => { }); var app = builder.Build(); - +// var logger = app.Logger; +//app.UseHttpLogging(); +app.UseRequestBodyLogging(); app.MapMcp(); app.Run(); -[McpServerToolType] -public static class EchoTool -{ - [McpServerTool, Description("Echoes the message back to the client.")] - public static string Echo(string message) => $"hello {message}"; -} - diff --git a/Tools/GiteaTool.cs b/Tools/GiteaTool.cs index a6528b7..526033d 100644 --- a/Tools/GiteaTool.cs +++ b/Tools/GiteaTool.cs @@ -1,8 +1,18 @@ using ModelContextProtocol.Server; +using Alchegos.Core.Services.Gitea; + namespace Alchegos.MCP.Tools; [McpServerToolType] public static class GiteaTool { - + public static string Login(string a) + { + throw new NotImplementedException(); + } + + public static string GetGiteaRepoList(string apiKey) + { + throw new NotImplementedException(); + } } \ No newline at end of file diff --git a/Tools/TerminalTool.cs b/Tools/TerminalTool.cs index 62d0f4b..c161fed 100644 --- a/Tools/TerminalTool.cs +++ b/Tools/TerminalTool.cs @@ -6,60 +6,52 @@ using System.Text.RegularExpressions; using ModelContextProtocol.Server; namespace Alchegos.MCP.Tools; - -[McpServerToolType, Description("Manage multiple persistent terminal sessions, enabling creation, closure, command execution, and output retrieval.")] +[McpServerToolType, Description(""" + Manages multiple persistent terminal sessions. + **Sessions are identified and operated on using their unique `sessionId`. The `label` is for human reference only.** + Enables creation, closure, command execution, and output retrieval. + """)] public static class TerminalTool { private static readonly ConcurrentDictionary _sessions = new(); - public record SessionInfo( - [property: Description("Unique identifier for the session.")] string SessionId, - [property: Description("Human-readable label for the session.")] string Label + [property: Description("Unique identifier (`sessionId`) for the session. **This ID is required for all operations targeting this specific session (e.g., sending input, exiting).**")] string SessionId, + [property: Description("Human-readable label for the session. **This is for display and organizational purposes only and CANNOT be used to interact with the session via subsequent tool calls.**")] string Label ); - public record ListSessionsResult( - [property: Description("List of all active sessions with their IDs and labels.")] List Sessions + [property: Description("List of all active sessions, including their `sessionId` and `label`. **Use the `sessionId` from this list for interacting with a specific session in other tool calls.**")] List Sessions ); - public record CreateSessionResult( [property: Description("Status of the operation: 'success' or 'fail'.")] string Status, [property: Description("Details of the created session if successful; otherwise, with empty fields.")] SessionInfo Session ); - public record ExitSessionResult( [property: Description("Status of the operation: 'success' or 'fail'.")] string Status ); - public record SendSignalResult( [property: Description("Status of the operation: 'success' or 'fail'.")] string Status ); - public record SendInputWithExceptResult( [property: Description("Indicates how the response was triggered: 'except' when a regex matched, or 'timeout' when the timeout was reached.")] string OutputBy, [property: Description("The regex pattern that matched, or null if none matched before timeout.")] string? MatchedExcept, [property: Description("Accumulated standard output from the session after sending input.")] string Stdout, [property: Description("Accumulated standard error from the session after sending input.")] string Stderr ); - public record SendInputAndForgetResult( [property: Description("Status of the operation: 'success' or 'fail'.")] string Status ); - public record OneOffCommandResult( - [property: Description("How the command completed: 'finish' when the process exited normally, or 'timeout' when the timeout was reached.")] string ReturnBy, + [property: Description("How the command completed: 'finish' when the process exited normally, or 'timeout' when the timeout was reached. 'error' otherwise")] string ReturnBy, [property: Description("Standard output produced by the command.")] string Stdout, [property: Description("Standard error produced by the command.")] string Stderr ); - public record GetCachedResultResult( [property: Description("Cached standard output of the session.")] string Stdout, [property: Description("Cached standard error of the session.")] string Stderr ); - public record ClearResult( [property: Description("Status of the operation: 'success' or 'fail'.")] string Status ); - private class TerminalSession : IDisposable { public string Id { get; } @@ -68,15 +60,13 @@ public static class TerminalTool private readonly StringBuilder _stdoutBuffer = new(); private readonly StringBuilder _stderrBuffer = new(); - private readonly object _lockObject = new(); - + private readonly Lock _lockObject = new(); private bool _isDisposed; public TerminalSession(string id, string label) { Id = id; Label = label; - Process = new Process { StartInfo = new ProcessStartInfo @@ -95,23 +85,19 @@ public static class TerminalTool Process.OutputDataReceived += (sender, e) => { if (e.Data != null) - { lock (_lockObject) { _stdoutBuffer.AppendLine(e.Data); } - } }; Process.ErrorDataReceived += (sender, e) => { if (e.Data != null) - { lock (_lockObject) { _stderrBuffer.AppendLine(e.Data); } - } }; Process.Start(); @@ -166,10 +152,8 @@ public static class TerminalTool { string stdout = _stdoutBuffer.ToString(); string stderr = _stderrBuffer.ToString(); - _stdoutBuffer.Clear(); _stderrBuffer.Clear(); - return (stdout, stderr); } } @@ -195,16 +179,13 @@ public static class TerminalTool { if (_isDisposed) return; - _isDisposed = true; - try { if (!Process.HasExited) { Process.StandardInput.WriteLine("exit"); Process.StandardInput.Flush(); - if (!Process.WaitForExit(1000)) Process.Kill(); } @@ -228,10 +209,8 @@ public static class TerminalTool { if (process.HasExited) return false; - if (!AttachConsole((uint)process.Id)) return false; - SetConsoleCtrlHandler(null, true); try { @@ -264,11 +243,9 @@ public static class TerminalTool UseShellExecute = false, CreateNoWindow = true }); - killProcess?.WaitForExit(); return killProcess?.ExitCode == 0; } - return false; } catch @@ -277,7 +254,6 @@ public static class TerminalTool } } - // Windows Console API imports [System.Runtime.InteropServices.DllImport("kernel32.dll")] public static extern bool AttachConsole(uint dwProcessId); @@ -285,7 +261,7 @@ public static class TerminalTool public static extern bool FreeConsole(); [System.Runtime.InteropServices.DllImport("kernel32.dll")] - public static extern bool SetConsoleCtrlHandler(ConsoleCtrlHandler? HandlerRoutine, bool Add); + public static extern bool SetConsoleCtrlHandler(ConsoleCtrlHandler? handlerRoutine, bool add); [System.Runtime.InteropServices.DllImport("kernel32.dll")] public static extern bool GenerateConsoleCtrlEvent(CtrlTypes dwCtrlEvent, uint dwProcessGroupId); @@ -298,34 +274,30 @@ public static class TerminalTool CTRL_LOGOFF_EVENT = 5, CTRL_SHUTDOWN_EVENT = 6 } - public delegate bool ConsoleCtrlHandler(CtrlTypes ctrlType); } - //——— Tool Methods ———// - - [McpServerTool, Description(""" - List all active terminal sessions. - Return Format: - { - sessions: [ { session_id: string, label: string }, ... ] - } + [McpServerTool, Description( + """ + Lists all currently active persistent terminal sessions being managed. + Provides the essential `sessionId` (required for all other operations on a session) and the human-readable `label` for each. + Use this to find the `sessionId` of an existing session you want to interact with. + Return Format: { sessions: [ { session_id: string, label: string }, ... ] } """)] - public static ListSessionsResult ListSessions() + public static ListSessionsResult ListTerminalSessions() { - var sessions = _sessions.Values.Select(s => new SessionInfo(s.Id, s.Label)).ToList(); + List sessions = _sessions.Values.Select(s => new SessionInfo(s.Id, s.Label)).ToList(); return new ListSessionsResult(sessions); } - [McpServerTool, Description(""" - Create a new terminal session. - Return Format: - { - status: 'success' | 'fail', - session: { session_id: string, label: string } - } + [McpServerTool, Description( + """ + Creates a new persistent terminal session (a background shell like bash or cmd) and assigns it a unique `sessionId`. + This `sessionId` is **essential** and **must be used** in subsequent calls to other TerminalTool functions (like SendInput..., OneOffCommand, GetCachedResult, ExitSession) to target this specific session. + The `label` is purely for human reference (e.g., in ListTerminalSessions). + Return Format: { status: 'success' | 'fail', session: { session_id: string, label: string } } """)] - public static CreateSessionResult CreateSession( + public static CreateSessionResult CreateTerminalSession( [Description("Memorable label for the new session.")] string label ) { @@ -333,12 +305,8 @@ public static class TerminalTool { string sessionId = Guid.NewGuid().ToString("N"); var session = new TerminalSession(sessionId, label); - if (_sessions.TryAdd(sessionId, session)) - { return new CreateSessionResult("success", new SessionInfo(sessionId, label)); - } - session.Dispose(); return new CreateSessionResult("fail", new SessionInfo(string.Empty, string.Empty)); } @@ -347,15 +315,14 @@ public static class TerminalTool return new CreateSessionResult("fail", new SessionInfo(string.Empty, string.Empty)); } } - - [McpServerTool, Description(""" - Exit a terminal session. Fails if the session ID does not exist. - Return Format: - { - status: 'success' | 'fail' - } + [McpServerTool, Description( + """ + Terminates and cleans up a specific persistent terminal session identified by its `sessionId`. + This effectively closes the background shell process associated with that session. + Use this when you are finished working with a specific session. Fails if the `sessionId` is invalid. + Return Format: { status: 'success' | 'fail' } """)] - public static ExitSessionResult ExitSession( + public static ExitSessionResult ExitTerminalSession( [Description("ID of the session to exit.")] string sessionId ) { @@ -364,18 +331,16 @@ public static class TerminalTool session.Dispose(); return new ExitSessionResult("success"); } - return new ExitSessionResult("fail"); } - - [McpServerTool, Description(""" - Send a signal to a terminal session (e.g., SIGINT=2). - Return Format: - { - status: 'success' | 'fail' - } + [McpServerTool, Description( + """ + Terminates and cleans up a specific persistent terminal session identified by its `sessionId`. + This effectively closes the background shell process associated with that session. + Use this when you are finished working with a specific session. Fails if the `sessionId` is invalid. + Return Format: { status: 'success' | 'fail' } """)] - public static SendSignalResult SendSignal( + public static SendSignalResult SendSignalToTerminalSession( [Description("Session ID to send the signal to.")] string sessionId, [Description("Signal number to send (e.g., 2 for SIGINT)." )] int signal ) @@ -385,23 +350,18 @@ public static class TerminalTool bool success = session.SendSignal(signal); return new SendSignalResult(success ? "success" : "fail"); } - return new SendSignalResult("fail"); } - [McpServerTool, Description(""" - Send input to a terminal session and wait until the output matches one of the exception regexes or timeout. - Use cases: interactive prompts (e.g., confirmation prompts, password input). - Return Format: - { - output_by: 'except' | 'timeout', - matched_except: string | null, - stdout: string, - stderr: string - } - Note: cached stdout/stderr is cleared after this call. + [McpServerTool, Description( + """ + Sends input text to a specific terminal session (identified by `sessionId`) and WAITS for output. + It returns ONLY when specific regex patterns (`excepts`) are matched in the session's stdout/stderr OR the `timeout` is reached. + **Use Case:** Handling interactive prompts within the shell (e.g., password prompts, 'yes/no' confirmations) where you need to wait for a specific response pattern before proceeding. + Clears the session's output buffer upon returning. Compare with `SendInputAndForget` and `OneOffCommand` for different interaction patterns. + Return Format: { output_by: 'except' | 'timeout', matched_except: string | null, stdout: string, stderr: string } """)] - public static SendInputWithExceptResult SendInputWithExcept( + public static SendInputWithExceptResult SendCommandToTerminalSessionWithExcept( [Description("Session ID to send input to.")] string sessionId, [Description("Input text to send (without newline)." )] string input, [Description("Timeout in milliseconds.")] int timeout, @@ -409,23 +369,16 @@ public static class TerminalTool ) { if (!_sessions.TryGetValue(sessionId, out var session)) - { return new SendInputWithExceptResult("timeout", null, string.Empty, string.Empty); - } - session.SendInput(input); - var regexPatterns = excepts.Select(pattern => new Regex(pattern, RegexOptions.Compiled)).ToArray(); - string matchedPattern = null; bool matched = false; var watch = Stopwatch.StartNew(); - while (watch.ElapsedMilliseconds < timeout && !matched) { var (stdout, stderr) = session.GetBuffers(); string combinedOutput = stdout + stderr; - for (int i = 0; i < regexPatterns.Length; i++) { if (regexPatterns[i].IsMatch(combinedOutput)) @@ -435,17 +388,11 @@ public static class TerminalTool break; } } - if (!matched) - { Thread.Sleep(10); - } } - watch.Stop(); - var (finalStdout, finalStderr) = session.GetAndClearBuffers(); - return new SendInputWithExceptResult( matched ? "except" : "timeout", matchedPattern, @@ -454,24 +401,23 @@ public static class TerminalTool ); } - [McpServerTool, Description(""" - Send input to a terminal session without waiting for output. Output is cached for later retrieval. - Use cases: long-running or background commands. e.g. ssh - Return Format: - { - status: 'success' | 'fail' - } + [McpServerTool, Description( + """ + Sends a POSIX-style signal to the process running within the specified terminal session (identified by `sessionId`). + Common use is sending signal 2 (SIGINT) to simulate Ctrl+C, interrupting a running command within that specific session's shell. + Requires the `sessionId` of an active session. Fails if the `sessionId` is invalid or the signal cannot be delivered. + Return Format: { status: 'success' | 'fail' } """)] - public static SendInputAndForgetResult SendInputAndForget( + public static SendInputAndForgetResult SendCommandToTerminalSessionAndForget( [Description("Session ID to send input to.")] string sessionId, - [Description("Input text to send (without newline)." )] string input + [Description("Input text to send (without newline)." )] string command ) { if (_sessions.TryGetValue(sessionId, out var session)) { try { - session.SendInput(input); + session.SendInput(command); return new SendInputAndForgetResult("success"); } catch @@ -483,66 +429,45 @@ public static class TerminalTool return new SendInputAndForgetResult("fail"); } - [McpServerTool, Description(""" - Execute a one-off command in the specified session and return stdout/stderr when done or timed out. - Use cases: quick commands that complete shortly (e.g., ls, echo). - Return Format: - { - return_by: 'finish' | 'timeout', - stdout: string, - stderr: string - } + [McpServerTool, Description( + """ + Executes a single command line within a specific terminal session (identified by `sessionId`), waits for it to finish, and returns the collected stdout/stderr. + This tool manages sending the command and detecting its completion automatically. + **Use Case:** Running relatively quick, synchronous commands where you need the output right away (e.g., `ls -l`, `pwd`, `git status`, `echo $VAR`). + Compare with `SendInputAndForget` (for background tasks) and `SendInputWithExcept` (for interactive prompts). + Return Format: { return_by: 'finish' | 'timeout' | 'error', stdout: string, stderr: string } """)] - public static OneOffCommandResult OneOffCommand( + public static OneOffCommandResult SendOneOffCommandToTerminalSession( [Description("Session ID to execute the command in.")] string sessionId, [Description("Command to execute, including arguments.")] string command, [Description("Timeout in milliseconds.")] int timeout ) { if (!_sessions.TryGetValue(sessionId, out var session)) - { - return new OneOffCommandResult("timeout", string.Empty, string.Empty); - } - + return new OneOffCommandResult("error", string.Empty, string.Empty); session.ClearBuffers(); - string marker = $"CMD_COMPLETE_{Guid.NewGuid():N}"; - if (OperatingSystem.IsWindows()) - { session.SendInput($"{command} & echo {marker}"); - } else - { session.SendInput($"{command}; echo {marker}"); - } - bool completed = false; var watch = Stopwatch.StartNew(); - while (watch.ElapsedMilliseconds < timeout && !completed) { var (stdout, _) = session.GetBuffers(); - if (stdout.Contains(marker)) - { completed = true; - } else - { Thread.Sleep(10); - } } - watch.Stop(); - var (finalStdout, finalStderr) = session.GetAndClearBuffers(); - if (completed) { finalStdout = Regex.Replace(finalStdout, $"{marker}\r?\n?$", ""); + finalStderr = Regex.Replace(finalStderr, $"{marker}\r?\n?$", ""); } - return new OneOffCommandResult( completed ? "finish" : "timeout", finalStdout, @@ -550,15 +475,13 @@ public static class TerminalTool ); } - [McpServerTool, Description(""" - Get and clear cached stdout/stderr of the specified session. - Return Format: - { - stdout: string, - stderr: string - } + [McpServerTool, Description( + """ + Retrieves AND clears all accumulated standard output (stdout) and standard error (stderr) that have been buffered within the specified terminal session (identified by `sessionId`) since the last read or clear operation. + **Use Case:** Primarily used to collect the output from commands previously started with `SendInputAndForget`. Call this periodically or when you expect a background task might have produced output. + Return Format: { stdout: string, stderr: string } """)] - public static GetCachedResultResult GetCachedResult( + public static GetCachedResultResult GetTerminalSessionCachedResult( [Description("Session ID to retrieve output from.")] string sessionId ) { @@ -567,16 +490,14 @@ public static class TerminalTool var (stdout, stderr) = session.GetAndClearBuffers(); return new GetCachedResultResult(stdout, stderr); } - return new GetCachedResultResult(string.Empty, string.Empty); } - [McpServerTool, Description(""" - Clear cached stdout/stderr of the specified session. - Return Format: - { - status: 'success' | 'fail' - } + [McpServerTool, Description( + """ + Discards (clears) any accumulated stdout and stderr buffered within the specified terminal session (identified by `sessionId`) without returning the content. + **Use Case:** Useful if you want to ignore any previous output before running a new command or checking for fresh output, particularly if using `SendInputAndForget`. + Return Format: { status: 'success' | 'fail' } """)] public static ClearResult Clear( [Description("Session ID to clear cache for.")] string sessionId @@ -587,7 +508,9 @@ public static class TerminalTool session.ClearBuffers(); return new ClearResult("success"); } - return new ClearResult("fail"); } + } + + diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 0000000..909070d --- /dev/null +++ b/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Trace" + + } + } +} \ No newline at end of file diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..120598e --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env sh +set -e +if [ ! -f /etc/ssh/ssh_host_rsa_key ]; then + ssh-keygen -A +fi +/usr/sbin/sshd + +exec dotnet Alchegos.MCP.dll diff --git a/playground.sh b/playground.sh new file mode 100644 index 0000000..677db01 --- /dev/null +++ b/playground.sh @@ -0,0 +1,141 @@ +#!/bin/sh +# ssh_passwordless_manager.sh +# Manage passwordless SSH hosts: list, add, delete, edit, query. + +CONFIG_FILE="$HOME/.ssh_playground_hosts" + +# Ensure config file exists +touch "$CONFIG_FILE" + +# Print usage information +usage() { + cat < [options] + +Commands: + list List all hosts configured for SSH connection + add [pub_key] + Add a new host; installs public key and saves config + delete Remove a host; deletes key from remote and config + edit [new_pub_key] + Replace the key for a given host + query Show the configuration for a given host + help Show this help message +EOF + exit 1 +} + +# Load entry by alias +load_entry() { + grep "^$1|" "$CONFIG_FILE" +} + +# List hosts +list_hosts() { + if [ ! -s "$CONFIG_FILE" ]; then + echo "No hosts configured." + exit 0 + fi + printf "%-15s %-25s %s\n" "ALIAS" "USER@HOST" "PRIVATE_KEY" + echo "---------------------------------------------------------------" + while IFS='|' read -r alias userhost privkey pubkey; do + printf "%-15s %-25s %s\n" "$alias" "$userhost" "$privkey" + done < "$CONFIG_FILE" +} + +# Add host +add_host() { + [ $# -lt 3 ] && echo "add requires alias, user@host, private key" && usage + alias="$1"; userhost="$2"; privkey="$3"; pubkey="$4" + [ -z "$pubkey" ] && pubkey="${privkey}.pub" + + if load_entry "$alias" >/dev/null; then + echo "Alias '$alias' already exists." && exit 1 + fi + + # Copy public key to remote + echo "Installing public key to $userhost..." + ssh-copy-id -i "$pubkey" "$userhost" || { echo "Failed to install key."; exit 1; } + + # Save to config + echo "$alias|$userhost|$privkey|$pubkey" >> "$CONFIG_FILE" + echo "Host '$alias' added." +} + +# Delete host +delete_host() { + [ $# -lt 1 ] && echo "delete requires alias" && usage + alias="$1" + entry=$(load_entry "$alias") + [ -z "$entry" ] && echo "Alias '$alias' not found." && exit 1 + + IFS='|' read -r _ userhost privkey pubkey < ~/.ssh/authorized_keys.tmp && \ + mv ~/.ssh/authorized_keys.tmp ~/.ssh/authorized_keys" || echo "Warning: could not remove key on remote." + + # Remove from config + grep -v "^$alias|" "$CONFIG_FILE" > "$CONFIG_FILE.tmp" && mv "$CONFIG_FILE.tmp" "$CONFIG_FILE" + echo "Host '$alias' deleted." +} + +# Edit host key +edit_host() { + [ $# -lt 2 ] && echo "edit requires alias and new private key" && usage + alias="$1"; newpriv="$2"; newpub="$3" + [ -z "$newpub" ] && newpub="${newpriv}.pub" + entry=$(load_entry "$alias") + [ -z "$entry" ] && echo "Alias '$alias' not found." && exit 1 + + IFS='|' read -r _ userhost oldpriv oldpub < ~/.ssh/authorized_keys.tmp && mv ~/.ssh/authorized_keys.tmp ~/.ssh/authorized_keys" || echo "Warning: could not remove old key." + + # Install new key + echo "Installing new public key to $userhost..." + ssh-copy-id -i "$newpub" "$userhost" || { echo "Failed to install new key."; exit 1; } + + # Update config + grep -v "^$alias|" "$CONFIG_FILE" > "$CONFIG_FILE.tmp" + echo "$alias|$userhost|$newpriv|$newpub" >> "$CONFIG_FILE.tmp" + mv "$CONFIG_FILE.tmp" "$CONFIG_FILE" + echo "Host '$alias' updated." +} + +# Query host +query_host() { + [ $# -lt 1 ] && echo "query requires alias" && usage + alias="$1" + entry=$(load_entry "$alias") + [ -z "$entry" ] && echo "Alias '$alias' not found." && exit 1 + IFS='|' read -r _ userhost privkey pubkey <