forked from mirror/openwrt
This provides an easy to use modular CLI that can be used to interact with OpenWrt services. It has full support for context sensitive tab completion and help. Extra modules can be provided by packages and can extend the existing node structure in any place. Signed-off-by: Felix Fietkau <nbd@nbd.name>
679 lines
14 KiB
Ucode
679 lines
14 KiB
Ucode
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
// Copyright (C) 2025 Felix Fietkau <nbd@nbd.name>
|
|
'use strict';
|
|
|
|
import * as callctx from "cli.context-call";
|
|
|
|
function prefix_match(prefix, str, icase)
|
|
{
|
|
if (icase) {
|
|
str = lc(str);
|
|
prefix = lc(prefix);
|
|
}
|
|
return substr(str, 0, length(prefix)) == prefix;
|
|
}
|
|
|
|
function context_clone()
|
|
{
|
|
let ret = { ...this };
|
|
ret.prompt = [ ...ret.prompt ];
|
|
ret.data = { ...ret.data };
|
|
ret.hooks = {};
|
|
return proto(ret, proto(this));
|
|
}
|
|
|
|
function context_entries()
|
|
{
|
|
return keys(this.node)
|
|
}
|
|
|
|
function context_help(entry)
|
|
{
|
|
if (entry)
|
|
return this.node[entry].help;
|
|
|
|
let ret = {};
|
|
for (let name, val in this.node)
|
|
ret[name] = val.help ?? "";
|
|
|
|
return ret;
|
|
}
|
|
|
|
function context_add_hook(type, cb)
|
|
{
|
|
this.hooks[type] ??= [];
|
|
push(this.hooks[type], cb);
|
|
}
|
|
|
|
function context_select_error(code, msg, ...args)
|
|
{
|
|
msg ??= "Unknown error";
|
|
msg = sprintf(msg, ...args);
|
|
let error = {
|
|
code, msg, args
|
|
};
|
|
this.errors ??= [];
|
|
push(this.errors, error);
|
|
}
|
|
|
|
function context_set(prompt, data)
|
|
{
|
|
if (prompt)
|
|
this.cur_prompt = prompt;
|
|
if (data)
|
|
this.data = { ...this.data, ...data };
|
|
return true;
|
|
}
|
|
|
|
const context_select_proto = {
|
|
add_hook: context_add_hook,
|
|
error: context_select_error,
|
|
set: context_set,
|
|
...callctx.callctx_error_proto,
|
|
};
|
|
|
|
function __context_select(ctx, name, args)
|
|
{
|
|
let entry = ctx.node[name];
|
|
if (!entry || !entry.select_node)
|
|
return;
|
|
|
|
let node = ctx.model.node[entry.select_node];
|
|
if (!node)
|
|
return;
|
|
|
|
let ret = proto(ctx.clone(), ctx.model.context_select_proto);
|
|
ret.cur_prompt = name;
|
|
ret.node = node;
|
|
try {
|
|
if (entry.select &&
|
|
!call(entry.select, entry, ctx.model.scope, ret, args))
|
|
ret.errors ??= [];
|
|
} catch (e) {
|
|
ctx.model.exception(e);
|
|
return;
|
|
}
|
|
|
|
push(ret.prompt, ret.cur_prompt);
|
|
ret.prev = ctx;
|
|
proto(ret, proto(ctx));
|
|
|
|
return ret;
|
|
}
|
|
|
|
function context_run_hooks(ctx, name)
|
|
{
|
|
try {
|
|
while (length(ctx.hooks[name]) > 0) {
|
|
let hook = ctx.hooks[name][0];
|
|
|
|
let ret = call(hook, ctx, ctx.model.scope);
|
|
if (!ret)
|
|
return false;
|
|
|
|
shift(ctx.hooks.exit);
|
|
}
|
|
} catch (e) {
|
|
ctx.model.exception(e);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function context_prev(ctx, skip_hooks)
|
|
{
|
|
if (!skip_hooks && !context_run_hooks(ctx, "exit"))
|
|
return;
|
|
return ctx.prev;
|
|
}
|
|
|
|
function context_top(ctx, skip_hooks)
|
|
{
|
|
while (ctx && ctx.prev)
|
|
ctx = context_prev(ctx, skip_hooks);
|
|
return ctx;
|
|
}
|
|
|
|
function prepare_spec(e, ctx, spec, argv)
|
|
{
|
|
if (type(spec) != "function")
|
|
return spec;
|
|
|
|
return call(spec, e, ctx.model.scope, ctx, argv);
|
|
}
|
|
|
|
function prepare_default(e, ctx, spec, argv, named_args)
|
|
{
|
|
if (type(spec) != "object" || type(spec.default) != "function")
|
|
return;
|
|
|
|
try {
|
|
spec.default = call(spec.default, e, ctx.model.scope, ctx, argv, named_args, spec);
|
|
} catch (e) {
|
|
model.exception(e);
|
|
}
|
|
}
|
|
|
|
function prepare_attr_spec(e, ctx, spec, argv, named_args)
|
|
{
|
|
if (type(spec) != "object")
|
|
return spec;
|
|
|
|
let t = ctx.model.types[spec.type];
|
|
if (t)
|
|
spec = { ...t, ...spec };
|
|
else
|
|
spec = { ...spec };
|
|
|
|
prepare_default(e, ctx, spec, argv, named_args, spec);
|
|
if (type(spec.value) == "function")
|
|
try {
|
|
spec.value = call(spec.value, e, ctx.model.scope, ctx, argv, named_args, spec);
|
|
} catch (e) {
|
|
ctx.model.exception(e);
|
|
spec.value = [];
|
|
}
|
|
|
|
return spec;
|
|
}
|
|
|
|
function parse_arg(ctx, name, spec, val)
|
|
{
|
|
let t;
|
|
|
|
if (val == null) {
|
|
ctx.invalid_argument("Missing argument %s", name);
|
|
return;
|
|
}
|
|
|
|
if (type(spec) == "object" && spec.type)
|
|
t = ctx.model.types[spec.type];
|
|
if (!t) {
|
|
ctx.invalid_argument("Invalid type in argument: %s", name);
|
|
return;
|
|
}
|
|
|
|
if (!t.parse)
|
|
return val;
|
|
|
|
return call(t.parse, spec, ctx.model.scope, ctx, name, val);
|
|
}
|
|
|
|
const context_defaults = {
|
|
up: [ "Return to previous node", context_prev ],
|
|
exit: [ "Return to previous node", context_prev ],
|
|
main: [ "Return to main node", context_top ],
|
|
};
|
|
|
|
const context_default_order = [ "up", "exit", "main" ];
|
|
|
|
function context_select(args, completion)
|
|
{
|
|
let ctx = this;
|
|
|
|
while (length(args) > completion ? 1 : 0) {
|
|
let name = args[0];
|
|
let entry = ctx.node[name];
|
|
|
|
if (!entry) {
|
|
let e = context_defaults[name];
|
|
if (!e)
|
|
return ctx;
|
|
|
|
shift(args);
|
|
ctx = e[1](ctx, completion);
|
|
if (!ctx)
|
|
return;
|
|
|
|
continue;
|
|
}
|
|
|
|
if (!entry.select_node)
|
|
return ctx;
|
|
|
|
let num_args = length(entry.args);
|
|
if (completion && num_args + 1 >= length(args))
|
|
return ctx;
|
|
|
|
shift(args);
|
|
let argv = [];
|
|
let parse_ctx = callctx.new(this.model, ctx);
|
|
if (num_args > 0) {
|
|
let cur_argv = slice(args, 0, num_args);
|
|
for (let i = 0; i < num_args; i++) {
|
|
let arg = shift(args);
|
|
let spec = entry.args[i];
|
|
|
|
spec = prepare_attr_spec(entry, ctx, spec, cur_argv, {});
|
|
if (arg != null)
|
|
arg = parse_arg(parse_ctx, spec.name, spec, arg);
|
|
|
|
if (arg != null)
|
|
push(argv, arg);
|
|
}
|
|
|
|
}
|
|
|
|
if (entry.no_subcommands && length(args) > 0)
|
|
parse_ctx.invalid_argument("command %s does not support subcommands", name);
|
|
|
|
if (length(parse_ctx.result.errors) > 0) {
|
|
ctx = ctx.clone();
|
|
ctx.errors = parse_ctx.result.errors;
|
|
return ctx;
|
|
}
|
|
|
|
ctx = __context_select(ctx, name, argv);
|
|
if (type(ctx) != "object" || ctx.errors)
|
|
break;
|
|
}
|
|
|
|
return ctx;
|
|
}
|
|
|
|
function complete_named_params(ctx, entry, obj, name, argv, named_params)
|
|
{
|
|
let data = [];
|
|
let empty = "";
|
|
|
|
if (substr(name, 0, 1) == "-") {
|
|
empty = "-";
|
|
name = substr(name, 1);
|
|
}
|
|
|
|
let defaults = {};
|
|
callctx.new(ctx.model, ctx).apply_defaults(obj, defaults);
|
|
for (let cur_name in sort(keys(obj))) {
|
|
let val = obj[cur_name];
|
|
|
|
if (!prefix_match(name, cur_name) || val.no_complete)
|
|
continue;
|
|
|
|
if (empty && !(val.allow_empty ?? entry.allow_empty))
|
|
continue;
|
|
|
|
if (!val.multiple && named_params[cur_name] != null)
|
|
continue;
|
|
|
|
if (type(val.available) == "function" &&
|
|
!call(val.available, val, ctx.model.scope, ctx, argv, named_params))
|
|
continue;
|
|
|
|
val = {
|
|
name: empty + cur_name,
|
|
...val,
|
|
};
|
|
push(data, val);
|
|
}
|
|
|
|
return {
|
|
type: "keywords",
|
|
name: "parameter",
|
|
help: "Parameter name",
|
|
value: data
|
|
};
|
|
}
|
|
|
|
function complete_param(e, ctx, cur, val, args, named_args)
|
|
{
|
|
cur = prepare_attr_spec(e, ctx, cur, args, named_args);
|
|
|
|
if (type(cur.value) == "object") {
|
|
let ret = [];
|
|
for (let key in sort(keys(cur.value)))
|
|
if (prefix_match(val, key, cur.ignore_case))
|
|
push(ret, {
|
|
name: key,
|
|
help: cur.value[key]
|
|
});
|
|
|
|
cur.value = ret;
|
|
return cur;
|
|
}
|
|
|
|
if (type(cur.value) == "array") {
|
|
cur.value = map(sort(filter(cur.value, (v) => prefix_match(val, v, cur.ignore_case))), (v) => ({ name: v }));
|
|
return cur;
|
|
}
|
|
|
|
let type_info = ctx.model.types[cur.type];
|
|
if (!type_info || !type_info.complete)
|
|
return cur;
|
|
|
|
cur.value = call(type_info.complete, cur, ctx.model.scope, ctx, val);
|
|
|
|
return cur;
|
|
}
|
|
|
|
function complete_arg_list(e, ctx, arg_info, args, base_args, named_args)
|
|
{
|
|
let cur_idx = length(args) - 1;
|
|
let cur = arg_info[cur_idx];
|
|
let val;
|
|
|
|
for (let i = 0; i <= cur_idx; i++)
|
|
val = shift(args);
|
|
|
|
return complete_param(e, ctx, cur, val, base_args, named_args);
|
|
}
|
|
|
|
function handle_empty_param(entry, spec, name, argv, named_args)
|
|
{
|
|
if (substr(name, 0, 1) != "-")
|
|
return;
|
|
|
|
name = substr(name, 1);
|
|
let cur = spec[name];
|
|
if (!cur)
|
|
return;
|
|
|
|
if (cur.default == null &&
|
|
!(cur.allow_empty ?? entry.allow_empty))
|
|
return;
|
|
|
|
if (cur.required) {
|
|
cur = { ...cur };
|
|
prepare_default(e, ctx, cur, argv, named_args, cur);
|
|
named_args[name] = cur.default;
|
|
} else {
|
|
named_args[name] = null;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
|
|
function default_complete(ctx, args)
|
|
{
|
|
let num_args = length(this.args);
|
|
let named_args = {};
|
|
let cur_args;
|
|
|
|
if (length(args) <= num_args)
|
|
return complete_arg_list(this, ctx, this.args, args, [ ...args ], named_args);
|
|
|
|
let spec = prepare_spec(this, ctx, this.named_args, args);
|
|
if (!spec)
|
|
return;
|
|
|
|
let base_args = slice(args, 0, num_args);
|
|
for (let i = 0; i < num_args; i++)
|
|
shift(args);
|
|
|
|
while (length(args) > 0) {
|
|
let name = args[0];
|
|
|
|
if (length(args) == 1)
|
|
return complete_named_params(ctx, this, spec, name, base_args, named_args);
|
|
|
|
shift(args);
|
|
let cur = spec[name];
|
|
if (!cur) {
|
|
if (handle_empty_param(this, spec, name, base_args, named_args))
|
|
continue;
|
|
return;
|
|
}
|
|
|
|
if (!cur.args) {
|
|
named_args[name] = true;
|
|
continue;
|
|
}
|
|
|
|
let val;
|
|
let cur_spec = cur.args;
|
|
if (type(cur_spec) != "array") {
|
|
cur_spec = [{
|
|
name,
|
|
help: cur.help,
|
|
...cur_spec
|
|
}];
|
|
named_args[name] = shift(args);
|
|
val = [ named_args[name] ];
|
|
} else {
|
|
let num_args = length(cur_spec);
|
|
let val = [];
|
|
for (let i = 0; i < num_args; i++)
|
|
push(val, shift(args));
|
|
named_args[name] = val;
|
|
}
|
|
|
|
if (!length(args))
|
|
return complete_arg_list(this, ctx, cur_spec, val, base_args, named_args);
|
|
}
|
|
}
|
|
|
|
function context_complete(args)
|
|
{
|
|
let ctx = this.select(args, true);
|
|
if (!ctx || ctx.errors)
|
|
return;
|
|
|
|
if (ctx != this) {
|
|
ctx = ctx.clone();
|
|
ctx.skip_default_complete = true;
|
|
}
|
|
|
|
if (length(args) > 1) {
|
|
let name = shift(args);
|
|
let entry = ctx.node[name];
|
|
if (!entry)
|
|
return;
|
|
|
|
try {
|
|
if (!entry.available || call(entry.available, entry, ctx.model.scope, ctx, args))
|
|
return call(entry.complete ?? default_complete, entry, ctx.model.scope, ctx, args);
|
|
} catch (e) {
|
|
this.model.exception(e);
|
|
}
|
|
return;
|
|
}
|
|
|
|
let name = shift(args) ?? "";
|
|
let prefix_len = length(name);
|
|
let data = [];
|
|
let default_data = {};
|
|
for (let cur_name in sort(keys(ctx.node))) {
|
|
let val = ctx.node[cur_name];
|
|
|
|
if (substr(cur_name, 0, prefix_len) != name)
|
|
continue;
|
|
|
|
if (val.available && !call(val.available, val, ctx.model.scope, ctx, args))
|
|
continue;
|
|
|
|
let cur = {
|
|
name: cur_name,
|
|
help: val.help,
|
|
category: val.select_node ? "Object" : "Action",
|
|
};
|
|
if (context_defaults[cur_name])
|
|
default_data[cur_name] = cur;
|
|
else
|
|
push(data, cur);
|
|
}
|
|
|
|
for (let cur_name in context_default_order) {
|
|
if (substr(cur_name, 0, prefix_len) != name)
|
|
continue;
|
|
|
|
let val = default_data[cur_name];
|
|
if (!val) {
|
|
if (!ctx.prev || ctx.skip_default_complete)
|
|
continue;
|
|
val = {
|
|
name: cur_name,
|
|
help: context_defaults[cur_name][0],
|
|
category: "Navigation",
|
|
};
|
|
}
|
|
|
|
push(data, val);
|
|
}
|
|
|
|
return {
|
|
type: "enum",
|
|
name: "command",
|
|
help: "Command",
|
|
value: data
|
|
};
|
|
}
|
|
|
|
function context_call(args)
|
|
{
|
|
let ctx = this.select(args);
|
|
if (!ctx || !length(args))
|
|
return;
|
|
|
|
let name = shift(args);
|
|
let entry = ctx.node[name];
|
|
if (!entry)
|
|
return;
|
|
|
|
if (!entry.call)
|
|
return;
|
|
|
|
let named_args = {};
|
|
let num_args = length(entry.args);
|
|
let cur_argv = slice(args, 0, num_args);
|
|
let argv = [];
|
|
let skip = {};
|
|
|
|
ctx = callctx.new(this.model, ctx);
|
|
ctx.entry = entry;
|
|
ctx.named_args = named_args;
|
|
|
|
for (let i = 0; i < num_args; i++) {
|
|
let arg = shift(args);
|
|
let spec = entry.args[i];
|
|
|
|
spec = prepare_attr_spec(entry, ctx, spec, cur_argv, named_args);
|
|
if (arg != null)
|
|
arg = parse_arg(ctx, spec.name, spec, arg);
|
|
|
|
if (spec.required && !length(arg)) {
|
|
if (spec.default)
|
|
arg = spec.default;
|
|
else
|
|
ctx.missing_argument("Missing argument %d: %s", i + 1, spec.name);
|
|
}
|
|
|
|
if (arg != null)
|
|
push(argv, arg);
|
|
}
|
|
|
|
let spec = prepare_spec(entry, ctx, entry.named_args, argv) ?? {};
|
|
let defaults = {};
|
|
ctx.apply_defaults(spec, defaults);
|
|
while (length(args) > 0) {
|
|
let name = shift(args);
|
|
let cur = spec[name];
|
|
try {
|
|
if (cur && type(cur.available) == "function" &&
|
|
!call(cur.available, cur, ctx.model.scope, ctx, argv, { ...defaults, ...named_args }))
|
|
cur = null;
|
|
} catch (e) {
|
|
ctx.model.exception(e);
|
|
continue;
|
|
}
|
|
|
|
if (!cur) {
|
|
if (handle_empty_param(entry, spec, name, argv, named_args))
|
|
continue;
|
|
ctx.invalid_argument("Invalid argument: %s", name);
|
|
return ctx.result;
|
|
}
|
|
|
|
if (!cur.args) {
|
|
named_args[name] = true;
|
|
continue;
|
|
}
|
|
|
|
let val;
|
|
let cur_spec = cur.args;
|
|
if (type(cur.args) == "array") {
|
|
val = [];
|
|
for (let spec in cur.args) {
|
|
spec = prepare_attr_spec(entry, ctx, spec, argv, named_args);
|
|
let cur = parse_arg(ctx, name, spec, shift(args));
|
|
if (cur == null)
|
|
return ctx.result;
|
|
|
|
push(val, cur);
|
|
}
|
|
} else {
|
|
let spec = prepare_attr_spec(entry, ctx, cur.args, argv, named_args);
|
|
val = parse_arg(ctx, name, spec, shift(args));
|
|
if (val == null)
|
|
return ctx.result;
|
|
}
|
|
if (cur.multiple) {
|
|
named_args[name] ??= [];
|
|
push(named_args[name], val);
|
|
} else {
|
|
named_args[name] = val;
|
|
}
|
|
}
|
|
|
|
for (let name, arg in spec) {
|
|
if (!arg.required || named_args[name] != null)
|
|
continue;
|
|
|
|
try {
|
|
if (type(arg.available) == "function" &&
|
|
!call(arg.available, arg, ctx.model.scope, ctx, argv, named_args))
|
|
continue;
|
|
} catch (e) {
|
|
ctx.model.exception(e);
|
|
continue;
|
|
}
|
|
|
|
let spec = { ...arg };
|
|
prepare_default(entry, ctx, spec, argv, named_args);
|
|
if (spec.default != null)
|
|
named_args[name] = spec.default;
|
|
else
|
|
ctx.missing_argument("Missing argument: %s", name);
|
|
}
|
|
|
|
if (length(ctx.result.errors) > 0)
|
|
return ctx.result;
|
|
|
|
if (entry.available && !call(entry.available, entry, ctx.model.scope, ctx))
|
|
return ctx.result;
|
|
|
|
try {
|
|
if (!entry.validate || call(entry.validate, entry, ctx.model.scope, ctx, argv, named_args))
|
|
call(entry.call, entry, ctx.model.scope, ctx, argv, named_args);
|
|
} catch (e) {
|
|
this.model.exception(e);
|
|
return;
|
|
}
|
|
return ctx.result;
|
|
}
|
|
|
|
const context_proto = {
|
|
clone: context_clone,
|
|
entries: context_entries,
|
|
help: context_help,
|
|
select: context_select,
|
|
call: context_call,
|
|
complete: context_complete,
|
|
add_hook: context_add_hook,
|
|
};
|
|
|
|
export function new(model) {
|
|
model.context_proto ??= {
|
|
model,
|
|
...context_proto
|
|
};
|
|
model.context_select_proto ??= {
|
|
model,
|
|
...context_select_proto
|
|
};
|
|
return proto({
|
|
prompt: [],
|
|
node: model.node.Root,
|
|
hooks: {},
|
|
data: {}
|
|
}, model.context_proto);
|
|
};
|