cli: add OpenWrt CLI
authorFelix Fietkau <nbd@nbd.name>
Mon, 13 Jan 2025 21:46:05 +0000 (22:46 +0100)
committerFelix Fietkau <nbd@nbd.name>
Thu, 13 Feb 2025 18:00:30 +0000 (19:00 +0100)
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>
13 files changed:
package/utils/cli/Makefile [new file with mode: 0644]
package/utils/cli/docs/MODULE-API.md [new file with mode: 0644]
package/utils/cli/files/usr/sbin/cli [new file with mode: 0755]
package/utils/cli/files/usr/share/ucode/cli/cache.uc [new file with mode: 0644]
package/utils/cli/files/usr/share/ucode/cli/color.uc [new file with mode: 0644]
package/utils/cli/files/usr/share/ucode/cli/context-call.uc [new file with mode: 0644]
package/utils/cli/files/usr/share/ucode/cli/context.uc [new file with mode: 0644]
package/utils/cli/files/usr/share/ucode/cli/datamodel.uc [new file with mode: 0644]
package/utils/cli/files/usr/share/ucode/cli/modules/network.uc [new file with mode: 0644]
package/utils/cli/files/usr/share/ucode/cli/modules/service.uc [new file with mode: 0644]
package/utils/cli/files/usr/share/ucode/cli/object-editor.uc [new file with mode: 0644]
package/utils/cli/files/usr/share/ucode/cli/types.uc [new file with mode: 0644]
package/utils/cli/files/usr/share/ucode/cli/utils.uc [new file with mode: 0644]

