Fügt CLI-Toolverwaltung und erweiterte UI-Funktionen hinzu

Ermöglicht die Verwaltung (Erstellen, Auflisten, Ausführen) von Tools über eine Befehlszeilenschnittstelle für Automatisierung und Integration. Die Benutzeroberfläche wurde mit dedizierten Ansichten für Toolerstellung und -bearbeitung, In-App-Lua-Hilfe sowie kontextsensitiven Menüs erweitert. Neue Abhängigkeiten (CLI11, rang) wurden integriert und eine umfassende Testsuite hinzugefügt. Die Lizenzierung wurde auf AGPL-3.0 umgestellt und ein Kontributionsleitfaden bereitgestellt.
This commit is contained in:
2026-07-17 17:22:15 +02:00
parent 074fa73992
commit c611b008af
45 changed files with 5501 additions and 709 deletions

138
tests/luaRunnerTests.cpp Normal file
View File

@@ -0,0 +1,138 @@
#include "luaRunner.h"
#include <cassert>
#include <chrono>
#include <filesystem>
#include <fstream>
#include <future>
namespace {
bool runScript(LuaRunner& runner, const std::string& taskId, const std::filesystem::path& path) {
std::promise<bool> completion;
auto result = completion.get_future();
const auto started = runner.run(taskId, path, 5, [&completion](bool success) {
completion.set_value(success);
});
assert(started.started);
assert(result.wait_for(std::chrono::seconds(10)) == std::future_status::ready);
return result.get();
}
} // namespace
int main() {
const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count();
const auto root = std::filesystem::temp_directory_path() /
std::filesystem::u8path("toolbox-unicode-工具-" + std::to_string(stamp));
std::filesystem::create_directories(root);
const auto validScript = root / std::filesystem::u8path("gültig.lua");
const auto invalidScript = root / std::filesystem::u8path("fehler.lua");
const auto longRunningScript = root / std::filesystem::u8path("langlaufend.lua");
const auto singleInstanceScript = root / std::filesystem::u8path("einzelinstanz.lua");
std::ofstream(validScript, std::ios::binary) << "local answer = 21 * 2\nassert(answer == 42)\n";
std::ofstream(invalidScript, std::ios::binary) << "error('expected failure')\n";
std::ofstream(longRunningScript, std::ios::binary) << "while true do end\n";
std::ofstream(singleInstanceScript, std::ios::binary)
<< "-- toolbox: single-instance\r\nwhile true do end\r\n";
LuaRunner runner;
assert(runScript(runner, "valid-tool", validScript));
assert(!runScript(runner, "invalid-tool", invalidScript));
std::promise<bool> longRunningCompletion;
auto longRunningResult = longRunningCompletion.get_future();
runner.run("C:/tools/first", longRunningScript, 0, [&longRunningCompletion](bool success) {
longRunningCompletion.set_value(success);
});
assert(runScript(runner, "C:/tools/second", validScript));
assert(longRunningResult.wait_for(std::chrono::milliseconds(50)) == std::future_status::timeout);
runner.cancel("C:/tools/first");
assert(longRunningResult.wait_for(std::chrono::seconds(5)) == std::future_status::ready);
assert(!longRunningResult.get());
std::promise<bool> singleCompletion;
auto singleResult = singleCompletion.get_future();
const auto idleSingleStatus = runner.status("C:/tools/single", singleInstanceScript);
assert(idleSingleStatus.singleInstance);
assert(!idleSingleStatus.running);
const auto firstSingle = runner.run(
"C:/tools/single", singleInstanceScript, 0,
[&singleCompletion](bool success) { singleCompletion.set_value(success); });
const auto duplicateSingle = runner.run(
"C:/tools/single", singleInstanceScript, 0, [](bool) {});
assert(firstSingle.started);
assert(firstSingle.singleInstance);
assert(!duplicateSingle.started);
assert(duplicateSingle.singleInstance);
const auto runningSingleStatus = runner.status("C:/tools/single", singleInstanceScript);
assert(runningSingleStatus.singleInstance);
assert(runningSingleStatus.running);
runner.cancel("C:/tools/single");
assert(singleResult.wait_for(std::chrono::seconds(5)) == std::future_status::ready);
assert(!singleResult.get());
const auto completedSingleStatus = runner.status("C:/tools/single", singleInstanceScript);
assert(completedSingleStatus.singleInstance);
assert(!completedSingleStatus.running);
std::promise<bool> firstParallelCompletion;
std::promise<bool> secondParallelCompletion;
auto firstParallelResult = firstParallelCompletion.get_future();
auto secondParallelResult = secondParallelCompletion.get_future();
const auto firstParallel = runner.run(
"C:/tools/parallel", longRunningScript, 0,
[&firstParallelCompletion](bool success) { firstParallelCompletion.set_value(success); });
const auto secondParallel = runner.run(
"C:/tools/parallel", longRunningScript, 0,
[&secondParallelCompletion](bool success) { secondParallelCompletion.set_value(success); });
assert(firstParallel.started);
assert(!firstParallel.singleInstance);
assert(secondParallel.started);
assert(!secondParallel.singleInstance);
assert(firstParallelResult.wait_for(std::chrono::milliseconds(50)) == std::future_status::timeout);
assert(secondParallelResult.wait_for(std::chrono::milliseconds(50)) == std::future_status::timeout);
runner.cancel("C:/tools/parallel");
assert(firstParallelResult.wait_for(std::chrono::seconds(5)) == std::future_status::ready);
assert(secondParallelResult.wait_for(std::chrono::seconds(5)) == std::future_status::ready);
assert(!firstParallelResult.get());
assert(!secondParallelResult.get());
const auto quickSingleScript = root / std::filesystem::u8path("kurze-einzelinstanz.lua");
std::ofstream(quickSingleScript, std::ios::binary)
<< "-- toolbox: single-instance\r\nreturn true\r\n";
std::promise<void> callbackEntered;
auto callbackEnteredResult = callbackEntered.get_future();
std::promise<void> releaseCallback;
auto releaseCallbackResult = releaseCallback.get_future().share();
std::promise<bool> quickSingleCompletion;
auto quickSingleResult = quickSingleCompletion.get_future();
const auto quickSingle = runner.run(
"C:/tools/quick-single", quickSingleScript, 0,
[&callbackEntered, releaseCallbackResult, &quickSingleCompletion](bool success) {
callbackEntered.set_value();
releaseCallbackResult.wait();
quickSingleCompletion.set_value(success);
});
assert(quickSingle.started);
assert(callbackEnteredResult.wait_for(std::chrono::seconds(5)) == std::future_status::ready);
const auto statusDuringCallback = runner.status("C:/tools/quick-single", quickSingleScript);
auto duplicateDuringCallback = std::async(std::launch::async, [&runner, &quickSingleScript] {
return runner.run("C:/tools/quick-single", quickSingleScript, 0, [](bool) {});
});
const auto duplicateWait = duplicateDuringCallback.wait_for(std::chrono::milliseconds(100));
releaseCallback.set_value();
assert(duplicateDuringCallback.wait_for(std::chrono::seconds(5)) == std::future_status::ready);
const auto duplicateDuringCallbackResult = duplicateDuringCallback.get();
assert(statusDuringCallback.singleInstance);
assert(!statusDuringCallback.running);
assert(duplicateWait == std::future_status::ready);
assert(!duplicateDuringCallbackResult.started);
assert(duplicateDuringCallbackResult.singleInstance);
assert(quickSingleResult.wait_for(std::chrono::seconds(5)) == std::future_status::ready);
assert(quickSingleResult.get());
runner.shutdown();
std::filesystem::remove_all(root);
return 0;
}

