Skip to content

Headless CLI - #670

Draft
CoffeeFlux wants to merge 20 commits into
TypesettingTools:masterfrom
CoffeeFlux:headless-cli
Draft

Headless CLI#670
CoffeeFlux wants to merge 20 commits into
TypesettingTools:masterfrom
CoffeeFlux:headless-cli

Conversation

@CoffeeFlux

Copy link
Copy Markdown
Member

No description provided.

Comment thread automation/include/aegisub/lfs.moon Outdated
arch1t3cht and others added 19 commits August 12, 2026 18:14
First steps towards implementing aegisub-cli's features inside of
Aegisub itself. This involves some terrible callback spaghetti in order
to set up everything in the correct order.

(cherry picked from commit c7e40f8)
This mainly involves making the subtitle/video/audio providers only
initialize their timers when they have a GUI.

(cherry picked from commit d26c21b)
This includes reading/writing mru.json, logging to files,
and autobackups.

(cherry picked from commit c0f1acf)
Add wrappers that show the dialogs when a gui is present and fall back
to something else when it's not. In particular, single choice dialogs
can be given predetermined answers from the global config, which will
hopefully be configurable from the command line in the future.

(cherry picked from commit 9ae1727)
The AsyncVideoProvider still doesn't fully work, though, since
it relies on wxEvents to send frame ready events.

(cherry picked from commit e3962c4)
Almost all of this code is taken from aegisub-cli, with a couple of
fixes and improvements.
Only --loglevel wasn't added for now since I plan to add some more
settings for that.
Note that aegisub.text_extents doesn't work on non-Windows yet.

Co-authored-by: Myaamori <myaamori1993@gmail.com>
(cherry picked from commit a5a3491)
This requires wx to be initialized on non-Windows, so we initialize it
as soon as CalculateTextExtents is called for the first time. Since this
requires a display server, we don't do it on startup.

(cherry picked from commit 59e51c7)
Partly, this uses some of the refactors made upstream.

(cherry picked from commit f9a9ff9)
The cli branch predates the boost::filesystem -> std::filesystem
migration, the removal of agi::make_unique/agi::str, and the move of
string_codec into libaegisub, so the cherry-picks needed adjusting:

- boost::filesystem -> std::filesystem (with explicit agi::fs::path
  conversions) in the CLI entry point and cli.cpp
- agi::make_unique -> std::make_unique, agi::str -> std::string
- string_codec.h -> libaegisub/ass/string_codec.h
- add missing standard includes for the new config globals
- fix --timecodes calling LoadKeyframes instead of LoadTimecodes
- Pump a main-thread dispatch queue while automation scripts run on a
  worker thread (cli::RunWithMainLoop), mirroring the GUI threading
  model. This replaces the inline dispatch executor, which deadlocked in
  agi::dispatch::Queue::Sync for anything that bounces through the main
  thread (text extents, clipboard access).
- Give scripts a process-local clipboard in CLI mode instead of
  crashing on the uninitialized wx clipboard.
- Fail text_extents gracefully when wxInitialize() fails due to a
  missing display server instead of crashing in wxMemoryDC.
- Create ?user at startup in CLI mode; the hotkey map is flushed to it
  even when the GUI-mode directory-creating startup steps are skipped.
- Don't try to load the MRU list from disk when constructed with an
  empty path (CLI mode).
- Catch exceptions in the CLI main path and report them instead of
  calling std::terminate.