diff --git a/package/utils/cli/Makefile b/package/utils/cli/Makefile
new file mode 100644 (file)
index 0000000..65eae06
--- /dev/null
@@ -0,0 +1,35 @@
+#
+# Copyright (C) 2025 OpenWrt.org
+#
+# This is free software, licensed under the GNU General Public License v2.
+# See /LICENSE for more information.
+#
+
+include $(TOPDIR)/rules.mk
+
+PKG_NAME:=cli
+PKG_RELEASE:=$(AUTORELEASE)
+
+PKG_LICENSE:=GPL-2.0
+PKG_MAINTAINER:=Felix Fietkau <nbd@nbd.name>
+
+include $(INCLUDE_DIR)/package.mk
+
+define Package/cli
+  SECTION:=utils
+  CATEGORY:=Utilities
+  TITLE:=OpenWrt CLI
+  DEPENDS:=+ucode +ucode-mod-uline \
+       +ucode-mod-ubus +ucode-mod-uloop \
+       +ucode-mod-fs +ucode-mod-rtnl
+endef
+
+define Build/Compile
+       :
+endef
+
+define Package/cli/install
+       $(CP) ./files/* $(1)/
+endef
+
+$(eval $(call BuildPackage,cli))
diff --git a/package/utils/cli/docs/MODULE-API.md b/package/utils/cli/docs/MODULE-API.md
new file mode 100644 (file)
index 0000000..ac6e2ce
--- /dev/null
@@ -0,0 +1,364 @@
+ Design of the `cli` module API
+
+## Structure:
+The cli is organized as a set of *nodes*, which are ucode objects objects describing *entries*.
+Each *entry* can either implement a *command*, or select another *node*, optionally with *parameters*.
+Additionally, it contains helptext and full *parameter* descriptions, including everything needed for tab completion.
+The initial *node* on startup is `Root`, representing the main menu.
+
+## Simple example:
+
+### Code:
+```
+const Example = {
+       hello: {
+               help: "Example command",
+               args: [
+                       {
+                               name: "name",
+                               type: "string",
+                               min: 3,
+                               max: 16,
+                               required: true,
+                       }
+               ],
+               call: function(ctx, argv, named) {
+                       return ctx.ok("Hello, " + argv[0]);
+               },
+       },
+       hello2: {
+               help: "Example command (named_args version)",
+               named_args: {
+                       name: {
+                               required: true,
+                               args: {
+                                       type: "string",
+                                       min: 3,
+                                       max: 16,
+                               }
+                       }
+               },
+               call: function(ctx, argv, named) {
+                       return ctx.ok("Hello, " + named.name);
+               },
+       }
+};
+
+const Root = {
+       example: {
+               help: "Example node",
+               select_node: "Example",
+       }
+};
+
+model.add_nodes({ Root, Example });
+```
+### Example interaction:
+```
+root@OpenWrt:~# cli
+Welcome to the OpenWrt CLI. Press '?' for help on commands/arguments
+cli> example 
+cli example> hello
+Error: Missing argument 1: name
+cli example> hello foo
+Hello, foo
+cli example> hello2
+Error: Missing argument: name
+cli example> hello2 name foo2
+Hello, foo2
+cli example> 
+```
+
+## API documentation:
+
+Each module is placed in `/usr/share/ucode/cli/modules` on the root filesystem.
+When included by the cli code, the scope contains the `model` variable, which is the main cli API object. This variable is also present in the scope of the other callback functions described below.
+
+### `model` methods:
+- `model.warn(msg)`: Pass a warning to the user (similar to the ucode `warn` function).
+- `model.exception(e)`: Print an exception with stack trace.
+- `model.add_module(path)`: Load a single module from `path`
+- `model.add_modules(path)`: Load multiple modules from the `path` wildcard pattern
+- `model.add_node(name, obj)`: Add a single node under the given name
+- `model.add_nodes(nodes)`: Add multiple nodes with taking `name` and `obj` from the `nodes` object.
+- `model.add_type(name, info)`: Add a data type with validation information,
+- `model.add_types(types)`: Add multiple data types, taking `name` and `info` from the `types` object.
+- `model.status_msg(msg)`: Print an asynchronous status message (should not be used from within a node `call` or `select` function).
+
+### Properties of an `entry` inside a `node`:
+Each entry must have at least `help` and either `call` or `select_node` set. 
+- `help`: Helptext describing the command
+- `call: function(ctx, argv, named)`: main command handler function of the entry.
+       - `this`: pointer to the `entry`
+       - `ctx`: call context object (see below)
+       - `argv`: array of positional arguments after the command name
+       - `named`: object of named parameters passed to the command
+       - Return value: either `ctx.ok(msg)` for successfull calls, or the result of an error function (see below).
+- `select_node`: (string) name of the *node* that this entry points to. Mutually exclusive with implementing `call`.
+- `select: function(ctx, argv, named)`: function for selecting another node.
+       - `this`: pointer to the *entry*
+       - `ctx`: node context object (see below)
+       - `argv`, `named`: see `call`
+       - Return value: either `ctx.set(prompt, data)`, `true`, or the result of an error function (see below).
+- `args`: array of positional *arguments* (see *argument* property description)
+- `named_args`: object of named *parameters*  (see *parameter* property description)
+- `available: function(ctx)`: function indicating if the entry can be used (affects tab completion and running commands)
+       - `this`: pointer to the *entry*
+       - `ctx`: node context object (see below)
+       - Return value: `true` if available, `false` otherwise.
+- `validate: function (ctx, argv, named)`: validate command arguments
+       - Function parameters: see `call`
+
+### Named *parameter* properties:
+- `help`: Description of the named parameter's purpose
+- `args`: Either an array of *argument* objects, or an object with a single *argument* (see below). If not set, paramter will not take any arguments, and its value will be `true` if the parameter was specified on the command line.
+- `available: function(ctx, argv, named)`: function indicating if the named parameter can be used (affects tab completion and argument validation). May depend on *arguments*/*parameters* specified before this one.
+- `multiple` (bool): indicates if an argument may be specified multiple times. Turns the value in `named` into an array.
+- `required` (bool): Parameter must be specified for the command
+- `default`: default value for the parameter.
+- `allow_empty`: empty values are allowed and can be specified on the command line using `-param_name` instead of `param_name`. The value in the `named` object will be `null` in that case.
+
+### Positional *argument* properties:
+- `name`: Short name of the *argument*
+- `help`: Longer description of the *argument* (used in helptext/completion)
+- `type`: data type name (see below)
+- `required` (bool): Value must not be empty
+- `value`: possible values for tab completion, one of:
+       - array of objects with the following contents:
+               - `name`: value string
+               - `help`: help text for this value
+       - `function(ctx, argv, named)` returning the above.
+- extra properties specific to the data type (see below)
+
+### Default data types:
+- `int`: Integer value. The valid range can be specified using the `min` and `max` properties.
+- `string`: String value. The valid string length can be specified using the `min` and `max` properties.
+- `bool`: Boolean value. Converts `"1"` and `"0"` to `true` and `false`
+- `enum`: String value that must match one entry of the list provided via the `value` property. Case-insensitive match can be enabled using the `ignore_case` property.
+- `path`: Local filesystem path. When the `new_path` property is set, only match directories for a file to be created.
+- `host`: Host name or IP address
+- `macaddr`: MAC address
+- `ipv4`: IPv4 address
+- `ipv6`: IPv6 address
+- `cidr4`: IPv4 address with netmask size, e.g. 192.168.1.1/24. Allows `auto` as value if the `allow_auto` property is set.
+
+### `call` context:
+Passed as `ctx` argument to entry `call` functions.
+- `ctx.data`: Object containing any data passed via `ctx.set()` from a `select` context.
+- `ctx.ok(msg)`: Indicates successful call, passes the message `msg` to the user.
+- `ctx.select(...args)`: After completion, switch to a different *node* by running the command chain provided as function argument (only entries with `.select_node` are supported).
+- `ctx.string(name, val)`: Passes a string to the caller as return value.
+- `ctx.list(name, val)`: Passes a list of values to the caller as return value. `val` must be an array.
+- `ctx.table(name, val)`: Passes a table as value to the caller. `val` can be an array `[ column_1, column_2 ]`, where each member of the outer array describes a row in the table. It can also be an object, where the property name is the first column value, and the value the second column value.
+- `ctx.multi_table(name, val)`: Passes multiple tables to the caller. Can be an array of `[ title, table ]`, or an object.
+- Error functions (see below)
+
+### `select` context:
+- `ctx.data`: Object containing any data passed via parent `ctx.set` calls.
+- `ctx.set(prompt, data)`: Modify the prompt and `ctx.data` for the child context. The string given in `prompt` is appended to the existing prompt. The data given in the `data` object is merged with the previous `ctx.data` value.
+- Error functions (see below)
+
+### Error functions:
+All error messages accept a format string in `msg`, with arguments added after it.
+- `ctx.invalid_argument(msg, ...args)`: Indicates that invalid arguments were provided.
+- `ctx.missing_argument(msg, ...args)`: Indicates that an expected argument was missing.
+- `ctx.command_failed(msg, ...args)`: Indicates that the command failed.
+- `ctx.not_found(msg, ...args)`: Indicates that a given entry was not found.
+- `ctx.unknown_error(msg, ...args)`: Indicates that the command failed for unknown or unspecified reasons.
+- `ctx.error(id, msg, ...args)`: Generic error message with `id` specifying a machine readable error type string.
+
+## Editor API documentation
+The editor API provides a layer of abstraction above node entries/calls in order to make it easy to edit properties of an object based on an attribute list, as well as create/destroy/show object instances using a consistent user interface.
+
+### Simple example:
+```
+import * as editor from "cli.object-editor";
+
+let changed = false;
+let data = {
+       things: {
+               foo: {
+                       label_str: [ "bar" ],
+                       id: 31337,
+               }
+       },
+};
+
+const thing_editor = {
+       change_cb: function(ctx) {
+               changed = true;
+       },
+       named_args: {
+               label: {
+                       help: "Thing label",
+                       attribute: "label_str",
+                       multiple: true,
+                       args: {
+                               type: "string",
+                               min: 2,
+                               max: 16
+                       },
+               },
+               id: {
+                       help: "Thing id",
+                       required: true,
+                       args: {
+                               type: "int",
+                               min: 1,
+                       },
+               },
+       },
+};
+const ExampleThing = editor.new(thing_editor);
+
+let Example = {
+       dump: {
+               help: "Dump current data",
+               call: function(ctx, argv, named) {
+                       return ctx.json("Data", {
+                               changed,
+                               data
+                       });
+               },
+       }
+};
+const example_editor = {
+       change_cb: function(ctx) {
+               changed = true;
+       },
+       types: {
+               thing: {
+                       node_name: "ExampleThing",
+                       node: ExampleThing,
+                       object: "things",
+               },
+       },
+};
+editor.edit_create_destroy(example_editor, Example);
+
+const Root = {
+       example: {
+               help: "Example node",
+               select_node: "Example",
+               select: function(ctx, argv, named) {
+                       return ctx.set(null, {
+                               object_edit: data,
+                       });
+               }
+       }
+};
+
+model.add_nodes({ Root, Example, ExampleThing });
+```
+### Example interaction:
+```
+root@OpenWrt:~# cli
+Welcome to the OpenWrt CLI. Press '?' for help on commands/arguments
+cli> example
+cli example> dump
+Data: {
+        "changed": false,
+        "data": {
+                "things": {
+                        "foo": {
+                                "label_str": [
+                                        "bar"
+                                ],
+                                "id": 31337
+                        }
+                }
+        }
+}
+cli example> thing foo set id 1337
+cli example> create thing bar id 168 label l1 label l2
+Added thing 'bar'
+cli example> thing bar show
+Values: 
+    id:         168
+    label:      l1, l2
+cli example> thing bar remove label 1
+cli example> thing bar show
+Values: 
+    id:         168
+    label:      l2
+cli example> dump
+Data: {
+        "changed": true,
+        "data": {
+                "things": {
+                        "foo": {
+                                "label_str": [
+                                        "bar"
+                                ],
+                                "id": 1337
+                        },
+                        "bar": {
+                                "id": 168,
+                                "label_str": [
+                                        "l2"
+                                ]
+                        }
+                }
+        }
+}
+cli example> destroy thing foo 
+Deleted thing 'foo'
+cli example> 
+```
+### API documentation
+Prelude: `import * as editor from "cli.object-editor";`
+
+#### Object editor:
+For editing an object, the following user commands are defined:
+- `set`: Changes property values
+- `show` Show all values
+
+If properties with `multiple: true` are defined, the following commands are also defined:
+- `add`: Add values to properties
+- `remove` Remove specific values from properties
+
+##### Variant 1 (editor-only node):
+`const Node = editor.new(editor_data)`
+
+##### Variant 2 (merge with existing entries):
+`let Node = {};`
+`editor.new(editor_data, Node);`
+
+The editor code assumes that the *node* that selects the editor node uses `ctx.set()` to set the `edit` field in `ctx.data` to the object being edited.
+
+#### `editor_data` properties:
+- `change_cb: function(ctx)`: Called whenever a property is changed by the user
+- `named_args`: Parameters for editing properties (based on *entry* `named_args`, see below)
+- `add`, `set`, `show`, `remove`: Object for overriding fields of the commands defined by the editor. Primarily used to override the helptext.
+
+#### Instance editor `named_args` entry properties:
+All *entry* `named_args` properties are supported, but the meaning is extended slightly:
+- `multiple`: Property array values can be added/removed
+- `default`: Default value when creating the object
+- `allow_empty`: Property can be deleted
+- `required`: Property is mandatory in the object.
+
+#### Object instance editor:
+For managing object instances, the following user commands are defined:
+- `create <type> <name> <...>`: Create a new instance. Also takes parameter values to be set on the object.
+- `destroy <type> <name>`: Delete an instance.
+- `list <type>` List all instances of a given type.
+
+The instance editor code assumes that the *node* that selects the editor node uses `ctx.set()` to set the `object_edit` field in `ctx.data` to the object being edited.
+
+##### Variant 1 (editor-only node):
+`const Node = editor.edit_create_destroy(instance_data);`
+
+##### Variant 2 (merge with existing entries):
+`let Node = {};`
+`editor.edit_create_destroy(instance_data, Node);`
+
+#### `instance_data` properties:
+- `change_cb: function(ctx)`: Called whenever an instance is added or deleted
+- `types`: Metadata about instances types (see below)
+
+#### `instance_data.types` object properties:
+- `node_name`: name of the *editor node* belonging to the object instance.
+- `node`: The *editor node* itself.
+- `object`: Name of the type specific container object inside the object pointed to by `ctx.data.object_edit`. 
+
diff --git a/package/utils/cli/files/usr/sbin/cli b/package/utils/cli/files/usr/sbin/cli
new file mode 100755 (executable)
index 0000000..0a763f2
--- /dev/null
@@ -0,0 +1,741 @@
+#!/usr/bin/env ucode
+// SPDX-License-Identifier: GPL-2.0-or-later
+// Copyright (C) 2025 Felix Fietkau <nbd@nbd.name>
+'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;
+let script_mode;
+
+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();
+       },
+});
+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;
+                       }
+               }
+       }
+});
+
+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 = "";
+
+                       add += sprintf("%-"+len+"s", entry.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 ];
+
+       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" ]
+});
+
+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;
+       }
+}
+
+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);
+}
diff --git a/package/utils/cli/files/usr/share/ucode/cli/cache.uc b/package/utils/cli/files/usr/share/ucode/cli/cache.uc
new file mode 100644 (file)
index 0000000..27cc049
--- /dev/null
@@ -0,0 +1,60 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+// Copyright (C) 2025 Felix Fietkau <nbd@nbd.name>
+'use strict';
+
+const CACHE_DEFAULT_TIMEOUT = 5;
+
+function cache_get(key, fn, timeout)
+{
+       let now = time();
+       let entry = this.entries[key];
+       if (entry) {
+               if (now < entry.timeout)
+                       return entry.data;
+
+               if (!fn)
+                       delete this.entries[key];
+       }
+
+       if (!fn)
+               return;
+
+       let data = fn();
+       if (!entry)
+               this.entries[key] = entry = {};
+       timeout ??= CACHE_DEFAULT_TIMEOUT;
+       entry.timeout = now + timeout;
+       entry.data = data;
+
+       return data;
+}
+
+function cache_remove(key)
+{
+       delete this.entries[key];
+}
+
+function cache_gc() {
+       let now = time();
+       for (let key, entry in this.entries)
+               if (now > entry.timeout)
+                       delete this.entries[key];
+}
+
+const cache_proto = {
+       get: cache_get,
+       remove: cache_remove,
+       gc: cache_gc,
+};
+
+export function new(model) {
+       model.cache_proto ??= { model, ...cache_proto };
+       let cache = proto({
+               entries: {},
+       }, model.cache_proto);
+       cache.gc_interval = model.uloop.interval(10000, () => {
+               cache.gc();
+       });
+
+       return cache;
+};
diff --git a/package/utils/cli/files/usr/share/ucode/cli/color.uc b/package/utils/cli/files/usr/share/ucode/cli/color.uc
new file mode 100644 (file)
index 0000000..39e5863
--- /dev/null
@@ -0,0 +1,65 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+// Copyright (C) 2025 Felix Fietkau <nbd@nbd.name>
+'use strict';
+
+const color_codes = {
+       black: 30,
+       red: 31,
+       green: 32,
+       yellow: 33,
+       blue: 34,
+       magenta: 35,
+       cyan: 36,
+       white: 37,
+       default: 39
+};
+
+function color_str(n)
+{
+       return "\e["+n+"m";
+}
+
+function color_code(str)
+{
+       let n = 0;
+       if (substr(str, 0, 7) == "bright_") {
+               str = substr(str, 7);
+               n += 60;
+       }
+       if (!color_codes[str])
+               return;
+
+       n += color_codes[str];
+       return n;
+}
+
+export function color_fg(name, str)
+{
+       let n = color_code(name);
+       if (!n)
+               return str;
+
+       let ret = color_str(n);
+       if (str != null)
+               ret += str + color_str(39);
+
+       return ret;
+};
+
+export function color_bg(name, str)
+{
+       let n = color_code(name);
+       if (!n)
+               return str;
+
+       let ret = color_str(n + 10);
+       if (str != null)
+               ret += str + color_str(49);
+
+       return ret;
+};
+
+export function bold(str)
+{
+       return color_str(1) + str + color_str(0);
+};
diff --git a/package/utils/cli/files/usr/share/ucode/cli/context-call.uc b/package/utils/cli/files/usr/share/ucode/cli/context-call.uc
new file mode 100644 (file)
index 0000000..0977cda
--- /dev/null
@@ -0,0 +1,126 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+// Copyright (C) 2025 Felix Fietkau <nbd@nbd.name>
+'use strict';
+
+function default_result()
+{
+       return {
+               errors: [],
+               ok: false
+       };
+}
+
+function context_clone()
+{
+       let ret = { ...this };
+       ret.result = default_result();
+       ret.data = { ...ret.data };
+       return proto(ret, proto(this));
+}
+
+function call_select(...args)
+{
+       this.result.ctx = this.node_ctx.select(args);
+}
+
+function call_ok(msg)
+{
+       this.result.ok = true;
+       if (msg)
+               this.result.status_msg = msg;
+       return true;
+}
+
+function call_error(code, msg, ...args)
+{
+       msg ??= "Unknown error";
+       msg = sprintf(msg, ...args);
+       let error = {
+               code, msg, args
+       };
+       push(this.result.errors, error);
+}
+
+function call_generic(ctx, name, type, val)
+{
+       ctx.result.type = type;
+       ctx.result.name = name;
+       ctx.result.data = val;
+       return ctx.ok();
+}
+
+function call_multi_table(name, val)
+{
+       return call_generic(this, name, "multi_table", val);
+}
+
+function call_table(name, val)
+{
+       return call_generic(this, name, "table", val);
+}
+
+function call_list(name, val)
+{
+       return call_generic(this, name, "list", val);
+}
+
+function call_string(name, val)
+{
+       return call_generic(this, name, "string", val);
+}
+
+function call_json(name, val)
+{
+       return call_generic(this, name, "json", val);
+}
+
+function call_apply_defaults(named_args, args)
+{
+       let entry = this.entry;
+       named_args ??= entry.named_args;
+       args ??= this.named_args;
+       for (let name, arg in named_args)
+               if (arg.default != null && !(name in args))
+                       args[name] ??= arg.default;
+}
+
+export const callctx_error_proto = {
+       missing_argument: function(msg, ...args) {
+               return this.error("MISSING_ARGUMENT", msg ?? "Missing argument", ...args);
+       },
+       invalid_argument: function(msg, ...args) {
+               return this.error("INVALID_ARGUMENT", msg ?? "Invalid argument", ...args);
+       },
+       unknown_error: function(msg, ...args) {
+               return this.error("UNKNOWN_ERROR", msg ?? "Unknown error", ...args);
+       },
+       not_found: function(msg, ...args) {
+               return this.error("NOT_FOUND", msg ?? "Not found", ...args);
+       },
+       command_failed: function(msg, ...args) {
+               return this.error("COMMAND_FAILEDu", msg ?? "Command failed", ...args);
+       },
+};
+
+const callctx_proto = {
+       clone: context_clone,
+       select: call_select,
+       apply_defaults: call_apply_defaults,
+       ok: call_ok,
+       list: call_list,
+       table: call_table,
+       multi_table: call_multi_table,
+       string: call_string,
+       json: call_json,
+
+       error: call_error,
+       ...callctx_error_proto,
+};
+
+export function new(model, ctx) {
+       let node_ctx = ctx;
+       let data = ctx.data;
+       model.callctx_proto ??= { model, ...callctx_proto };
+       let result = default_result();
+       return proto({ node_ctx, data, result }, model.callctx_proto);
+};
diff --git a/package/utils/cli/files/usr/share/ucode/cli/context.uc b/package/utils/cli/files/usr/share/ucode/cli/context.uc
new file mode 100644 (file)
index 0000000..b3f24f7
--- /dev/null
@@ -0,0 +1,679 @@
+// 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);
+};
diff --git a/package/utils/cli/files/usr/share/ucode/cli/datamodel.uc b/package/utils/cli/files/usr/share/ucode/cli/datamodel.uc
new file mode 100644 (file)
index 0000000..bc78a7b
--- /dev/null
@@ -0,0 +1,175 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+// Copyright (C) 2025 Felix Fietkau <nbd@nbd.name>
+'use strict';
+
+import * as context from "cli.context";
+import * as cache from "cli.cache";
+import * as libubus from "ubus";
+import * as uloop from "uloop";
+import { glob, dirname } from "fs";
+let types = require("cli.types");
+
+uloop.init();
+let ubus = libubus.connect();
+
+function status_msg(msg)
+{
+       if (this.cb.status_msg)
+               call(this.cb.status_msg, this, this.scope, msg);
+}
+
+function poll_key(keys, prompt)
+{
+       if (!model.cb.poll_key)
+               return;
+
+       if (prompt)
+               warn(prompt);
+
+    while (1) {
+        let key = lc(model.cb.poll_key());
+               if (!key || key == "\x03")
+                       return;
+
+               if (index(keys, key) >= 0)
+                       return key;
+       }
+}
+
+function merge_object(obj, add)
+{
+       for (let name, entry in add)
+               obj[name] = entry;
+}
+
+function add_node(name, node)
+{
+       let obj = this.node;
+
+       if (obj[name])
+               merge_object(obj[name], node);
+       else
+               obj[name] = { ...node };
+
+       return obj[name];
+}
+
+function add_nodes(add)
+{
+       for (let name, val in add)
+               this.add_node(name, val);
+}
+
+function add_hook(name, val)
+{
+       let obj = this.hooks;
+
+       if (type(val) == "function")
+               val = [ val ];
+       obj[name] ??= [];
+       push(obj[name], ...val);
+}
+
+function add_hooks(add)
+{
+       for (let name, val in add)
+               this.add_hook(name, val);
+}
+
+function add_type(name, val)
+{
+       this.type[name] = val;
+}
+
+function add_types(add)
+{
+       for (let name, val in add)
+               this.add_type(name, val);
+}
+
+function add_module(path)
+{
+       if (substr(path, 0, 1) != "/")
+               path = dirname(sourcepath()) + "/modules/" + path;
+
+       let mod;
+       try {
+               let fn = loadfile(path, {
+                       raw_mode: true,
+                       strict_declarations: true,
+               });
+               mod = call(fn, this, this.scope);
+       } catch (e) {
+               this.warn(`${e}\n${e.stacktrace[0].context}\nFailed to open module ${path}.\n`);
+               return;
+       }
+}
+
+function add_modules(path)
+{
+       path ??= "*.uc";
+       if (substr(path, 0, 1) != "/")
+               path = dirname(sourcepath()) + "/modules/" + path;
+
+       for (let mod in glob(path))
+               this.add_module(mod);
+}
+
+function run_hook(name, ...args)
+{
+       let hooks = this.hooks[name];
+       if (!hooks)
+               return;
+
+       for (let hook in hooks)
+               call(hook, this, {}, ...args);
+}
+
+function init()
+{
+       this.run_hook("init");
+}
+
+function context_new()
+{
+       return context.new(this);
+}
+
+function exception(e)
+{
+       this.warn(`${e}\n${e.stacktrace[0].context}`);
+}
+
+const data_proto = {
+       warn, exception,
+       poll_key,
+       add_module,
+       add_modules,
+       add_node,
+       add_nodes,
+       add_type,
+       add_types,
+       add_hook,
+       add_hooks,
+       run_hook,
+       init,
+       status_msg,
+       context: context_new,
+};
+
+export function new(cb) {
+       cb ??= {};
+       let model = proto({
+               libubus, ubus, uloop,
+               cb,
+               hooks: {},
+               node: {
+                       Root: {}
+               },
+               warnings: {},
+               types: { ...types },
+       }, data_proto);
+       model.scope = proto({ model }, global);
+       model.cache = cache.new(model);
+       return model;
+};
diff --git a/package/utils/cli/files/usr/share/ucode/cli/modules/network.uc b/package/utils/cli/files/usr/share/ucode/cli/modules/network.uc
new file mode 100644 (file)
index 0000000..3d5ef5b
--- /dev/null
@@ -0,0 +1,138 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+// Copyright (C) 2025 Felix Fietkau <nbd@nbd.name>
+'use strict';
+
+import { time_format } from "cli.utils";
+
+function get_interfaces()
+{
+       let data = model.ubus.call("network.interface", "dump");
+       if (!data)
+               return {};
+
+       let ret = {};
+       for (let iface in data.interface)
+               ret[iface.interface] = iface;
+
+       return ret;
+}
+
+function interface_validate(ctx, argv)
+{
+       let name = argv[0];
+       if (!name)
+               return ctx.missing_argument("Missing argument: %s", "name");
+
+       if (index(get_interfaces(), name) < 0)
+               return ctx.not_found("Interface not found: %s", name);
+
+       return true;
+}
+
+const interface_args = [
+       {
+               name: "interface",
+               help: "Interface name",
+               type: "enum",
+               value: (ctx) => keys(get_interfaces())
+       }
+];
+
+function interface_status(data)
+{
+       if (data.up)
+               return "up";
+       if (!data.autostart)
+               return "down";
+       if (!data.available)
+               return "unavailable";
+       return "pending";
+}
+
+const Network = {
+       list: {
+               help: "List interfaces",
+               call: function(ctx, argv) {
+                       return ctx.list("Interfaces", keys(get_interfaces()));
+               }
+       },
+       reload: {
+               help: "Reload network config",
+               call: function(ctx, argv) {
+                       model.ubus.call("network", "reload");
+                       return ctx.ok("Configuration reloaded");
+               }
+       },
+       restart: {
+               help: "Restart interface",
+               validate: interface_validate,
+               args: interface_args,
+               call: function(ctx, argv) {
+                       let name = shift(argv);
+                       model.ubus.call("network.interface."+name, "down");
+                       model.ubus.call("network.interface."+name, "up");
+                       return ctx.ok("Interface restarted");
+               }
+       },
+       start: {
+               help: "Start interface",
+               validate: interface_validate,
+               args: interface_args,
+               call: function(ctx, argv) {
+                       let name = shift(argv);
+                       model.ubus.call("network.interface."+name, "up");
+                       return ctx.ok("Interface started");
+               }
+       },
+       stop: {
+               help: "Stop interface",
+               validate: interface_validate,
+               args: interface_args,
+               call: function(ctx, argv) {
+                       let name = shift(argv);
+                       model.ubus.call("network.interface."+name, "down");
+                       return ctx.ok("Interface stopped");
+               }
+       },
+       status: {
+               help: "Interface status",
+               args: interface_args,
+               call: function(ctx, argv) {
+                       let name = shift(argv);
+                       let status = get_interfaces();
+                       if (!name) {
+                               let data = {};
+                               for (let iface, ifdata in status)
+                                       data[iface] = interface_status(ifdata);
+
+                               return ctx.table("Status", data);
+                       }
+
+                       let ifdata = status[name];
+                       let data = {
+                               Status: interface_status(ifdata),
+                       };
+                       if (ifdata.up)
+                               data.Uptime = time_format(ifdata.uptime);
+
+                       if (length(ifdata["ipv4-address"]) > 0)
+                               data.IPv4 = join(", ", map(ifdata["ipv4-address"], (v) => v.address + "/" + v.mask));
+                       if (length(ifdata["ipv6-address"]) > 0)
+                               data.IPv6 = join(", ", map(ifdata["ipv6-address"], (v) => v.address + "/" + v.mask));
+                       if (length(ifdata["dns-server"]) > 0)
+                               data.DNS = join(", ", ifdata["dns-server"]);
+                       if (length(ifdata["route"]) > 0)
+                               data.Routes = join(", ", map(ifdata["route"], (v) => (v.mask == 0 ? "Default" : `${v.target}/${v.mask}`) + ": " + v.nexthop));
+                       return ctx.table("Status", data);
+               }
+       }
+};
+
+const Root = {
+       network: {
+               help: "Network interface configuration",
+               select_node: "Network",
+       }
+};
+
+model.add_nodes({ Root, Network });
diff --git a/package/utils/cli/files/usr/share/ucode/cli/modules/service.uc b/package/utils/cli/files/usr/share/ucode/cli/modules/service.uc
new file mode 100644 (file)
index 0000000..a280f65
--- /dev/null
@@ -0,0 +1,174 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+// Copyright (C) 2025 Felix Fietkau <nbd@nbd.name>
+'use strict';
+
+import { glob, access, basename } from "fs";
+
+function get_services()
+{
+       return model.cache.get("init_service_list", () => {
+               let services = glob("/etc/init.d/*");
+               services = filter(services, (v) => !system([ "grep", "-q", "start_service()", v ]));
+               services = map(services, basename);
+               return sort(services);
+       });
+}
+
+function get_service_status( name)
+{
+       return model.ubus.call("service", "list", (name ? { name } : null));
+}
+
+function service_running(name)
+{
+       let status = get_service_status(name);
+       return !!(status && status[name]);
+}
+
+function __service_cmd(name, cmd)
+{
+       return system([ "/etc/init.d/" + name, cmd ]) == 0;
+}
+
+function service_cmd(ctx, name, cmd, msg)
+{
+       if (__service_cmd(name, cmd))
+               return ctx.ok(msg);
+       else
+               return ctx.command_failed("Command failed");
+}
+
+function service_validate(ctx, argv)
+{
+       let name = argv[0];
+       if (!name)
+               return ctx.missing_argument("Missing argument: %s", "name");
+
+       if (index(get_services(), name) < 0)
+               return ctx.not_found("Service not found: %s", name);
+
+       return true;
+}
+
+const service_args = [
+       {
+               name: "name",
+               help: "Service name",
+               type: "enum",
+               value: (ctx) => get_services()
+       }
+];
+
+const service_settings = {
+       enabled: {
+               help: "Service enabled at system boot",
+       },
+       disabled: {
+               help: "Service disabled at system boot",
+       }
+};
+
+const SystemService = {
+       list: {
+               help: "List services",
+               call: function(ctx, argv) {
+                       return ctx.list("Services", get_services());
+               }
+       },
+       reload: {
+               help: "Reload service",
+               validate: service_validate,
+               args: service_args,
+               call: function(ctx, argv) {
+                       return service_cmd(ctx, shift(argv), "reload", "Service reloaded");
+               }
+       },
+       restart: {
+               help: "Restart service",
+               validate: service_validate,
+               args: service_args,
+               call: function(ctx, argv) {
+                       return service_cmd(ctx, shift(argv), "restart", "Service restarted");
+               }
+       },
+       set: {
+               help: "Change service settings",
+               validate: service_validate,
+               args: service_args,
+               named_args: service_settings,
+               call: function(ctx, argv, param) {
+                       if (!length(param))
+                               return ctx.invalid_argument("No settings provided");
+
+                       if (param.enabled && param.disabled)
+                               return ctx.invalid_argument("enabled and disabled cannot be set at the same time");
+
+                       if (param.enabled && !__service_cmd(name, "enable"))
+                               ctx.command_failed("Command failed: %s", "enable");
+
+                       if (param.disabled && !__service_cmd(name, "disable"))
+                               ctx.command_failed("Command failed: %s", "disable");
+
+                       return ctx.ok("Settings changed");
+               }
+       },
+       start: {
+               help: "Start service",
+               validate: service_validate,
+               args: service_args,
+               call: function(ctx, argv) {
+                       let name = shift(argv);
+
+                       if (service_running(name))
+                               return ctx.invalid_argument("Service already running", name);
+
+                       return service_cmd(ctx, name, "start", "Service started");
+               }
+       },
+       stop: {
+               help: "Stop service",
+               validate: service_validate,
+               args: service_args,
+               call: function(ctx, argv) {
+                       let name = shift(argv);
+
+                       if (!service_running(name))
+                               return ctx.invalid_argument("Service not running", name);
+
+                       return service_cmd(ctx, name, "stop", "Service stopped");
+               }
+       },
+       status: {
+               help: "Service status",
+               args: service_args,
+               call: function(ctx, argv) {
+                       let name = shift(argv);
+                       if (!name) {
+                               let data = {};
+                               for (let service in get_services()) {
+                                       let running = service_running(service);
+                                       data[service] = running ? "running" : "not running";
+                               }
+                               return ctx.table("Status", data);
+                       }
+
+                       if (index(get_services(), name) < 0)
+                               return ctx.not_found("Service not found: %s", name);
+
+                       let data = {
+                               "Running": service_running(name),
+                               "Enabled": __service_cmd(name, "enabled"),
+                       };
+                       return ctx.table("Status", data);
+               }
+       }
+};
+
+const Root = {
+       service: {
+               help: "System service configuration",
+               select_node: "SystemService",
+       }
+};
+
+model.add_nodes({ Root, SystemService });
diff --git a/package/utils/cli/files/usr/share/ucode/cli/object-editor.uc b/package/utils/cli/files/usr/share/ucode/cli/object-editor.uc
new file mode 100644 (file)
index 0000000..c55bf40
--- /dev/null
@@ -0,0 +1,546 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+// Copyright (C) 2025 Felix Fietkau <nbd@nbd.name>
+'use strict';
+
+function __get_edit_object(ctx, entry, argv, name)
+{
+       if (type(entry.edit_object) == "function")
+               return call(entry.edit_object, entry, ctx.model.scope, ctx, argv);
+
+       if (name)
+               return ctx.data.edit[name];
+
+       return ctx.data.edit;
+}
+
+function get_edit_object(ctx, entry, argv, name)
+{
+       let obj = __get_edit_object(ctx, entry, argv, name);
+       if (!obj)
+               ctx.invalid_argument();
+
+       return obj;
+}
+
+function get_param_object(ctx, obj, spec, argv)
+{
+       if (type(spec.get_object) != "function")
+               return obj;
+
+       return call(spec.get_object, spec, ctx.model.scope, ctx, spec, obj, argv);
+}
+
+function call_change_cb(ctx, entry, argv, named)
+{
+       if (!length(named) || type(entry.change_cb) != "function")
+               return;
+
+       call(entry.change_cb, entry, ctx.model.scope, ctx, argv);
+}
+
+function check_duplicate(ctx, val, new_val)
+{
+       for (let i = 0; i < length(new_val); i++) {
+               let v = new_val[i];
+               if ((val && index(val, v) >= 0) ||
+                       (i && index(slice(new_val, 0, i), v) >= 0)) {
+                       ctx.invalid_argument("Duplicate value: %s", v);
+                       return true;
+               }
+       }
+}
+
+export function add_call(ctx, argv, named)
+{
+       let spec = this.named_args;
+       let obj = get_edit_object(ctx, this, argv);
+       if (!obj)
+               return;
+
+       for (let name, val in named) {
+               let cur = spec[name];
+               if (type(cur.add) == "function") {
+                       call(cur.add, cur, ctx.model.scope, ctx, val);
+                       continue;
+               }
+
+               if (cur.attribute)
+                       name = cur.attribute;
+
+               let cur_obj = get_param_object(ctx, obj, cur, argv);
+               cur_obj[name] ??= [];
+               if (!cur.allow_duplicate &&
+                   check_duplicate(ctx, obj[name], val))
+                       return;
+               push(cur_obj[name], ...val);
+       }
+       call_change_cb(ctx, this, argv, named);
+       return ctx.ok();
+};
+
+export function set_call(ctx, argv, named)
+{
+       let spec = this.named_args;
+       let obj = get_edit_object(ctx, this, argv);
+       if (!obj)
+               return;
+
+       for (let name, val in named) {
+               let cur = spec[name];
+               if (!cur)
+                       continue;
+
+               if (type(cur.set) == "function") {
+                       call(cur.set, cur, ctx.model.scope, ctx, val);
+                       continue;
+               }
+
+               if (cur.attribute)
+                       name = cur.attribute;
+
+               let cur_obj = get_param_object(ctx, obj, cur, argv);
+               if (val == null) {
+                       delete cur_obj[name];
+                       continue;
+               }
+
+               if (cur.multiple && !cur.allow_duplicate &&
+                   check_duplicate(ctx, obj[name], val))
+                       return;
+               cur_obj[name] = val;
+       }
+       call_change_cb(ctx, this, argv, named);
+       return ctx.ok();
+};
+
+export function remove_call(ctx, argv, named)
+{
+       let spec = this.named_args;
+       let obj = get_edit_object(ctx, this, argv);
+       if (!obj)
+               return;
+
+       for (let name, val in named) {
+               let cur = spec[name];
+               if (type(cur.remove) == "function") {
+                       call(cur.remove, cur, ctx.model.scope, ctx, val);
+                       continue;
+               }
+
+               if (cur.attribute)
+                       name = cur.attribute;
+
+               let cur_obj = get_param_object(ctx, obj, cur, argv);
+               let data = cur_obj[name];
+               if (!data)
+                       continue;
+
+               for (let idx in val)
+                       data[+idx - 1] = null;
+
+               cur_obj[name] = filter(data, (v) => v != null);
+               if (cur.attribute_allow_empty && !length(cur_obj[name]))
+                       delete cur_obj[name];
+       }
+       call_change_cb(ctx, this, argv, named);
+       return ctx.ok();
+};
+
+export function show_call(ctx, argv, named)
+{
+       let obj = get_edit_object(ctx, this, argv);
+       if (!obj)
+               return;
+
+       let data = {};
+       for (let name, spec in this.attribute_info) {
+               let val;
+               if (type(spec.get) == "function") {
+                       val = call(spec.get, spec, ctx.model.scope, ctx);
+               } else {
+                       let cur_obj = get_param_object(ctx, obj, spec, argv);
+                       val = cur_obj[spec.attribute ?? name];
+               }
+               val ??= spec.default;
+
+               if (val != null)
+                       data[name] = val;
+       }
+
+       return ctx.table("Values", data);
+};
+
+function param_values(ctx, argv, named_args, spec)
+{
+       let obj = get_edit_object(ctx, this, argv);
+       if (!obj)
+               return;
+
+       let values;
+       if (type(spec.get) == "function")
+               values = call(spec.get, spec, ctx.model.scope, ctx);
+       else {
+               let cur_obj = get_param_object(ctx, obj, spec, argv);
+               values = cur_obj[spec.attribute];
+       }
+
+       let ret = {};
+       let idx = 0;
+       for (let value in values)
+               ret["" + (++idx)] = value;
+
+       return ret;
+}
+
+function add_params(orig_params)
+{
+       let params = {};
+
+       for (let name, val in orig_params) {
+               if (!val.multiple)
+                       continue;
+
+               val = { ...val };
+               delete val.required;
+               delete val.allow_empty;
+               params[name] = val;
+       }
+
+       return params;
+}
+
+function set_params(orig_params)
+{
+       let params = {};
+
+       for (let name, val in orig_params) {
+               val = { ...val };
+               if (!val.required)
+                       val.allow_empty = true;
+               else
+                       delete val.allow_empty;
+
+               delete val.required;
+               params[name] = val;
+       }
+
+       return params;
+}
+
+function remove_params(orig_params)
+{
+       let params = {};
+
+       for (let name, val in orig_params) {
+               if (!val.multiple)
+                       continue;
+
+               val = { ...val };
+               val.attribute_allow_empty = val.allow_empty;
+               delete val.required;
+               delete val.allow_empty;
+               val.args = {
+                       type: "enum",
+                       attribute: val.attribute ?? name,
+                       value: param_values,
+                       force_helptext: true,
+               };
+
+               params[name] = val;
+       }
+
+       return params;
+}
+
+export function new(info, node)
+{
+       let params = info.named_args;
+       let ret = {
+               add: {
+                       help: "Add list parameter entries",
+                       args: info.args,
+                       named_args: add_params(params),
+                       call: add_call,
+                       edit_object: info.edit_object,
+                       change_cb: info.change_cb,
+                       ...(info.add ?? {}),
+               },
+               show: {
+                       help: "Show parameter values",
+                       args: info.args,
+                       call: show_call,
+                       attribute_info: params,
+                       ...(info.show ?? {}),
+               },
+               set: {
+                       help: "Set parameter values",
+                       args: info.args,
+                       named_args: set_params(params),
+                       call: set_call,
+                       edit_object: info.edit_object,
+                       change_cb: info.change_cb,
+                       ...(info.set ?? {}),
+               },
+               remove: {
+                       help: "Remove parameter values",
+                       args: info.args,
+                       named_args: remove_params(params),
+                       call: remove_call,
+                       edit_object: info.edit_object,
+                       change_cb: info.change_cb,
+                       ...(info.remove ?? {}),
+               }
+       };
+
+       if (!length(ret.add.named_args)) {
+               delete ret.add;
+               delete ret.remove;
+       }
+
+       if (node)
+               for (let cmd, val in ret)
+                       node[cmd] = val;
+
+       return ret;
+};
+
+export function object_destroy_call(ctx, argv, named)
+{
+       let type_name = argv[0];
+       if (!type_name)
+               return ctx.invalid_argument();
+
+       let info = this.object_info;
+       let type_info = info.types[type_name];
+       if (!type_info)
+               return ctx.invalid_argument();
+
+       let obj_name = type_info.object ?? type_name;
+
+       let name = argv[1];
+       if (type_info.delete)
+               return call(type_info.delete, info, ctx.model.scope, ctx, type, name);
+
+       let obj = ctx.data.object_edit[obj_name];
+       if (!obj)
+               return ctx.unknown_error();
+
+       if (!obj[name])
+               return ctx.not_found();
+
+       delete obj[name];
+
+       if (info.change_cb)
+               call(info.change_cb, info, ctx.model.scope, ctx, argv);
+
+       return ctx.ok(`Deleted ${argv[0]} '${name}'`);
+};
+
+const create_edit_param = {
+       help: "Edit object after creating",
+};
+
+export function object_create_params(node)
+{
+       if (!node.show)
+               return {};
+
+       let orig_params = node.show.attribute_info;
+       let params = {};
+
+       for (let name, val in orig_params) {
+               if (val.change_only)
+                       continue;
+
+               params[name] = val;
+       }
+       params.edit ??= create_edit_param;
+
+       return params;
+};
+
+export function object_create_call(ctx, argv, named)
+{
+       let type_name = argv[0];
+       if (!type_name)
+               return ctx.invalid_argument();
+
+       let info = this.object_info;
+       let type_info = info.types[type_name];
+       if (!type_info)
+               return ctx.invalid_argument();
+
+       let obj_name = type_info.object ?? type_name;
+
+       let name = argv[1];
+       let obj, data;
+       if (type_info.add) {
+               data = call(type_info.add, info, ctx.model.scope, ctx, type, name);
+               if (!data)
+                       return;
+       } else {
+               data = {};
+       }
+
+       ctx.data.object_edit[obj_name] ??= {};
+       obj = ctx.data.object_edit[obj_name];
+
+       let entry = type_info.node.set;
+       if (entry) {
+               ctx.apply_defaults();
+               let subctx = ctx.clone();
+               subctx.data.edit = data;
+
+               try {
+                       call(entry.call, entry, ctx.model.scope, subctx, argv, named);
+               } catch (e) {
+                       ctx.model.exception(e);
+                       return ctx.unknown_error();
+               }
+
+               if (!subctx.result.ok) {
+                       ctx.result = subctx.result;
+                       return;
+               }
+       }
+
+       obj[name] = data;
+
+       if (named.edit)
+               ctx.select(type_name, name);
+
+       return ctx.ok(`Added ${type_name} '${name}'`);
+};
+
+function object_lookup(ctx, entry, type_name)
+{
+       let info = entry.object_info;
+       let type_info = info.types[type_name];
+       if (!type_info)
+               return [];
+
+       let obj_name = type_info.object ?? type_name;
+
+       return ctx.data.object_edit[obj_name];
+}
+
+function object_values(ctx, entry, type_name)
+{
+       let obj = object_lookup(ctx, entry, type_name);
+       if (!obj)
+               return [];
+
+       return keys(obj);
+}
+
+export function object_list_call(ctx, argv, named)
+{
+       return ctx.list(argv[0] + " list", object_values(ctx, this, argv[0]));
+};
+
+export function edit_create_destroy(info, node)
+{
+       let type_arg = {
+               name: "type",
+               help: "Type",
+               type: "enum",
+               required: true,
+               value: keys(info.types),
+       };
+       let name_arg = {
+               name: "name",
+               help: "Name",
+               type: "string",
+               required: true,
+       };
+       let delete_name_arg = {
+               ...name_arg,
+               type: "enum",
+               value: function(ctx, argv) {
+                       return object_values(ctx, this, argv[0]);
+               }
+       };
+
+       let create_params = {};
+       for (let name, val in info.types)
+               create_params[name] = object_create_params(val.node);
+
+       let types_info = " (" + join(", ", keys(info.types)) + ")";
+       let cmds = {
+               destroy: {
+                       object_info: info,
+                       help: "Delete object" + types_info,
+                       args: [ type_arg, delete_name_arg ],
+                       call: object_destroy_call,
+               },
+               list: {
+                       object_info: info,
+                       help: "List objects" + types_info,
+                       args: [ type_arg ],
+                       call: object_list_call,
+               },
+               create: {
+                       object_info: info,
+                       help: "Create object" + types_info,
+                       args: [ type_arg, name_arg ],
+                       type_params: create_params,
+                       named_args: function(ctx, argv) {
+                               if (!argv[0])
+                                       return;
+                               return this.type_params[argv[0]];
+                       },
+                       call: object_create_call,
+               },
+       };
+
+       for (let name, val in info.types) {
+               cmds[name] = {
+                       object_name: name,
+                       object_info: info,
+                       help: "Edit " + name,
+                       args: [
+                               {
+                                       ...name_arg,
+                                       type: "enum",
+                                       value: function(ctx, argv) {
+                                               return object_values(ctx, this, this.object_name);
+                                       }
+                               }
+                       ],
+                       select_node: val.node_name,
+                       select: function(ctx, argv) {
+                               let name = argv[0];
+                               if (!name) {
+                                       warn(`Missing argument\n`);
+                                       return;
+                               }
+
+                               let obj = object_lookup(ctx, this, this.object_name);
+                               if (!obj) {
+                                       warn(`Object not found\n`);
+                                       return;
+                               }
+
+                               let entry = obj[name];
+                               if (!entry) {
+                                       warn(`${name} not found\n`);
+                                       return;
+                               }
+
+                               let info = this.object_info;
+                               let type_info = info.types[this.object_name];
+                               return ctx.set(`${this.object_name} "${name}"`, {
+                                       name,
+                                       edit: entry,
+                                       object_edit: entry,
+                               });
+                       }
+               };
+       }
+
+       if (node)
+               for (let cmd, val in cmds)
+                       node[cmd] = val;
+
+       return cmds;
+};
diff --git a/package/utils/cli/files/usr/share/ucode/cli/types.uc b/package/utils/cli/files/usr/share/ucode/cli/types.uc
new file mode 100644 (file)
index 0000000..46c563d
--- /dev/null
@@ -0,0 +1,182 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+// Copyright (C) 2025 Felix Fietkau <nbd@nbd.name>
+'use strict';
+
+import { access, basename, dirname, opendir, stat } from "fs";
+
+function is_directory(path)
+{
+       let s = stat(path);
+       return s && s.type == "directory";
+}
+
+const types = {
+       bool: {
+               value: [ "0", "1" ],
+               parse: function(ctx, name, val) {
+                       if (val == "1")
+                               return true;
+                       if (val == "0")
+                               return false;
+                       ctx.invalid_argument("value for %s must be 0 or 1", name);
+                       return;
+               },
+       },
+       int: {
+               parse: function(ctx, name, strval) {
+                       let val = +strval;
+                       if (substr(strval, 0, 1) == "-")
+                               strval = substr(strval, 1);
+                       if (match(strval, /[^0-9]/)) {
+                               ctx.invalid_argument("value for %s is not a number", name);
+                               return;
+                       }
+                       if ((this.min == null || val >= this.min) &&
+                           (this.max == null || val <= this.max))
+                               return val;
+                       if (this.min != null && this.max != null)
+                               ctx.invalid_argument(`value for %s must be between ${this.min} and ${this.max}`, name);
+                       else if (this.min != null)
+                               ctx.invalid_argument(`value for %s must be at least ${this.min}`, name);
+                       else
+                               ctx.invalid_argument(`value for %s must not be bigger than ${this.max}`, name);
+                       return;
+               }
+       },
+       string: {
+               parse: function(ctx, name, val) {
+                       let len = length(val);
+                       if ((this.min == null || len >= this.min) &&
+                           (this.max == null || len <= this.max))
+                               return val;
+                       if (this.min != null && this.max != null)
+                               ctx.invalid_argument(`String value %s must be between ${this.min} and ${this.max} characters`, name);
+                       else if (this.min != null)
+                               ctx.invalid_argument(`String value %s must be at least ${this.min} characters long`, name);
+                       else
+                               ctx.invalid_argument(`String value %s must not be longer than ${this.max} characters`, name);
+                       return;
+               }
+       },
+       enum: {
+               parse: function(ctx, name, val) {
+                       if (this.no_validate)
+                               return val;
+
+                       let list = this.value;
+                       if (this.ignore_case) {
+                               val = lc(val);
+                               val = filter(list, (v) => val == lc(v))[0];
+                       } else {
+                               if (index(list, val) < 0)
+                                       val = null;
+                       }
+
+                       if (val == null)
+                               ctx.invalid_argument("Invalid value for %s", name);
+
+                       return val;
+               }
+       },
+       path: {
+               complete: function(ctx, val) {
+                       let ret = [];
+
+                       let dir = split(val, "/");
+                       let prefix = pop(dir);
+                       push(dir, "");
+                       dir = join("/", dir);
+                       let prefix_len = length(prefix);
+                       let d = opendir(length(dir) ? dir : ".");
+                       if (!d)
+                               return ret;
+
+                       let cur;
+                       while (cur = d.read()) {
+                               if (cur == "." || cur == "..")
+                                       continue;
+
+                               if (substr(cur, 0, prefix_len) != prefix)
+                                       continue;
+
+                               let path = dir + cur;
+                               let incomplete = false;
+                               if (is_directory(path)) {
+                                       path += "/";
+                                       incomplete = true;
+                               }
+
+                               push(ret, { name: path, incomplete });
+                       }
+
+                       return ret;
+               },
+               parse: function(ctx, name, val) {
+                       if (this.new_path) {
+                               let dir = dirname(val);
+                               let s = stat(dir);
+                               if (!is_directory(dir)) {
+                                       ctx.invalid_argument("Path '%s' is not a directory", dir);
+                                       return;
+                               }
+                       } else {
+                               if (!access(val, "r")) {
+                                       ctx.invalid_argument("Path '%s' does not exist", val);
+                                       return;
+                               }
+                       }
+                       return val;
+               }
+       },
+       host: {
+               parse: function(ctx, name, val) {
+                       if (length(iptoarr(val)) != 0)
+                               return val;
+                       if (length(val) > 255)
+                               return;
+                       let labels = split(val, ".");
+                       if (length(filter(labels, label => !match(label, /^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9])$/))) == 0 && length(labels) > 0)
+                               return val;
+                       ctx.invalid_argument("value for %s is not an valid IP or hostname", name);
+                       return;
+               }
+       },
+       macaddr: {
+               parse: function(ctx, name, val) {
+                       val = lc(val);
+                       let arr = split(val, ":");
+                       if (length(arr) != 6 || length(filter(arr, (v) => !match(v, /^[0-9a-f][0-9a-f]$/))))
+                               return ctx.invalid_argument("value for %s is not an MAC address", name);
+                       return val;
+               }
+       },
+       ipv4: {
+               parse: function(ctx, name, val) {
+                       if (length(iptoarr(val)) == 4)
+                               return val;
+                       ctx.invalid_argument("value for %s is not an IPv4", name);
+                       return;
+               }
+       },
+       ipv6: {
+               parse: function(ctx, name, val) {
+                       if (length(iptoarr(val)) == 16)
+                               return val;
+                       ctx.invalid_argument("value for %s is not an IPv6", name);
+                       return;
+               }
+       },
+       cidr4: {
+               parse: function(ctx, name, val) {
+                       let m = split(val, '/', 2);
+                       if (m && +m[1] <= 32 &&
+                           ((m[0] == "auto" && this.allow_auto) ||
+                            length(iptoarr(m[0])) == 4))
+                               return val;
+                       ctx.invalid_argument("value for %s is not cidr4 (e.g. 192.168.1.1/24)", name);
+                       return;
+               }
+       },
+};
+
+return types;
diff --git a/package/utils/cli/files/usr/share/ucode/cli/utils.uc b/package/utils/cli/files/usr/share/ucode/cli/utils.uc
new file mode 100644 (file)
index 0000000..f299ad3
--- /dev/null
@@ -0,0 +1,26 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+// Copyright (C) 2025 Felix Fietkau <nbd@nbd.name>
+'use strict';
+
+export function time_format(val)
+{
+       let ret = `${val % 60}s`;
+
+       val /= 60;
+       if (!val)
+               return ret;
+
+       ret = `${val % 60}m ${ret}`;
+
+       val /= 60;
+       if (!val)
+               return ret;
+
+       ret = `${val % 24 }h ${ret}`;
+
+       val /= 24;
+       if (!val)
+               return ret;
+
+       return `${val}d ${ret}`;
+};