View File

@@ -0,0 +1,154 @@
#include "toolRegistry.h"
#include <cassert>
#include <chrono>
#include <filesystem>
#include <fstream>
#include <string>
namespace {
std::string readText(const std::filesystem::path& path) {
std::ifstream input(path, std::ios::binary);
return {std::istreambuf_iterator<char>(input), std::istreambuf_iterator<char>()};
}
} // namespace
int main() {
const auto suffix = std::to_string(std::chrono::steady_clock::now().time_since_epoch().count());
const std::filesystem::path home = std::filesystem::temp_directory_path() /
("toolbox-settings-contract-" + suffix);
std::filesystem::create_directories(home);
ToolDraft created{
.name = "Acme",
.description = "Startet Acme.",
.luaScript = "error('must be replaced')",
.iconBase64 = {},
.timeoutSeconds = 30,
.templateType = "program",
.templateTarget = R"(C:\Program Files\Acme\Acme.exe)",
};
const auto creation = ToolRegistry::createTool(home, created);
assert(creation.success);
const auto oldPath = ToolRegistry::toolboxDirectory(home) / "Acme";
const auto toolboxRoot = ToolRegistry::toolboxDirectory(home);
const auto preparedStage = toolboxRoot / ".toolbox-update-101";
const auto preparedJournal = toolboxRoot / ".toolbox-transaction-101.json";
std::filesystem::copy(oldPath, preparedStage, std::filesystem::copy_options::recursive);
std::ofstream(preparedJournal) <<
R"({"version":1,"id":"101","originalName":"Acme","targetName":"Acme Website"})";
ToolRegistry::loadTools(home);
assert(std::filesystem::exists(oldPath));
assert(!std::filesystem::exists(preparedStage));
assert(!std::filesystem::exists(preparedJournal));
const auto movedStage = toolboxRoot / ".toolbox-update-102";
const auto movedBackup = toolboxRoot / ".toolbox-backup-102";
const auto movedJournal = toolboxRoot / ".toolbox-transaction-102.json";
std::filesystem::copy(oldPath, movedStage, std::filesystem::copy_options::recursive);
std::ofstream(movedJournal) <<
R"({"version":1,"id":"102","originalName":"Acme","targetName":"Acme Website"})";
std::filesystem::rename(oldPath, movedBackup);
ToolRegistry::loadTools(home);
assert(std::filesystem::exists(oldPath));
assert(!std::filesystem::exists(movedStage));
assert(!std::filesystem::exists(movedBackup));
assert(!std::filesystem::exists(movedJournal));
const auto mismatchedBackup = toolboxRoot / ".toolbox-backup-104";
const auto mismatchedJournal = toolboxRoot / ".toolbox-transaction-wrong.json";
std::filesystem::copy(oldPath, mismatchedBackup, std::filesystem::copy_options::recursive);
std::ofstream(mismatchedJournal) <<
R"({"version":1,"id":"104","originalName":"Acme","targetName":"Acme Website"})";
ToolRegistry::loadTools(home);
assert(std::filesystem::exists(mismatchedBackup));
assert(std::filesystem::exists(mismatchedJournal));
const auto outsideTool = home / "outside-tool";
std::filesystem::create_directory(outsideTool);
std::ofstream(outsideTool / "start.lua") << "print('outside')";
assert(!ToolRegistry::getToolDetails(home, outsideTool).success);
const auto orphanPath = ToolRegistry::toolboxDirectory(home) / ".toolbox-update-orphan";
std::filesystem::create_directory(orphanPath);
std::ofstream(orphanPath / "start.lua") << "print('orphan')";
const auto visibleTools = ToolRegistry::loadTools(home);
assert(visibleTools.size() == 1);
assert(visibleTools.front().name == "Acme");
std::ofstream(oldPath / "asset.txt") << "preserve me";
const auto loaded = ToolRegistry::getToolDetails(home, oldPath);
assert(loaded.success);
assert(loaded.details.name == "Acme");
assert(loaded.details.description == "Startet Acme.");
assert(loaded.details.timeoutSeconds == 30);
assert(loaded.details.templateType == "program");
assert(loaded.details.templateTarget == R"(C:\Program Files\Acme\Acme.exe)");
assert(loaded.details.luaScript.find("start") != std::string::npos);
assert(loaded.details.luaScript.find("must be replaced") == std::string::npos);
ToolUpdateDraft update{
.name = "Acme Website",
.description = "Öffnet die Acme-Website.",
.luaScript = "error('must also be replaced')",
.timeoutSeconds = 45,
.templateType = "website",
.templateTarget = "https://www.example.com/docs",
};
const auto updated = ToolRegistry::updateTool(home, oldPath, update);
assert(updated.success);
const auto newPath = ToolRegistry::toolboxDirectory(home) / "Acme Website";
assert(updated.toolPath == newPath);
assert(!std::filesystem::exists(oldPath));
assert(readText(newPath / "asset.txt") == "preserve me");
const auto committedBackup = toolboxRoot / ".toolbox-backup-103";
const auto committedJournal = toolboxRoot / ".toolbox-transaction-103.json";
std::filesystem::copy(newPath, committedBackup, std::filesystem::copy_options::recursive);
std::ofstream(committedJournal) <<
R"({"version":1,"id":"103","originalName":"Acme","targetName":"Acme Website"})";
ToolRegistry::loadTools(home);
assert(std::filesystem::exists(newPath));
assert(!std::filesystem::exists(committedBackup));
assert(!std::filesystem::exists(committedJournal));
const auto reloaded = ToolRegistry::getToolDetails(home, newPath);
assert(reloaded.success);
assert(reloaded.details.name == "Acme Website");
assert(reloaded.details.description == "Öffnet die Acme-Website.");
assert(reloaded.details.timeoutSeconds == 45);
assert(reloaded.details.templateType == "website");
assert(reloaded.details.templateTarget == "https://www.example.com/docs");
assert(reloaded.details.luaScript.find("https://www.example.com/docs") != std::string::npos);
assert(reloaded.details.luaScript.find("must also be replaced") == std::string::npos);
std::ofstream(newPath / "tool.json") << R"({"version":1,"type":"bogus","target":"ignored"})";
const auto invalidMetadata = ToolRegistry::getToolDetails(home, newPath);
assert(invalidMetadata.success);
assert(invalidMetadata.details.templateType == "lua");
assert(invalidMetadata.details.templateTarget.empty());
std::filesystem::remove(newPath / "tool.json");
const auto legacy = ToolRegistry::getToolDetails(home, newPath);
assert(legacy.success);
assert(legacy.details.templateType == "lua");
assert(legacy.details.templateTarget.empty());
const auto invalid = ToolRegistry::updateTool(home, newPath, ToolUpdateDraft{
.name = "../escape",
.description = "invalid",
.luaScript = "print('invalid')",
.timeoutSeconds = 30,
.templateType = "lua",
});
assert(!invalid.success);
assert(std::filesystem::exists(newPath));
assert(readText(newPath / "description.txt") == "Öffnet die Acme-Website.");
std::filesystem::remove_all(home);
return 0;
}

