#!/usr/bin/env ucode // SPDX-License-Identifier: GPL-2.0-or-later // Copyright (C) 2025 Felix Fietkau 'use strict'; import * as datamodel from "cli.datamodel"; import { bold, color_fg } from "cli.color"; import * as uline from "uline"; import { basename, stdin } from "fs"; let history = []; let history_edit; let history_idx = -1; let cur_line; let interactive, script_mode, raw_mode; while (length(ARGV) > 0) { let cmd = ARGV[0]; if (substr(cmd, 0, 1) != "-") break; shift(ARGV); switch (cmd) { case '-i': interactive = true; break; case '-s': script_mode = true; break; case '-R': raw_mode = true; break; } } let el; let model = datamodel.new({ getpass: uline.getpass, poll_key: (timeout) => el.poll_key(timeout), status_msg: (msg) => { el.hide_prompt(); warn(msg + "\n"); el.refresh_prompt(); }, opt_pretty_print: !raw_mode }); let uloop = model.uloop; model.add_modules(); let ctx = model.context(); let parser = uline.arg_parser({ line_separator: ";" }); let base_prompt = [ "cli" ]; model.add_nodes({ Root: { exit: { help: "Exit the CLI", call: function(ctx) { el.close(); uloop.end(); interactive = false; return ctx.ok(); } } } }); model.init(); function update_prompt() { el.set_state({ prompt: bold(join(" ", [ ...base_prompt, ...ctx.prompt ]) + "> "), }); } let cur_completion, tab_arg, tab_arg_len, tab_prefix, tab_suffix, tab_prefix_len, tab_quote, tab_ctx; function max_len(list, len) { for (let entry in list) if (length(entry) > len) len = length(entry); return len + 3; } function sort_completion(data) { let categories = {}; for (let entry in data) { let cat = entry.category ?? " "; categories[cat] ??= []; push(categories[cat], entry); } return categories; } function val_str(val) { if (type(val) == "array") return join(", ", val); return val; } function helptext_list_str(cur, str) { let data = cur.value; let categories = sort_completion(data); let cat_len = max_len(keys(categories)); let has_categories = length(categories) > 1 || !categories[" "]; let len = max_len(map(data, (v) => v.name), 10); if (has_categories || str == null) str = ""; for (let cat, cdata in categories) { if (has_categories && cat != " ") { if (length(str) > 0) str += "\n"; str += `${cat}:\n`; } for (let val in cdata) { let name = val.name; let help = val.help ?? ""; let extra = []; if (val.multiple) push(extra, "multiple"); if (val.required) push(extra, "required"); if (val.default) push(extra, "default: " + val_str(val.default)); if (length(extra) > 0) help += " (" + join(", ", extra) + ")"; if (length(help) > 0) name += ":"; str += sprintf(" %-" + len + "s %s\n", name, help); } } return str; } function helptext(cur) { if (!cur) { el.set_hint(`\n No help information available\n`); return true; } let str = `${cur.help}: `; let data = cur.value; if (type(data) != "array") { str += `<${cur.type}>\n`; } else if (length(data) > 0) { str += "\n"; str = helptext_list_str(cur, str); } else { str += " (no match)\n"; } el.set_hint(str); return true; } function completion_ctx(arg_info) { let cur_ctx = ctx; for (let args in arg_info.args) { let sel = cur_ctx.select(args, true); if (!length(args)) cur_ctx = sel; if (type(sel) != "object" || sel.errors) return; } return cur_ctx; } function completion_replace_arg(val, incomplete, skip_space) { let ref = substr(tab_prefix, -tab_prefix_len); val = parser.escape(val, ref); if (incomplete) { let last = substr(val, -1); if (last == '"' || last == "'") val = substr(val, 0, -1); } else if (!skip_space) { val += " "; } let line = tab_prefix; if (tab_prefix_len) line = substr(tab_prefix, 0, -tab_prefix_len); line += val; let pos = length(line); line += tab_suffix; el.set_state({ line, pos }); } function completion_check_prefix(data) { let prefix = data[0].name; let prefix_len = length(prefix); for (let entry in data) { entry = entry.name; if (prefix_len > length(entry)) prefix_len = length(entry); } prefix = substr(prefix, 0, prefix_len); for (let entry in data) { entry = substr(entry.name, 0, prefix_len); while (entry != prefix) { prefix_len--; prefix = substr(prefix, 0, prefix_len); entry = substr(entry, 0, prefix_len); } } completion_replace_arg(prefix, true); } function completion(count) { if (count < 2) { let line_data = el.get_line(); let line = line_data.line; let pos = line_data.pos; tab_suffix = substr(line, pos); if (length(tab_suffix) > 0 && substr(tab_suffix, 0, 1) != " ") { let idx = index(tab_suffix, " "); if (idx < 0 || !idx) pos += length(tab_suffix); else pos += idx; tab_suffix = substr(line, pos); } tab_prefix = substr(line, 0, pos); let arg_info = parser.parse(tab_prefix); let is_open = arg_info.missing != null; if (arg_info.missing == "\\\"") tab_quote = "\""; else tab_quote = arg_info.missing ?? ""; let args = pop(arg_info.args); let arg_pos = pop(arg_info.pos); if (!is_open && substr(tab_prefix, -1) == " ") push(args, ""); let tab_arg_pos = arg_pos[length(args) - 1]; tab_arg = args[length(args) - 1]; if (tab_arg_pos) tab_prefix_len = tab_arg_pos[1] - tab_arg_pos[0]; else tab_prefix_len = 0; tab_ctx = completion_ctx(arg_info); if (!tab_ctx) return; cur_completion = tab_ctx.complete([...args]); } if (!tab_ctx) return; if (count < 0 || (cur_completion && cur_completion.force_helptext)) return helptext(cur_completion); let cur = cur_completion; if (!cur || !cur.value) { if (!tab_prefix_len) { el.set_hint(""); return; } cur = { value: [{ name: tab_arg, }] }; } let data = cur.value; if (length(data) == 0) { el.set_hint(` (no match)`); return; } if (length(data) == 1) { completion_replace_arg(data[0].name, data[0].incomplete); el.set_hint(""); el.reset_key_input(); return; } if (count == 1) completion_check_prefix(data); if (count > 1) { let idx = (count - 2) % length(data); completion_replace_arg(data[idx].name, false, true); } let win = el.get_window(); let str = ""; let x = 0; let categories = sort_completion(data); let cat_len = max_len(keys(categories)); let len = max_len(map(data, (v) => v.name)); let has_categories = length(categories) > 1 || !categories[" "]; for (let cat, cdata in categories) { let cat_start = cat != " "; if (cat_start) cat += ": "; if (x) { str += "\n"; x = 0; } for (let entry in cdata) { let add; if (!x && has_categories) add = sprintf(" %-"+cat_len+"s", cat); else add = " "; cat = ""; let name = entry.name; if (entry.incomplete) name += "..."; add += sprintf("%-"+len+"s", name); str += add; x += length(add); if (x + length(add) < win.x) continue; str += "\n"; x = 0; } } el.set_hint(str); } function format_entry(val) { if (type(val) == "bool") val = val ? "yes" : "no"; return val; } function format_multiline(prefix, val) { let prefix2 = replace(prefix, /./g, " "); let prefix_len = length(prefix); let win = el.get_window(); let x = 0; if (type(val) != "array") val = [ val ]; if (length(val) == 0) val = [ "" ]; for (let cur in val) { cur = format_entry(cur); let cur_lines = split(cur, "\n"); if (length(cur_lines) > 1) { if (x) { warn(',\n'); x = 0; } cur = join("\n" + prefix2, cur_lines); warn(cur); x = win.x; prefix = null; continue; } if (x && (x + length(cur) > win.x - 3)) { warn(',\n'); x = 0; } if (!x) { warn(prefix ?? prefix2); prefix = null; x = prefix_len; } else { warn(', '); x += 2; } warn(cur); x += length(cur); } warn('\n'); } function format_table(table) { let data = table; let len = max_len(map(data, (v) => v[0]), 8); for (let line in data) { let name = line[0]; let val = line[1]; let prefix = sprintf(" %-" + len + "s ", name + ":"); format_multiline(prefix, val); } } function convert_table(val) { if (type(val) == "array") return val; let data = []; for (let name in sort(keys(val))) push(data, [ name, val[name] ]); return data; } function convert_multi_table(val) { if (type(val) != "array") { let data = []; for (let name in sort(keys(val))) push(data, [ val[name], name ]); val = data; } for (let line in val) line[0] = convert_table(line[0]); return val; } function format_result(res) { if (!res) { warn(color_fg("red", "Unknown command") + "\n"); return; } if (!res.ok) { for (let err in res.errors) { warn(color_fg("red", "Error: "+ err.msg) + "\n"); } if (!length(res.errors)) warn(color_fg("red", "Failed") + "\n"); return; } if (res.status_msg) warn(color_fg("green", res.status_msg) + "\n"); if (res.name) warn(res.name + ": "); let data = res.data; switch (res.type) { case "multi_table": data = convert_multi_table(data); warn("\n"); for (let table in data) { if (table[1]) warn("\n" + table[1] + ":\n"); format_table(table[0]); warn("\n"); } break; case "table": data = convert_table(data); warn("\n"); format_table(data); break; case "list": warn("\n"); for (let entry in data) warn(" - " + entry + "\n"); break; case "string": warn(res.data + "\n"); break; case "json": warn(sprintf("%.J\n", res.data)); break; } } function line_history_reset() { history_idx = -1; history_edit = null; cur_line = null; } function line_history(dir) { let min_idx = cur_line == null ? 0 : -1; let new_idx = history_idx + dir; if (new_idx < min_idx || new_idx >= length(history)) return; let line = el.get_line().line; let cur_history = history_edit ?? history; if (history_idx == -1) cur_line = line; else if (cur_history[history_idx] != line) { history_edit ??= [ ...history ]; history_edit[history_idx] = line; cur_history = history_edit; } history_idx = new_idx; if (history_idx < 0) line = cur_line; else line = cur_history[history_idx]; let pos = length(line); el.set_state({ line, pos }); } let rev_search, rev_search_results, rev_search_index; function reverse_search_update(line) { if (line) { rev_search = line; rev_search_results = filter(history, (l) => index(l, line) >= 0); rev_search_index = 0; } let prompt = "reverse-search: "; if (line && !length(rev_search_results)) prompt = "failing " + prompt; el.set_state({ line2_prompt: prompt, }); if (line && length(rev_search_results)) { line = rev_search_results[0]; let pos = length(line); el.set_state({ line, pos }); } } function reverse_search_reset() { if (rev_search == null) return; rev_search = null; rev_search_results = null; rev_search_index = 0; el.set_state({ line2_prompt: null }); } function reverse_search() { if (rev_search == null) { reverse_search_update(""); return; } if (!length(rev_search_results)) return; rev_search_index = (rev_search_index + 1) % length(rev_search_results); let line = rev_search_results[rev_search_index]; let pos = length(line); el.set_state({ line, pos }); } function line_cb(line) { reverse_search_reset(); line_history_reset(); unshift(history, line); let arg_info = parser.parse(line); if (!arg_info) return; for (let cmd in arg_info.args) { let orig_cmd = [ ...cmd ]; // convenience hack if (cmd[0] == "cd" && cmd[1] == "..") { shift(cmd); cmd[0] = "up"; } else if (cmd[0] == "ls") { let compl = ctx.complete([""]); if (!compl) continue; warn(helptext_list_str(compl)); continue; } let cur_ctx = ctx.select(cmd); if (type(cur_ctx) != "object" || cur_ctx.errors) { format_result(cur_ctx); break; } if (!length(cmd)) { ctx = cur_ctx; update_prompt(); continue; } try { let res = cur_ctx.call(cmd); format_result(res); if (res && res.ctx) { ctx = res.ctx; update_prompt(); } } catch (e) { model.exception(e); } } } const cb = { eof: () => { warn(`\n`); uloop.end(); }, line_check: (line) => parser.check(line) == null, line2_cursor: () => { reverse_search_reset(); return false; }, line2_update: reverse_search_update, key_input: (c, count) => { try { switch(c) { case "?": if (parser.check(el.get_line().line) != null) return false; completion(-1); return true; case "\t": reverse_search_reset(); completion(count); return true; case '\x03': if (count < 2) { el.set_state({ line: "", pos: 0 }); } else if (ctx.prev) { warn(`\n`); let cur_ctx = ctx.select([ "main" ]); if (cur_ctx && !cur_ctx.errors) ctx = cur_ctx; update_prompt(); } else { warn(`\n`); el.poll_stop(); uloop.end(); } return true; case "\x12": reverse_search(); return true; } } catch (e) { warn(`${e}\n${e.stacktrace[0].context}`); } }, cursor_up: () => { try { line_history(1); } catch (e) { el.set_hint(`${e}\n${e.stacktrace[0].context}`); } }, cursor_down: () => { try { line_history(-1); } catch (e) { el.set_hint(`${e}\n${e.stacktrace[0].context}`); } }, }; el = uline.new({ utf8: true, cb, key_input_list: [ "?", "\t", "\x03", "\x12" ] }); if (SCRIPT_NAME != "cli") { let cur_ctx = ctx.select([ basename(SCRIPT_NAME) ]); if (cur_ctx && cur_ctx != ctx && !cur_ctx.errors) { ctx = cur_ctx; delete ctx.prev; ctx.node.exit = model.node.Root.exit; base_prompt = []; } } while (length(ARGV) > 0) { let cmd = ARGV; let idx = index(ARGV, ":"); if (idx >= 0) { cmd = slice(ARGV, 0, idx); ARGV = slice(ARGV, idx + 1); } else { ARGV = []; } interactive ??= false; let orig_cmd = [ ...cmd ]; let cur_ctx = ctx.select(cmd); if (type(cur_ctx) != "object" || cur_ctx.errors) { format_result(cur_ctx); break; } if (!length(cmd)) { ctx = cur_ctx; continue; } let res = cur_ctx.call(cmd); format_result(res); } if (script_mode) { el.close(); while (!stdin.error()) { let line = stdin.read("line"); line_cb(line); } exit(0); } if (interactive != false) { warn("Welcome to the OpenWrt CLI. Press '?' for help on commands/arguments\n"); update_prompt(); el.set_uloop(line_cb); uloop.run(); exit(0); }