#!/usr/bin/env lua -- lmcp server — cross-platform shell tools -- Works on Linux, macOS, and Windows without modification. -- SPDX-License-Identifier: MIT -- Resolve package paths relative to this script local dir = arg[0]:match('(.*[/\\])') or './' local sep = package.config:sub(1, 1) -- '/' on Unix, '\\' on Windows package.path = package.path .. ';' .. dir .. '?.lua' -- Windows: add lua\ subdirectory for LuaSocket DLLs if sep == '\\' then package.cpath = package.cpath .. ';' .. dir .. 'lua\\?.dll' .. ';' .. dir .. 'lua\\socket\\?.dll' .. ';' .. dir .. 'lua\\mime\\?.dll' end local lmcp = require('lmcp') -- ---- Platform detection ---- local WINDOWS = sep == '\\' local function is_windows() return WINDOWS end -- ---- Non-blocking command execution with timeout ---- -- io.popen blocks until the child exits. On any OS, a long-running -- process (like a daemon) will hang lmcp forever. We work around this -- by spawning into temp files and polling a sentinel. local function tmpname() if WINDOWS then local tmp = os.getenv("TEMP") or "C:\\Windows\\Temp" return tmp .. "\\lmcp_" .. os.time() .. "_" .. math.random(10000, 99999) else return os.tmpname() end end local function sleep_ms(ms) if WINDOWS then -- ping loopback: ~1s per -n count. For sub-second, use busy-wait. if ms < 500 then local target = os.clock() + ms / 1000 while os.clock() < target do end else local secs = math.ceil(ms / 1000) os.execute("ping -n " .. (secs + 1) .. " 127.0.0.1 >nul 2>&1") end else -- POSIX: use sleep command (supports fractional seconds on GNU) if ms < 1000 then os.execute("sleep 0." .. string.format("%03d", ms)) else os.execute("sleep " .. math.ceil(ms / 1000)) end end end local function file_exists(path) local f = io.open(path, 'r') if f then f:close(); return true end return false end local function read_file(path) local f = io.open(path, 'r') if not f then return nil end local c = f:read('*a'); f:close(); return c end local function remove_silent(path) os.remove(path) end local function run(cmd, timeout_sec) timeout_sec = timeout_sec or 120 local base = tmpname() local out_file = base .. ".out" local done_file = base .. ".done" if WINDOWS then -- Write a batch wrapper that runs the command and signals completion local bat_file = base .. ".bat" local bf = io.open(bat_file, 'w') if not bf then return "Error: could not create temp file" end bf:write("@echo off\r\n") bf:write(cmd .. ' > "' .. out_file .. '" 2>&1\r\n') bf:write('echo %ERRORLEVEL% > "' .. done_file .. '"\r\n') bf:close() os.execute('start /B cmd /C "' .. bat_file .. '"') -- Poll for sentinel local elapsed = 0 local interval = 100 -- ms while elapsed < timeout_sec * 1000 do if file_exists(done_file) then break end sleep_ms(interval) elapsed = elapsed + interval if interval < 2000 then interval = math.floor(interval * 1.5) end end local output = read_file(out_file) remove_silent(bat_file) remove_silent(out_file) remove_silent(done_file) if elapsed >= timeout_sec * 1000 then return output or ("Error: command timed out after " .. timeout_sec .. "s") end return output and output ~= "" and output or "(no output)" else -- POSIX: use shell backgrounding + wait with timeout -- sh -c '(cmd > out 2>&1; echo $? > done) &' then poll local sh_cmd = string.format( "(%s) > '%s' 2>&1; echo $? > '%s'", cmd, out_file, done_file ) os.execute("sh -c '" .. sh_cmd:gsub("'", "'\\''") .. "' &") local elapsed = 0 local interval = 50 -- ms while elapsed < timeout_sec * 1000 do if file_exists(done_file) then break end sleep_ms(interval) elapsed = elapsed + interval if interval < 2000 then interval = math.floor(interval * 1.5) end end local output = read_file(out_file) remove_silent(out_file) remove_silent(done_file) if elapsed >= timeout_sec * 1000 then return output or ("Error: command timed out after " .. timeout_sec .. "s") end return output and output ~= "" and output or "(no output)" end end -- ---- Server setup ---- local server_name = os.getenv("LMCP_NAME") or (WINDOWS and "windows-tools" or "linux-tools") local server = lmcp.new(server_name, { port = tonumber(os.getenv("LMCP_PORT") or arg[1]) or 8080, }) -- ---- Tools ---- server:tool("shell", "Execute a shell command.", { type = "object", properties = { command = { type = "string", description = "Command to execute" }, cwd = { type = "string", description = "Working directory" }, timeout = { type = "integer", description = "Timeout in seconds", default = 120 }, powershell = { type = "boolean", description = "Use PowerShell (Windows only)", default = false }, }, required = { "command" }, }, function(a) local cmd = a.command if a.cwd then if WINDOWS then cmd = 'cd /d "' .. a.cwd .. '" && ' .. cmd else cmd = 'cd "' .. a.cwd .. '" && ' .. cmd end end if a.powershell and WINDOWS then cmd = 'powershell -NoProfile -Command "' .. cmd:gsub('"', '\\"') .. '"' end return run(cmd, a.timeout or 120) end) server:tool("shell_bg", "Fire-and-forget shell command (Linux-only). Fully detaches via setsid+nohup+stdio-redirect and returns immediately with PID and log path. Use for daemons that must outlive the lmcp request.", { type = "object", properties = { command = { type = "string", description = "Shell command to launch" }, cwd = { type = "string", description = "Working directory" }, log = { type = "string", description = "Log file (stdout+stderr). Default: /tmp/lmcp-bg--.log" }, }, required = { "command" }, }, function(a) if WINDOWS then return "Error: shell_bg is Linux-only (Windows Start-Process equivalent TBD)" end if type(a.command) ~= "string" or a.command == "" then return "Error: command required" end local log = a.log if not log or log == "" then log = string.format("/tmp/lmcp-bg-%d-%d.log", os.time(), math.random(1000, 9999)) end local pid_file = log .. ".pid" local inner = a.command if a.cwd and a.cwd ~= "" then inner = "cd '" .. a.cwd:gsub("'", "'\\''") .. "' && " .. inner end local sq = function(s) return "'" .. s:gsub("'", "'\\''") .. "'" end local full = string.format( "setsid nohup sh -c %s %s 2>&1 & echo $! > %s", sq(inner), sq(log), sq(pid_file) ) os.execute(full) local f = io.open(pid_file, 'r') local pid = "?" if f then pid = (f:read('*a') or ""):match("(%d+)") or "?" f:close() os.remove(pid_file) end return string.format("launched pid=%s log=%s", pid, log) end) server:tool("read_file", "Read a file.", { type = "object", properties = { path = { type = "string" } }, required = { "path" }, }, function(a) local c = read_file(a.path) if not c then return "Error: could not read " .. a.path end return c end) server:tool("write_file", "Write content to a file.", { type = "object", properties = { path = { type = "string" }, content = { type = "string" }, }, required = { "path", "content" }, }, function(a) local f = io.open(a.path, 'w') if not f then return "Error: could not write " .. a.path end f:write(a.content); f:close() return string.format("Written %d bytes to %s", #a.content, a.path) end) server:tool("edit_file", "Replace exact text in a file (literal match). Fails unless old_string is unique, unless replace_all=true.", { type = "object", properties = { path = { type = "string", description = "Path to file" }, old_string = { type = "string", description = "Exact text to replace (literal, no regex)" }, new_string = { type = "string", description = "Replacement text" }, replace_all = { type = "boolean", description = "Replace every occurrence (default: false)", default = false }, }, required = { "path", "old_string", "new_string" }, }, function(a) if type(a.path) ~= "string" or a.path == "" then return "Error: path required" end if type(a.old_string) ~= "string" then return "Error: old_string required" end if type(a.new_string) ~= "string" then return "Error: new_string required" end if a.old_string == "" then return "Error: old_string cannot be empty" end if a.old_string == a.new_string then return "Error: new_string must differ from old_string" end local f = io.open(a.path, 'rb') if not f then return "Error: could not read " .. a.path end local content = f:read('*a'); f:close() local count, pos = 0, 1 while pos <= #content do local i = content:find(a.old_string, pos, true) if not i then break end count = count + 1 pos = i + #a.old_string end if count == 0 then return "Error: old_string not found in " .. a.path end if count > 1 and not a.replace_all then return string.format("Error: old_string matches %d times in %s (use replace_all=true or provide more surrounding context to disambiguate)", count, a.path) end local parts, p, replaced = {}, 1, 0 while true do local i = content:find(a.old_string, p, true) if not i then break end parts[#parts+1] = content:sub(p, i-1) parts[#parts+1] = a.new_string p = i + #a.old_string replaced = replaced + 1 if not a.replace_all then break end end parts[#parts+1] = content:sub(p) local w = io.open(a.path, 'wb') if not w then return "Error: could not write " .. a.path end w:write(table.concat(parts)); w:close() return string.format("Edited %s: %d replacement(s)", a.path, replaced) end) server:tool("list_dir", "List directory contents.", { type = "object", properties = { path = { type = "string", default = "." } }, }, function(a) local path = a.path or "." if WINDOWS then return run('dir /b "' .. path .. '"', 10) else return run("ls -1 '" .. path:gsub("'", "'\\''") .. "'", 10) end end) server:tool("search_files", "Search for files by pattern.", { type = "object", properties = { pattern = { type = "string", description = "File name pattern" }, path = { type = "string", default = WINDOWS and "C:\\" or "/" }, }, required = { "pattern" }, }, function(a) local path = a.path or (WINDOWS and "C:\\" or "/") if WINDOWS then return run('dir /b /s "' .. path .. '\\' .. a.pattern .. '"', 30) else -- -L: follow symlinks on the start path. macOS BSD find otherwise -- silently emits nothing when the start path is itself a symlink -- (common on Homebrew, e.g. /usr/local/share/lua -> Cellar/…/share/lua). return run("find -L '" .. path:gsub("'", "'\\''") .. "' -name '" .. a.pattern:gsub("'", "'\\''") .. "' 2>/dev/null", 30) end end) if WINDOWS then server:tool("systeminfo", "Get Windows system information.", { type = "object", properties = {}, }, function() return run("systeminfo", 30) end) end io.stderr:write(string.format("lmcp %s starting on port %d (%s)\n", server_name, server.port, WINDOWS and "Windows" or "POSIX")) server:run()