-- SPDX-FileCopyrightText: 2017 Daniel Ratcliffe -- -- SPDX-License-Identifier: LicenseRef-CCPL --[[- The shell API provides access to CraftOS's command line interface. It allows you to [start programs][`run`], [add completion for a program][`setCompletionFunction`], and much more. [`shell`] is not a "true" API. Instead, it is a standard program, which injects its API into the programs that it launches. This allows for multiple shells to run at the same time, but means that the API is not available in the global environment, and so is unavailable to other [APIs][`os.loadAPI`]. ## Programs and the program path When you run a command with the shell, either from the prompt or [from Lua code][`shell.run`], the shell API performs several steps to work out which program to run: 1. Firstly, the shell attempts to resolve [aliases][`shell.aliases`]. This allows us to use multiple names for a single command. For example, the `list` program has two aliases: `ls` and `dir`. When you write `ls /rom`, that's expanded to `list /rom`. 2. Next, the shell attempts to find where the program actually is. For this, it uses the [program path][`shell.path`]. This is a colon separated list of directories, each of which is checked to see if it contains the program. `list` or `list.lua` doesn't exist in `.` (the current directory), so the shell now looks in `/rom/programs`, where `list.lua` can be found! 3. Finally, the shell reads the file and checks if the file starts with a `#!`. This is a [hashbang][], which says that this file shouldn't be treated as Lua, but instead passed to _another_ program, the name of which should follow the `#!`. [hashbang]: https://en.wikipedia.org/wiki/Shebang_(Unix) @module[module] shell @changed 1.103.0 Added support for hashbangs. ]] local make_package = dofile("rom/modules/main/cc/require.lua").make local multishell = multishell local parentShell = shell local parentTerm = term.current() if multishell then multishell.setTitle(multishell.getCurrent(), "shell") end local bExit = false local sDir = parentShell and parentShell.dir() or "" local sPath = parentShell and parentShell.path() or ".:/rom/programs" local tAliases = parentShell and parentShell.aliases() or {} local tCompletionInfo = parentShell and parentShell.getCompletionInfo() or {} local tProgramStack = {} local shell = {} --- @export local function createShellEnv(dir) local env = { shell = shell, multishell = multishell } env.require, env.package = make_package(env, dir) return env end -- Set up a dummy require based on the current shell, for loading some of our internal dependencies. local require do local env = setmetatable(createShellEnv("/rom/programs"), { __index = _ENV }) require = env.require end local expect = require("cc.expect").expect local exception = require "cc.internal.exception" -- Colours local promptColour, textColour, bgColour if term.isColour() then promptColour = colours.yellow textColour = colours.white bgColour = colours.black else promptColour = colours.white textColour = colours.white bgColour = colours.black end local function tokenise(...) local sLine = table.concat({ ... }, " ") local tWords = {} local bQuoted = false for match in string.gmatch(sLine .. "\"", "(.-)\"") do if bQuoted then table.insert(tWords, match) else for m in string.gmatch(match, "[^ \t]+") do table.insert(tWords, m) end end bQuoted = not bQuoted end return tWords end -- Execute a program using os.run, unless a shebang is present. -- In that case, execute the program using the interpreter specified in the hashbang. -- This may occur recursively, up to the maximum number of times specified by remainingRecursion -- Returns the same type as os.run, which is a boolean indicating whether the program exited successfully. local function executeProgram(remainingRecursion, path, args) local file, err = fs.open(path, "r") if not file then printError(err) return false end -- First check if the file begins with a #! local contents = file.readLine() or "" if contents:sub(1, 2) == "#!" then file.close() remainingRecursion = remainingRecursion - 1 if remainingRecursion == 0 then printError("Hashbang recursion depth limit reached when loading file: " .. path) return false end -- Load the specified hashbang program instead local hashbangArgs = tokenise(contents:sub(3)) local originalHashbangPath = table.remove(hashbangArgs, 1) local resolvedHashbangProgram = shell.resolveProgram(originalHashbangPath) if not resolvedHashbangProgram then printError("Hashbang program not found: " .. originalHashbangPath) return false elseif resolvedHashbangProgram == "rom/programs/shell.lua" and #hashbangArgs == 0 then -- If we try to launch the shell then our shebang expands to "shell ", which just does a -- shell.run("") again, resulting in an infinite loop. This may still happen (if the user -- has a custom shell), but this reduces the risk. -- It's a little ugly special-casing this, but it's probably worth warning about. printError("Cannot use the shell as a hashbang program") return false end -- Add the path and any arguments to the interpreter's arguments table.insert(hashbangArgs, path) for _, v in ipairs(args) do table.insert(hashbangArgs, v) end hashbangArgs[0] = originalHashbangPath return executeProgram(remainingRecursion, resolvedHashbangProgram, hashbangArgs) end contents = contents .. "\n" .. (file.readAll() or "") file.close() local dir = fs.getDir(path) local env = setmetatable(createShellEnv(dir), { __index = _G }) env.arg = args local func, err = load(contents, "@/" .. path, nil, env) if not func then -- We had a syntax error. Attempt to run it through our own parser if -- the file is "small enough", otherwise report the original error. if #contents < 1024 * 128 then local parser = require "cc.internal.syntax" if parser.parse_program(contents) then printError(err) end else printError(err) end return false end if settings.get("bios.strict_globals", false) then getmetatable(env).__newindex = function(_, name) error("Attempt to create global " .. tostring(name), 2) end end local ok, err, co = exception.try(func, table.unpack(args, 1, args.n)) if ok then return true end if err and err ~= "" then printError(err) exception.report(err, co) end return false end --- Run a program with the supplied arguments. -- -- Unlike [`shell.run`], each argument is passed to the program verbatim. While -- `shell.run("echo", "b c")` runs `echo` with `b` and `c`, -- `shell.execute("echo", "b c")` runs `echo` with a single argument `b c`. -- -- @tparam string command The program to execute. -- @tparam string ... Arguments to this program. -- @treturn boolean Whether the program exited successfully. -- @since 1.88.0 -- @usage Run `paint my-image` from within your program: -- -- shell.execute("paint", "my-image") function shell.execute(command, ...) expect(1, command, "string") for i = 1, select('#', ...) do expect(i + 1, select(i, ...), "string") end local sPath = shell.resolveProgram(command) if sPath ~= nil then tProgramStack[#tProgramStack + 1] = sPath if multishell then local sTitle = fs.getName(sPath) if sTitle:sub(-4) == ".lua" then sTitle = sTitle:sub(1, -5) end multishell.setTitle(multishell.getCurrent(), sTitle) end local result = executeProgram(100, sPath, { [0] = command, ... }) tProgramStack[#tProgramStack] = nil if multishell then if #tProgramStack > 0 then local sTitle = fs.getName(tProgramStack[#tProgramStack]) if sTitle:sub(-4) == ".lua" then sTitle = sTitle:sub(1, -5) end multishell.setTitle(multishell.getCurrent(), sTitle) else multishell.setTitle(multishell.getCurrent(), "shell") end end return result else printError("No such program") return false end end -- Install shell API --- Run a program with the supplied arguments. -- -- All arguments are concatenated together and then parsed as a command line. As -- a result, `shell.run("program a b")` is the same as `shell.run("program", -- "a", "b")`. -- -- @tparam string ... The program to run and its arguments. -- @treturn boolean Whether the program exited successfully. -- @usage Run `paint my-image` from within your program: -- -- shell.run("paint", "my-image") -- @see shell.execute Run a program directly without parsing the arguments. -- @changed 1.80pr1 Programs now get their own environment instead of sharing the same one. -- @changed 1.83.0 `arg` is now added to the environment. function shell.run(...) local tWords = tokenise(...) local sCommand = tWords[1] if sCommand then return shell.execute(sCommand, table.unpack(tWords, 2)) end return false end --- Exit the current shell. -- -- This does _not_ terminate your program, it simply makes the shell terminate -- after your program has finished. If this is the toplevel shell, then the -- computer will be shutdown. function shell.exit() bExit = true end --- Return the current working directory. This is what is displayed before the -- `> ` of the shell prompt, and is used by [`shell.resolve`] to handle relative -- paths. -- -- @treturn string The current working directory. -- @see setDir To change the working directory. function shell.dir() return sDir end --- Set the current working directory. -- -- @tparam string dir The new working directory. -- @throws If the path does not exist or is not a directory. -- @usage Set the working directory to "rom" -- -- shell.setDir("rom") function shell.setDir(dir) expect(1, dir, "string") if not fs.isDir(dir) then error("Not a directory", 2) end sDir = fs.combine(dir, "") end --- Set the path where programs are located. -- -- The path is composed of a list of directory names in a string, each separated -- by a colon (`:`). On normal turtles will look in the current directory (`.`), -- `/rom/programs` and `/rom/programs/turtle` folder, making the path -- `.:/rom/programs:/rom/programs/turtle`. -- -- @treturn string The current shell's path. -- @see setPath To change the current path. function shell.path() return sPath end --- Set the [current program path][`path`]. -- -- Be careful to prefix directories with a `/`. Otherwise they will be searched -- for from the [current directory][`shell.dir`], rather than the computer's root. -- -- @tparam string path The new program path. -- @since 1.2 function shell.setPath(path) expect(1, path, "string") sPath = path end --- Resolve a relative path to an absolute path. -- -- The [`fs`] and [`io`] APIs work using absolute paths, and so we must convert -- any paths relative to the [current directory][`dir`] to absolute ones. This -- does nothing when the path starts with `/`. -- -- @tparam string path The path to resolve. -- @usage Resolve `startup.lua` when in the `rom` folder. -- -- shell.setDir("rom") -- print(shell.resolve("startup.lua")) -- -- => rom/startup.lua function shell.resolve(path) expect(1, path, "string") local sStartChar = string.sub(path, 1, 1) if sStartChar == "/" or sStartChar == "\\" then return fs.combine("", path) else return fs.combine(sDir, path) end end local function pathWithExtension(_sPath, _sExt) local nLen = #sPath local sEndChar = string.sub(_sPath, nLen, nLen) -- Remove any trailing slashes so we can add an extension to the path safely if sEndChar == "/" or sEndChar == "\\" then _sPath = string.sub(_sPath, 1, nLen - 1) end return _sPath .. "." .. _sExt end --- Resolve a program, using the [program path][`path`] and list of [aliases][`aliases`]. -- -- @tparam string command The name of the program -- @treturn string|nil The absolute path to the program, or [`nil`] if it could -- not be found. -- @since 1.2 -- @changed 1.80pr1 Now searches for files with and without the `.lua` extension. -- @usage Locate the `hello` program. -- -- shell.resolveProgram("hello") -- -- => rom/programs/fun/hello.lua function shell.resolveProgram(command) expect(1, command, "string") -- Substitute aliases firsts if tAliases[command] ~= nil then command = tAliases[command] end -- If the path is a global path, use it directly if command:find("/") or command:find("\\") then local sPath = shell.resolve(command) if fs.exists(sPath) and not fs.isDir(sPath) then return sPath else local sPathLua = pathWithExtension(sPath, "lua") if fs.exists(sPathLua) and not fs.isDir(sPathLua) then return sPathLua end end return nil end -- Otherwise, look on the path variable for sPath in string.gmatch(sPath, "[^:]+") do sPath = fs.combine(shell.resolve(sPath), command) if fs.exists(sPath) and not fs.isDir(sPath) then return sPath else local sPathLua = pathWithExtension(sPath, "lua") if fs.exists(sPathLua) and not fs.isDir(sPathLua) then return sPathLua end end end -- Not found return nil end --- Return a list of all programs on the [path][`shell.path`]. -- -- @tparam[opt] boolean include_hidden Include hidden files. Namely, any which -- start with `.`. -- @treturn { string } A list of available programs. -- @usage textutils.tabulate(shell.programs()) -- @since 1.2 function shell.programs(include_hidden) expect(1, include_hidden, "boolean", "nil") local tItems = {} -- Add programs from the path for sPath in string.gmatch(sPath, "[^:]+") do sPath = shell.resolve(sPath) if fs.isDir(sPath) then local tList = fs.list(sPath) for n = 1, #tList do local sFile = tList[n] if not fs.isDir(fs.combine(sPath, sFile)) and (include_hidden or string.sub(sFile, 1, 1) ~= ".") then if #sFile > 4 and sFile:sub(-4) == ".lua" then sFile = sFile:sub(1, -5) end tItems[sFile] = true end end end end -- Sort and return local tItemList = {} for sItem in pairs(tItems) do table.insert(tItemList, sItem) end table.sort(tItemList) return tItemList end local function completeProgram(sLine) local bIncludeHidden = settings.get("shell.autocomplete_hidden") if #sLine > 0 and (sLine:find("/") or sLine:find("\\")) then -- Add programs from the root return fs.complete(sLine, sDir, { include_files = true, include_dirs = false, include_hidden = bIncludeHidden, }) else local tResults = {} local tSeen = {} -- Add aliases for sAlias in pairs(tAliases) do if #sAlias > #sLine and string.sub(sAlias, 1, #sLine) == sLine then local sResult = string.sub(sAlias, #sLine + 1) if not tSeen[sResult] then table.insert(tResults, sResult) tSeen[sResult] = true end end end -- Add all subdirectories. We don't include files as they will be added in the block below local tDirs = fs.complete(sLine, sDir, { include_files = false, include_dirs = false, include_hidden = bIncludeHidden, }) for i = 1, #tDirs do local sResult = tDirs[i] if not tSeen[sResult] then table.insert (tResults, sResult) tSeen [sResult] = true end end -- Add programs from the path local tPrograms = shell.programs() for n = 1, #tPrograms do local sProgram = tPrograms[n] if #sProgram > #sLine and string.sub(sProgram, 1, #sLine) == sLine then local sResult = string.sub(sProgram, #sLine + 1) if not tSeen[sResult] then table.insert(tResults, sResult) tSeen[sResult] = true end end end -- Sort and return table.sort(tResults) return tResults end end local function completeProgramArgument(sProgram, nArgument, sPart, tPreviousParts) local tInfo = tCompletionInfo[sProgram] if tInfo then return tInfo.fnComplete(shell, nArgument, sPart, tPreviousParts) end return nil end --- Complete a shell command line. -- -- This accepts an incomplete command, and completes the program name or -- arguments. For instance, `l` will be completed to `ls`, and `ls ro` will be -- completed to `ls rom/`. -- -- Completion handlers for your program may be registered with -- [`shell.setCompletionFunction`]. -- -- @tparam string sLine The input to complete. -- @treturn { string }|nil The list of possible completions. -- @see _G.read For more information about completion. -- @see shell.completeProgram -- @see shell.setCompletionFunction -- @see shell.getCompletionInfo -- @since 1.74 function shell.complete(sLine) expect(1, sLine, "string") if #sLine > 0 then local tWords = tokenise(sLine) local nIndex = #tWords if string.sub(sLine, #sLine, #sLine) == " " then nIndex = nIndex + 1 end if nIndex == 1 then local sBit = tWords[1] or "" local sPath = shell.resolveProgram(sBit) if tCompletionInfo[sPath] then return { " " } else local tResults = completeProgram(sBit) for n = 1, #tResults do local sResult = tResults[n] local sPath = shell.resolveProgram(sBit .. sResult) if tCompletionInfo[sPath] then tResults[n] = sResult .. " " end end return tResults end elseif nIndex > 1 then local sPath = shell.resolveProgram(tWords[1]) local sPart = tWords[nIndex] or "" local tPreviousParts = tWords tPreviousParts[nIndex] = nil return completeProgramArgument(sPath , nIndex - 1, sPart, tPreviousParts) end end return nil end --- Complete the name of a program. -- -- @tparam string program The name of a program to complete. -- @treturn { string } A list of possible completions. -- @see cc.shell.completion.program function shell.completeProgram(program) expect(1, program, "string") return completeProgram(program) end --- Set the completion function for a program. When the program is entered on -- the command line, this program will be called to provide auto-complete -- information. -- -- The completion function accepts four arguments: -- -- 1. The current shell. As completion functions are inherited, this is not -- guaranteed to be the shell you registered this function in. -- 2. The index of the argument currently being completed. -- 3. The current argument. This may be the empty string. -- 4. A list of the previous arguments. -- -- For instance, when completing `pastebin put rom/st` our pastebin completion -- function will receive the shell API, an index of 2, `rom/st` as the current -- argument, and a "previous" table of `{ "put" }`. This function may then wish -- to return a table containing `artup.lua`, indicating the entire command -- should be completed to `pastebin put rom/startup.lua`. -- -- You completion entries may also be followed by a space, if you wish to -- indicate another argument is expected. -- -- @tparam string program The path to the program. This should be an absolute path -- _without_ the leading `/`. -- @tparam function(shell: table, index: number, argument: string, previous: { string }):({ string }|nil) complete -- The completion function. -- @see cc.shell.completion Various utilities to help with writing completion functions. -- @see shell.complete -- @see _G.read For more information about completion. -- @since 1.74 function shell.setCompletionFunction(program, complete) expect(1, program, "string") expect(2, complete, "function") tCompletionInfo[program] = { fnComplete = complete, } end --- Get a table containing all completion functions. -- -- This should only be needed when building custom shells. Use -- [`setCompletionFunction`] to add a completion function. -- -- @treturn { [string] = { fnComplete = function } } A table mapping the -- absolute path of programs, to their completion functions. function shell.getCompletionInfo() return tCompletionInfo end --- Returns the path to the currently running program. -- -- @treturn string The absolute path to the running program. -- @since 1.3 function shell.getRunningProgram() if #tProgramStack > 0 then return tProgramStack[#tProgramStack] end return nil end --- Add an alias for a program. -- -- @tparam string command The name of the alias to add. -- @tparam string program The name or path to the program. -- @since 1.2 -- @usage Alias `vim` to the `edit` program -- -- shell.setAlias("vim", "edit") function shell.setAlias(command, program) expect(1, command, "string") expect(2, program, "string") tAliases[command] = program end --- Remove an alias. -- -- @tparam string command The alias name to remove. function shell.clearAlias(command) expect(1, command, "string") tAliases[command] = nil end --- Get the current aliases for this shell. -- -- Aliases are used to allow multiple commands to refer to a single program. For -- instance, the `list` program is aliased to `dir` or `ls`. Running `ls`, `dir` -- or `list` in the shell will all run the `list` program. -- -- @treturn { [string] = string } A table, where the keys are the names of -- aliases, and the values are the path to the program. -- @see shell.setAlias -- @see shell.resolveProgram This uses aliases when resolving a program name to -- an absolute path. function shell.aliases() -- Copy aliases local tCopy = {} for sAlias, sCommand in pairs(tAliases) do tCopy[sAlias] = sCommand end return tCopy end if multishell then --- Open a new [`multishell`] tab running a command. -- -- This behaves similarly to [`shell.run`], but instead returns the process -- index. -- -- This function is only available if the [`multishell`] API is. -- -- @tparam string ... The command line to run. -- @see shell.run -- @see multishell.launch -- @since 1.6 -- @usage Launch the Lua interpreter and switch to it. -- -- local id = shell.openTab("lua") -- shell.switchTab(id) function shell.openTab(...) local tWords = tokenise(...) local sCommand = tWords[1] if sCommand then local sPath = shell.resolveProgram(sCommand) if sPath == "rom/programs/shell.lua" then return multishell.launch(createShellEnv("rom/programs"), sPath, table.unpack(tWords, 2)) elseif sPath ~= nil then return multishell.launch(createShellEnv("rom/programs"), "rom/programs/shell.lua", sCommand, table.unpack(tWords, 2)) else printError("No such program") end end end --- Switch to the [`multishell`] tab with the given index. -- -- @tparam number id The tab to switch to. -- @see multishell.setFocus -- @since 1.6 function shell.switchTab(id) expect(1, id, "number") multishell.setFocus(id) end end local tArgs = { ... } if #tArgs > 0 then -- "shell x y z" -- Run the program specified on the commandline shell.run(...) else local function show_prompt() term.setBackgroundColor(bgColour) term.setTextColour(promptColour) write(shell.dir() .. "> ") term.setTextColour(textColour) end -- "shell" -- Print the header term.setBackgroundColor(bgColour) term.setTextColour(promptColour) print(os.version()) term.setTextColour(textColour) -- Run the startup program if parentShell == nil then shell.run("/rom/startup.lua") end -- Read commands and execute them local tCommandHistory = {} while not bExit do term.redirect(parentTerm) show_prompt() local complete if settings.get("shell.autocomplete") then complete = shell.complete end local ok, result local co = coroutine.create(read) assert(coroutine.resume(co, nil, tCommandHistory, complete)) while coroutine.status(co) ~= "dead" do local event = table.pack(os.pullEvent()) if event[1] == "file_transfer" then -- Abandon the current prompt local _, h = term.getSize() local _, y = term.getCursorPos() if y == h then term.scroll(1) term.setCursorPos(1, y) else term.setCursorPos(1, y + 1) end term.setCursorBlink(false) -- Run the import script with the provided files local ok, err = require("cc.internal.import")(event[2].getFiles()) if not ok and err then printError(err) end -- And attempt to restore the prompt. show_prompt() term.setCursorBlink(true) event = { "term_resize", n = 1 } -- Nasty hack to force read() to redraw. end if result == nil or event[1] == result or event[1] == "terminate" then ok, result = coroutine.resume(co, table.unpack(event, 1, event.n)) if not ok then error(result, 0) end end end if result:match("%S") and tCommandHistory[#tCommandHistory] ~= result then table.insert(tCommandHistory, result) end shell.run(result) end end