Runs a deterministic automation macro on a fixture file in a throwaway
HOME without a display server and checks the output. The macro also
round-trips the clipboard, which exercises dispatch::Main().Sync() from
the script thread through the CLI main loop.
A GUI-subsystem application on Windows links against WinMain, which
wxIMPLEMENT_APP provides and our custom main() does not, so the branch
did not link on Windows. Keep the stock wx entry point on Windows (no
--cli mode there for now) and default the new config flags to the GUI
behavior so platforms which never run our main() work unchanged.
There is no event loop to deliver the rendered frame to, and queueing
wx events without a wxApp crashes when a script triggers a subtitle
commit while video is loaded.
Script runtime errors surface as UserCancelException, which
LuaCommand::operator() swallows after rolling back since in the GUI a
cancelled macro is not an error. In CLI mode there is no user to cancel
the run, so it either means the script threw an error or a dialog went
unanswered; rethrow so aegisub --cli exits nonzero instead of writing
the output file and reporting success. Found by running the
Aegisub-Motion tests, where a failing test previously exited 0.
The original headless branch bundled this registration into the same
commit as the aegisub-lua module-test wiring, which has since landed on
master via TypesettingTools#657, so only the e2e half needs restoring. Gate it to Linux:
Windows keeps wx's entry point so --cli doesn't exist there, and the
suite has only been vetted headless on the Linux runners. CI picks it up
through the existing 'meson test --suite Aegisub' step.
boost::program_options::store/notify ran outside any exception handler,
so an unknown option, a malformed value, or a repeated single-value
argument killed the process with an uncaught exception rather than a
diagnostic - and since this main() also fronts GUI launches on POSIX, a
stray toolkit flag would take down the GUI too. Parse inside a handler
that prints the error and usage, allow unregistered options through for
the GUI (wx parses the original argv itself) while rejecting them in
--cli mode, and make missing-argument errors exit nonzero instead of 0
so scripts can tell failure from help output.
Project::LoadSubtitles reports failures (missing file, parse errors)
through the GUI error path rather than throwing, so the CLI carried on
with the context's untouched blank document: at best saving an empty
file with exit code 0, at worst crashing on Events.front() since the
blank document has no dialogue lines. Detect the failed load and exit
with a diagnostic instead. A valid file with no dialogue lines is not
affected: SubsController guarantees at least one line on load, matching
how the GUI opens such files.

Also make wrapMessageBox print errors and warnings to stderr in CLI
mode - previously they went only to the log sink, which is invisible on
the console, so the reason for a failed load never reached the user -
and fix its severity checks, which used | rather than & and so
classified every message as a warning.
Adds cases for the boundary conditions around the happy path: a valid
file with no dialogue lines (loads with an inserted blank line and runs
the macro, matching GUI behavior), a nonexistent input file (clean exit
1 with a diagnostic and no output written), a malformed option value,
and an unknown option (both clean exit 1 rather than death by uncaught
exception).
program_options is one of boost's compiled libraries, and this branch
would have been the first thing in the tree to link it - the wrong
direction given the appetite to shrink the boost dependency. CLI11 is
a header-only single dependency consumed through its first-party meson
support (the wrap is just a hash-pinned pointer to the upstream
tarball), included by main.cpp alone.

The port also removes the stringly-typed variables_map lookups in
favor of options bound to typed locals, requires in-file and out-file
up front in --cli mode instead of failing later with a bad_any_cast,
and keeps the boost behaviors the tests pin down: positionals are also
accessible as named options, repeatable options take exactly one value
per occurrence (allow_extra_args(false) - CLI11's vector options are
otherwise greedy and would swallow the positionals), unknown options
still pass through for GUI launches and error out in CLI mode, and all
parse failures exit 1 with a diagnostic.
@CoffeeFlux CoffeeFlux changed the title Headless cli Headless CLI Aug 13, 2026

@arch1t3cht arch1t3cht left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for cleaning this up and finishing this!

The rebase of my old cli branch looks good except for a couple of lines that cause compiler warnings now that the warning level was raised, see the following patches that can be squashed onto e97b25e and 4d738d5:

diff --git a/libaegisub/include/libaegisub/background_runner.h b/libaegisub/include/libaegisub/background_runner.h
index 743f5e643..63c6740fe 100644
--- a/libaegisub/include/libaegisub/background_runner.h
+++ b/libaegisub/include/libaegisub/background_runner.h
@@ -55,7 +55,7 @@ namespace agi {
 		virtual bool IsCancelled()=0;
 	};
 