42
tests/toolRuntime.test.js Normal file
View File

@@ -0,0 +1,42 @@
'use strict'
const assert = require('node:assert/strict')
const runtime = require('../res/html/tool-runtime.js')
function fakeCard() {
const classes = new Set()
const status = { hidden: true }
return {
classList: {
add: value => classes.add(value),
remove: value => classes.delete(value),
toggle: (value, enabled) => enabled ? classes.add(value) : classes.delete(value),
contains: value => classes.has(value),
},
dataset: {},
attributes: {},
setAttribute(name, value) { this.attributes[name] = value },
querySelector(selector) {
return selector === '.tool-running-status' ? status : null
},
status,
}
}
assert.equal(runtime.initialRunning({ singleInstance: true, running: true }), true)
assert.equal(runtime.initialRunning({ singleInstance: false, running: true }), false)
const card = fakeCard()
runtime.applyCardState(card, true)
assert.equal(runtime.isCardRunBlocked(card), true)
assert.equal(card.classList.contains('tool-running'), true)
assert.equal(card.dataset.runDisabled, 'true')
assert.equal(card.attributes['aria-busy'], 'true')
assert.equal(card.status.hidden, false)
runtime.applyCardState(card, false)
assert.equal(runtime.isCardRunBlocked(card), false)
assert.equal(card.classList.contains('tool-running'), false)
assert.equal(card.dataset.runDisabled, 'false')
assert.equal(card.attributes['aria-busy'], 'false')
assert.equal(card.status.hidden, true)

View File

@@ -0,0 +1,95 @@
#include "toolTemplates.h"
#include <sol/sol.hpp>
#include <cassert>
#include <filesystem>
#include <string>
#include <tuple>
namespace {
std::tuple<std::string, std::string> capturedLaunch(const std::string& script) {
sol::state lua;
lua.open_libraries(sol::lib::base, sol::lib::string);
std::string operation;
std::string target;
auto toolbox = lua.create_named_table("toolbox");
for (const std::string name : {"open_program", "open_folder", "open_url"}) {
toolbox.set_function(name, [&, name](const std::string& value) {
operation = name;
target = value;
return std::tuple{true, std::string{}};
});
}
const sol::protected_function_result result = lua.safe_script(script, sol::script_pass_on_error);
assert(result.valid());
return {operation, target};
}
std::string capturedPrint(const std::string& script) {
sol::state lua;
lua.open_libraries(sol::lib::base, sol::lib::string);
std::string output;
lua.set_function("print", [&output](const std::string& value) { output = value; });
const sol::protected_function_result result = lua.safe_script(script, sol::script_pass_on_error);
assert(result.valid());
return output;
}
} // namespace
int main() {
const std::string syntaxSensitiveName = "x'); error('injected\n\t\\\"";
assert(capturedPrint(ToolTemplates::defaultLuaScript(syntaxSensitiveName)) == syntaxSensitiveName);
const std::filesystem::path programPath(R"(C:\Program Files\Acme\Acme.exe)");
const auto program = ToolTemplates::programLauncher(programPath);
assert(program.success);
assert(program.draft.name == "Acme");
assert(program.draft.description == "Startet Acme.");
assert(program.draft.luaScript.find(R"(C:\\Program Files\\Acme\\Acme.exe)") != std::string::npos);
assert(program.draft.luaScript.find("os.execute") == std::string::npos);
assert((capturedLaunch(program.draft.luaScript) ==
std::tuple{std::string("open_program"), program.draft.templateTarget}));
const std::filesystem::path folderPath(R"(C:\Users\User\Projects)");
const auto folder = ToolTemplates::folderLauncher(folderPath);
assert(folder.success);
assert(folder.draft.name == "Projects öffnen");
assert(folder.draft.description == "Öffnet den Ordner Projects.");
assert(folder.draft.luaScript.find(R"(C:\\Users\\User\\Projects)") != std::string::npos);
assert(folder.draft.luaScript.find("os.execute") == std::string::npos);
assert((capturedLaunch(folder.draft.luaScript) ==
std::tuple{std::string("open_folder"), folder.draft.templateTarget}));
const auto website = ToolTemplates::websiteLauncher("https://www.example.com/docs");
assert(website.success);
assert(website.draft.name == "example.com öffnen");
assert(website.draft.description == "Öffnet https://www.example.com/docs.");
assert(website.draft.luaScript.find("https://www.example.com/docs") != std::string::npos);
assert(website.draft.luaScript.find("os.execute") == std::string::npos);
assert((capturedLaunch(website.draft.luaScript) ==
std::tuple{std::string("open_url"), std::string("https://www.example.com/docs")}));
const std::string shellSensitiveUrl = "https://example.com/%TEMP%?a=1&b=(two)";
const auto shellSensitiveWebsite = ToolTemplates::websiteLauncher(shellSensitiveUrl);
assert(shellSensitiveWebsite.success);
assert((capturedLaunch(shellSensitiveWebsite.draft.luaScript) ==
std::tuple{std::string("open_url"), shellSensitiveUrl}));
const auto invalidWebsite = ToolTemplates::websiteLauncher("javascript:alert(1)");
assert(!invalidWebsite.success);
const auto invalidProgram = ToolTemplates::programLauncher(
std::filesystem::path(R"(C:\Temp\notes.txt)"));
assert(!invalidProgram.success);
const auto unicodeProgram = ToolTemplates::programLauncher(
std::filesystem::path(LR"(C:\Programme\工具.exe)"));
assert(unicodeProgram.success);
assert(unicodeProgram.draft.name == "工具");
assert(unicodeProgram.draft.luaScript.find("工具.exe") != std::string::npos);
return 0;
}

162
tests/toolbox_cli_test.py Normal file
View File

@@ -0,0 +1,162 @@
import json
import os
import pathlib
import subprocess
import sys
import tempfile
def run(cli: pathlib.Path, *args: str, expected_code: int = 0,
env: dict[str, str] | None = None) -> dict:
completed = subprocess.run(
[str(cli), *args],
check=False,
capture_output=True,
text=True,
encoding="utf-8",
env=env,
)
assert completed.returncode == expected_code, completed.stderr or completed.stdout
if expected_code == 0:
assert completed.stderr == "", completed.stderr
return json.loads(completed.stdout)
def run_text(cli: pathlib.Path, *args: str) -> str:
completed = subprocess.run(
[str(cli), *args],
check=False,
capture_output=True,
text=True,
encoding="utf-8",
)
assert completed.returncode == 0, completed.stderr or completed.stdout
assert completed.stderr == "", completed.stderr
return completed.stdout
def main() -> None:
cli = pathlib.Path(sys.argv[1])
assert cli.is_file(), f"CLI binary missing: {cli}"
short_help = run_text(cli, "-h")
long_help = run_text(cli, "--help")
for help_text in (short_help, long_help):
assert "TOOLBOX CLI" in help_text
assert "BEFEHLE" in help_text
assert "list" in help_text
assert "create" in help_text
assert "run" in help_text
assert not help_text.lstrip().startswith("{")
colored_help = run_text(cli, "--color", "always", "-h")
assert "\x1b[" in colored_help
with tempfile.TemporaryDirectory(prefix="toolbox-cli-test-工具-") as home:
created = run(
cli,
"--home", home,
"create",
"--name", "Agent 工具",
"--description", "Created through the CLI",
"--script", 'assert(type(toolbox.open_url) == "function")\nprint("agent-ready-✓")',
"--timeout", "5",
)
assert created == {
"message": "Tool wurde erstellt.",
"ok": True,
"tool": "Agent 工具",
}
listed = run(cli, "--home", home, "list")
assert listed["ok"] is True
assert listed["tools"] == [{
"description": "Created through the CLI",
"name": "Agent 工具",
}]
listed_with_trailing_home = run(cli, "list", "--home", home)
assert listed_with_trailing_home == listed
forced_color_listed = run(cli, "--color", "always", "--home", home, "list")
assert forced_color_listed == listed
executed = run(cli, "--home", home, "run", "Agent 工具")
assert executed == {
"ok": True,
"output": "agent-ready-✓\n",
"tool": "Agent 工具",
}
script_file = pathlib.Path(home) / "skript-工具.lua"
script_file.write_text('print("datei-✓")\n', encoding="utf-8")
file_created = run(
cli,
"--home", home,
"create",
"--name", "Datei 工具",
"--script-file", str(script_file),
)
assert file_created["ok"] is True
file_executed = run(cli, "--home", home, "run", "Datei 工具")
assert file_executed == {
"ok": True,
"output": "datei-✓\n",
"tool": "Datei 工具",
}
syntax_sensitive_name = "x'); error('injected"
injected = run(
cli,
"--home", home,
"create",
"--name", syntax_sensitive_name,
)
assert injected["ok"] is True
injected_run = run(cli, "--home", home, "run", syntax_sensitive_name)
assert injected_run == {
"ok": True,
"output": f"Hallo aus {syntax_sensitive_name}\n",
"tool": syntax_sensitive_name,
}
default_home_env = os.environ.copy()
default_home_env["USERPROFILE"] = home
default_listed = run(cli, "list", env=default_home_env)
assert {tool["name"] for tool in default_listed["tools"]} == {
"Agent 工具", "Datei 工具", syntax_sensitive_name
}
rejected = run(
cli,
"--home", home,
"create",
"--name", "../escape",
expected_code=2,
)
assert rejected["ok"] is False
assert not (pathlib.Path(home) / "escape").exists()
with tempfile.TemporaryDirectory(prefix="toolbox-cli-root-") as home, \
tempfile.TemporaryDirectory(prefix="toolbox-cli-external-") as external:
toolbox_root = pathlib.Path(home) / ".Toolbox"
if sys.platform == "win32":
linked = subprocess.run(
["cmd.exe", "/c", "mklink", "/J", str(toolbox_root), external],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
assert linked.returncode == 0
else:
toolbox_root.symlink_to(external, target_is_directory=True)
rejected_root = run(
cli,
"--home", home,
"create",
"--name", "Must Not Escape",
expected_code=2,
)
assert rejected_root["ok"] is False
assert not (pathlib.Path(external) / "Must Not Escape").exists()
if __name__ == "__main__":
main()