-	/// @class DummyProgressSink
+	/// @class CLIProgressSink
 	/// @brief A progress sink that doesn't do anything with its progress updates except print logs
 	class CLIProgressSink : public ProgressSink {
 		std::ostream &stream;
@@ -64,11 +64,11 @@ namespace agi {
 		CLIProgressSink(std::ostream &stream = std::cout) : stream(stream) {};
 
 		void SetIndeterminate() {}
-		void SetTitle(std::string const& title) {}
-		void SetMessage(std::string const& title) {}
-		void SetProgress(int64_t cur, int64_t max) {}
+		void SetTitle(std::string const&) {}
+		void SetMessage(std::string const&) {}
+		void SetProgress(int64_t, int64_t) {}
 		void Log(std::string const& str) { stream << str; }
-		void SetStayOpen(bool stayopen) {}
+		void SetStayOpen(bool) {}
 		bool IsCancelled() { return false; }
 	};
diff --git a/src/auto4_lua_dialog.cpp b/src/auto4_lua_dialog.cpp
index 05d64fef4..98621449a 100644
--- a/src/auto4_lua_dialog.cpp
+++ b/src/auto4_lua_dialog.cpp
@@ -459,7 +459,7 @@ namespace Automation4 {
 			buttons.emplace_back(wxID_CANCEL, "");
 		}
 
-		for (int i = 0; i < buttons.size(); i++) {
+		for (int i = 0; i < std::ssize(buttons); i++) {
 			LOG_D("automation/lua/dialog") << "created button: " << buttons[i].second << " (" << i << ")";
 		}
 	}
@@ -541,7 +541,7 @@ namespace Automation4 {
 	}
 
 	void LuaDialog::PushButton(int button) {
-		if (button != -1 && (button < 0 || button >= buttons.size())) {
+		if (button != -1 && (button < 0 || button >= std::ssize(buttons))) {
 			LOG_E("agi/auto4_lua_dialog") << "Button " << button << " not in range; defaulting to cancel";
 			button = -1;
 		}

Almost all of the further additions also look good to me. The dispatch queue is pretty much what I was also planning to do back when I was working on this branch, and I definitely also agree with using CLI11 over boost's program_options (which I just copied from myaa's aegisub-cli back then).

(Adding cli11 to the --skip-subprojects arg in osx-bundle.sh should be enough to fix the Mac CI).

Apart from the review comments, I mainly just have a few thoughts on what else is eventually needed for CLI support:

  • For full parity with aegisub-cli, the resolution resampler would also need to be exposed to the CLI. Though then again resolution resampling is becoming less and less relevant now that LayoutRes is available everywhere so maybe this isn't even needed any more?

  • Back when working on this, I was considering making an actual aegisub-cli wrapper program that just passes its arguments on to aegisub --cli (on Linux/Mac this can be a simple symlink that branches on argv[0], on Windows the aegisub.com wrapper can be copied to aegisub-cli.com and also branch on argv[0]).

  • The main use case for headless CLI at the moment are end-to-end tests for automation scripts, but of course, once this is released, it will get used by other tooling like muxtools and SubKt. So we'd need to try and get the CLI API right the first time so we don't need to do compat breaks in the future. Not that I have any concrete issues with the current API, but it's at least something to think about.

  • My biggest worry is that Aegisub still loads and writes a bunch of global configuration, even in CLI mode, and I'm still not really sure how to best reconcile this:

    • On the one hand, with E2E tests or as part of some muxing pipeline, it would be best for aegisub-cli to be as self-contained and reproducible as possible
    • In particular there is probably no reason why aegisub-cli should write to the user's config/hotkeys/MRU/autosaves/etc.
    • On the other hand, users may want aegisub-cli to load their global automation scripts. Also, even if there was a way to set a different automation autoload/include directory for the CLI, there would need to be some easy way to install an automation script with all of its dependencies into that directory.
    • In some setups (portable Windows installs) it is simply impossible to load Aegisub's built-in Lua libraries without also loading (i.e. adding to the include path) user-installed libraries.
    • At the moment, DependencyControl scripts will probably still try to automatically update themselves when called in CLI mode, which is probably not a good idea? But on the other hand it would be nice if there was a way to explicitly request to install or update scripts from the CLI. There's probably no way to sort this out without DependencyControl (and hence maybe scripts in general) being explicitly aware of whether it's running in CLI mode or not, and where the automation directory is.

    The solution to all of this is probably some collection of command-line options to specify how Aegisub should interact with its global config, but I'm not yet sure what they should be. (Some prior art here is mpv with --no-config and --player-operation-mode. We should probably also check some other GUI applications that offer CLI modes, like e.g. Inkscape.). Maybe something like:

    • CLI mode does not load Aegisub's config.json by default (if necessary it can load a builtin config overlay for CLI mode that e.g. disables autosaving?) but a config.json can be passed on the command line (e.g. --config-string="<explicit JSON>" or --config-file=config.json or --config-user to load the user's config, if there is even a use-case for the latter)
      • Will need to check which config options even affect CLI mode. At the very least the audio/video provider and automation path options do.
    • By default, both ?data and ?user are added to the automation include path: This is out of necessity since ?data needs to be added for aegisub-cli to be in any way useful, and ?user is sometimes equal to ?data, but it would be nice to have some better solution here. But the path can be adjusted if needed - either via --config or even through some explicit command-line option.
    • Like aegisub-cli already does, no global automation scripts are loaded by default; scripts to load need to be explicitly passed on the command-line. But it'd be nice to also be able to pass a directory of scripts or, if a use-case comes up, to load Aegisub's global autoload directories.
    • How to handle a) DependencyControl's updates and b) the config files of scripts? DependencyControl probably shouldn't auto-update in headless mode unless explicitly requested to, and scripts maybe shouldn't write their own config/state files unless explicitly requested to, but then the question is how to explicitly request these. The simple solution for now would be to expose if Aegisub is running in CLI mode or not to Lua, but I'm not even sure if that is the best solution. The longer-term solution might be to integrate the config handling of scripts more directly into Aegisub's built-in API, which I wanted to do eventually anyway.

    But I'm still very unsure about much of this.

  • The long-term plan should probably be to separate Aegisub's GUI front-end more and more from the subtitle/video/audio/automation loading (finishing the libaegisub split) and processing to make all of the CLI code paths less invasive, but that will need much bigger refactors.

aegisub.progress.task("Appending markers")

-- The clipboard functions bounce through the main thread via
-- dispatch::Main().Sync(), so this exercises the CLI main loop pump

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do they actually? AFAICT they explicitly do not dispatch to Main in CLI mode.

Comment thread src/main.cpp Outdated
// line (SubsController inserts one into empty files on commit),
// so an empty event list here means the file didn't load.
if (context.ass->Events.empty()) {
std::cerr << "Failed to load " << vm["in-file"].as<std::string>() << std::endl;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it's better to make Project::LoadSubtitles return a status instead? Project::DoLoadSubtitles already returns a bool so LoadSubtitles would just need to pass that on.

Comment thread src/main.cpp
// A GUI-subsystem application's entry point on Windows is WinMain, which wx
// provides; --cli mode is not available there yet
wxIMPLEMENT_APP(AegisubApp);
#else

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I can tell it should be fairly simple to manually implement WinMain on Windows and forward the arguments to wxEntry? I can try this out later.

Though I guess the bigger issue there is that windows-subsystem programs can't send their stdout to the console. For that, my plan was to just steal mpv's solution, i.e. making an aegisub.com wrapper that calls aegisub.exe and redirects stdin/out.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants