15 KiB
CLI Architecture Options
Current State
Currently, both GUI and console variants create the full UI:
- Console variant = GUI variant + visible console window
- All ImGui/rendering initialization still happens
- Window is created even if you just want to process files
Problem: No true headless/CLI mode for automation, testing, or server environments.
Goal
Enable true CLI operation:
- Process graphs without creating windows
- Validate, convert, export without UI overhead
- Reuse existing CLI argument parsing from
entry_point.cpp - Keep GUI and CLI code maintainable
Architecture Options
Option 1: Headless Mode Flag ⭐ (Recommended for Quick Implementation)
Concept: Add a --headless flag that skips all UI initialization.
Pros:
- Minimal code changes
- Reuses all existing infrastructure
- Single codebase for both modes
- Easy to maintain
Cons:
- Application class still assumes UI might exist
- Some refactoring needed to make UI optional
- Not the cleanest separation
Implementation:
// blueprints-example.cpp
int Main(const ArgsMap& args)
{
// Check for headless mode
bool headless = false;
auto it = args.find("headless");
if (it != args.end() && it->second.Type == ArgValue::Type::Bool && it->second.Bool) {
headless = true;
}
if (headless) {
// CLI mode - no UI
return RunCLI(args);
} else {
// GUI mode - existing code
App example("Blueprints", args);
if (example.Create())
return example.Run();
return 0;
}
}
int RunCLI(const ArgsMap& args)
{
// Load graph
std::string filename = GetStringArg(args, "file", "");
if (filename.empty()) {
fprintf(stderr, "Error: --file required in headless mode\n");
return 1;
}
// Process without UI
GraphState graph;
if (!graph.Load(filename)) {
fprintf(stderr, "Error: Failed to load %s\n", filename.c_str());
return 1;
}
// Perform operations
std::string command = GetStringArg(args, "command", "validate");
if (command == "validate") {
return ValidateGraph(graph);
} else if (command == "export") {
std::string output = GetStringArg(args, "output", "");
return ExportGraph(graph, output);
}
// ... more commands
return 0;
}
Entry point update:
// entry_point.cpp - add to CLI parser
app.add_option("--headless", "Run without GUI")->capture_default_str();
app.add_option("--command", "Command to execute (validate, export, etc.)")->capture_default_str();
app.add_option("--output", "Output file path")->capture_default_str();
Usage:
# Validate graph
blueprints-example-console.exe --headless --file graph.json --command validate
# Export to different format
blueprints-example-console.exe --headless --file graph.json --command export --output graph.xml
# GUI mode (existing)
blueprints-example.exe --file graph.json
Option 2: Separate CLI Executable
Concept: Create blueprints-cli as a completely separate executable.
Pros:
- Clean separation
- No UI dependencies in CLI build
- Smaller executable for CLI
- Clear intent (different binary = different purpose)
Cons:
- Code duplication risk
- Two build targets to maintain
- Shared code must be extracted to library
Implementation:
# CMakeLists.txt
add_library(blueprints-core STATIC
core/graph_state.cpp
blocks/block.cpp
# ... all non-UI code
)
# GUI application
add_example_executable(blueprints-example
blueprints-example.cpp
app.cpp
# ... UI files
)
target_link_libraries(blueprints-example PRIVATE blueprints-core)
# CLI application
add_executable(blueprints-cli
cli/blueprints-cli.cpp
cli/commands.cpp
)
target_link_libraries(blueprints-cli PRIVATE blueprints-core)
# Note: No imgui, no application library
// cli/blueprints-cli.cpp
int main(int argc, char** argv)
{
CLI::App app{"Blueprints CLI"};
std::string file;
std::string command;
std::string output;
app.add_option("-f,--file", file, "Input graph file")->required();
app.add_option("-c,--command", command, "Command")->required();
app.add_option("-o,--output", output, "Output file");
CLI11_PARSE(app, argc, argv);
// Execute command
return ExecuteCommand(command, file, output);
}
Usage:
blueprints-cli -f graph.json -c validate
blueprints-cli -f graph.json -c export -o graph.xml
blueprints-example --file graph.json # GUI
Option 3: Application Base Class Architecture ⭐⭐ (Recommended for Long-term)
Concept: Separate core logic from presentation.
Pros:
- Clean architecture
- Core testable without UI
- Natural separation of concerns
- Easy to add new frontends (Web, TUI, etc.)
Cons:
- Requires significant refactoring
- More files to maintain
- Need to identify what's "core" vs "UI"
Implementation:
// core/blueprints_engine.h
class BlueprintsEngine {
public:
BlueprintsEngine();
virtual ~BlueprintsEngine() = default;
// Core operations (no UI)
bool LoadGraph(const std::string& filename);
bool SaveGraph(const std::string& filename);
bool ValidateGraph();
bool ExecuteGraph();
std::string ExportGraph(const std::string& format);
GraphState& GetGraph() { return m_Graph; }
protected:
GraphState m_Graph;
// ... core state
};
// app/blueprints_app.h
class BlueprintsApp : public BlueprintsEngine {
public:
BlueprintsApp(const char* name, const ArgsMap& args);
// UI operations
bool Create();
int Run();
void OnFrame(float deltaTime);
private:
Application m_Application;
ax::NodeEditor::EditorContext* m_Editor;
// ... UI state
// UI methods
void RenderNodes();
void HandleInput();
};
// cli/blueprints_cli.h
class BlueprintsCLI : public BlueprintsEngine {
public:
BlueprintsCLI(const ArgsMap& args);
int Execute();
private:
ArgsMap m_Args;
int CommandValidate();
int CommandExport();
int CommandExecute();
};
// blueprints-example.cpp (GUI)
int Main(const ArgsMap& args)
{
BlueprintsApp app("Blueprints", args);
if (app.Create())
return app.Run();
return 0;
}
// blueprints-cli.cpp (CLI)
int Main(const ArgsMap& args)
{
BlueprintsCLI cli(args);
return cli.Execute();
}
Usage:
# Same executable, mode determined by arguments
blueprints-example-console.exe --headless --file graph.json --command validate
blueprints-example.exe --file graph.json # GUI mode
Option 4: Composition Pattern
Concept: Core engine is a separate component, Application "has-a" engine.
Pros:
- Very clean separation
- Engine easily testable
- Can swap out UI completely
- Multiple UIs can share same engine
Cons:
- More indirection
- Need to manage engine lifetime
- Communication between engine and UI
Implementation:
// core/graph_engine.h
class GraphEngine {
public:
struct Config {
std::string filename;
bool autoSave = false;
// ... options
};
GraphEngine(const Config& config);
bool Load();
bool Save();
bool Validate();
void Execute();
// Query state
const GraphState& GetState() const { return m_State; }
// Modify state
void AddNode(const NodeConfig& config);
void RemoveNode(NodeId id);
void AddLink(PinId from, PinId to);
private:
Config m_Config;
GraphState m_State;
};
// app/blueprints_app.h
class BlueprintsApp {
public:
BlueprintsApp(std::unique_ptr<GraphEngine> engine);
bool Create();
int Run();
private:
std::unique_ptr<GraphEngine> m_Engine;
Application m_Application;
// UI renders m_Engine->GetState()
};
// cli/blueprints_cli.h
class BlueprintsCLI {
public:
BlueprintsCLI(std::unique_ptr<GraphEngine> engine, const ArgsMap& args);
int Execute();
private:
std::unique_ptr<GraphEngine> m_Engine;
ArgsMap m_Args;
};
// blueprints-example.cpp
int Main(const ArgsMap& args)
{
bool headless = GetBoolArg(args, "headless", false);
GraphEngine::Config config;
config.filename = GetStringArg(args, "file", "");
auto engine = std::make_unique<GraphEngine>(config);
if (headless) {
BlueprintsCLI cli(std::move(engine), args);
return cli.Execute();
} else {
BlueprintsApp app(std::move(engine));
if (app.Create())
return app.Run();
return 0;
}
}
Option 5: Command Pattern
Concept: CLI commands are objects, both GUI and CLI execute commands.
Pros:
- Undo/redo support naturally
- Commands are testable
- GUI and CLI share exact same logic
- Easy to add scripting
Cons:
- Everything must be a command
- More abstraction overhead
- Complex for simple operations
Implementation:
// commands/command.h
class Command {
public:
virtual ~Command() = default;
virtual bool Execute(GraphState& state) = 0;
virtual std::string GetName() const = 0;
};
// commands/validate_command.h
class ValidateCommand : public Command {
public:
bool Execute(GraphState& state) override {
// Validation logic
return ValidateGraphImpl(state);
}
std::string GetName() const override { return "validate"; }
};
// commands/export_command.h
class ExportCommand : public Command {
public:
ExportCommand(const std::string& outputPath, const std::string& format)
: m_OutputPath(outputPath), m_Format(format) {}
bool Execute(GraphState& state) override {
return ExportGraphImpl(state, m_OutputPath, m_Format);
}
std::string GetName() const override { return "export"; }
private:
std::string m_OutputPath;
std::string m_Format;
};
// cli/command_runner.h
class CommandRunner {
public:
CommandRunner(GraphState& state) : m_State(state) {}
bool Run(std::unique_ptr<Command> cmd) {
printf("Executing: %s\n", cmd->GetName().c_str());
return cmd->Execute(m_State);
}
private:
GraphState& m_State;
};
// blueprints-cli.cpp
int Main(const ArgsMap& args)
{
bool headless = GetBoolArg(args, "headless", false);
std::string filename = GetStringArg(args, "file", "");
GraphState state;
if (!state.Load(filename)) {
fprintf(stderr, "Failed to load graph\n");
return 1;
}
if (headless) {
std::string command = GetStringArg(args, "command", "validate");
CommandRunner runner(state);
std::unique_ptr<Command> cmd;
if (command == "validate") {
cmd = std::make_unique<ValidateCommand>();
} else if (command == "export") {
std::string output = GetStringArg(args, "output", "");
std::string format = GetStringArg(args, "format", "json");
cmd = std::make_unique<ExportCommand>(output, format);
}
return runner.Run(std::move(cmd)) ? 0 : 1;
} else {
// GUI mode - commands triggered by UI actions
BlueprintsApp app(state);
return app.Run();
}
}
Comparison Matrix
| Criteria | Option 1 Headless Flag |
Option 2 Separate Exe |
Option 3 Base Class |
Option 4 Composition |
Option 5 Commands |
|---|---|---|---|---|---|
| Implementation Effort | ⭐⭐⭐⭐⭐ Easy | ⭐⭐⭐ Moderate | ⭐⭐ Hard | ⭐⭐ Hard | ⭐ Very Hard |
| Maintainability | ⭐⭐⭐ Good | ⭐⭐⭐ Good | ⭐⭐⭐⭐ Great | ⭐⭐⭐⭐⭐ Excellent | ⭐⭐⭐ Good |
| Code Reuse | ⭐⭐⭐⭐⭐ Excellent | ⭐⭐⭐ Good | ⭐⭐⭐⭐ Great | ⭐⭐⭐⭐⭐ Excellent | ⭐⭐⭐⭐⭐ Excellent |
| Testing | ⭐⭐ Fair | ⭐⭐⭐⭐ Great | ⭐⭐⭐⭐⭐ Excellent | ⭐⭐⭐⭐⭐ Excellent | ⭐⭐⭐⭐⭐ Excellent |
| Clean Separation | ⭐⭐ Fair | ⭐⭐⭐⭐⭐ Excellent | ⭐⭐⭐⭐ Great | ⭐⭐⭐⭐⭐ Excellent | ⭐⭐⭐⭐ Great |
| Binary Size (CLI) | Large (full UI) | ⭐⭐⭐⭐⭐ Small | Medium | Medium | Medium |
| Flexibility | ⭐⭐⭐ Good | ⭐⭐⭐ Good | ⭐⭐⭐⭐ Great | ⭐⭐⭐⭐⭐ Excellent | ⭐⭐⭐⭐⭐ Excellent |
Recommended Approach
Phase 1: Quick Win (Option 1)
Start with Headless Flag approach:
- Immediate CLI functionality
- Minimal code changes
- Validates the CLI use cases
Phase 2: Refactor (Option 3 or 4)
Once CLI use cases are clear:
- Extract core logic
- Either use Base Class (if inheritance makes sense) or Composition (if more flexibility needed)
- Better testing and separation
Phase 3: Advanced (Optional)
If you need undo/redo, scripting, or complex workflows:
- Add Command Pattern on top of Phase 2
CLI Argument Reuse
All options can reuse entry_point.cpp CLI parsing:
// entry_point.cpp - add CLI options
app.add_flag("--headless", "Run without GUI");
app.add_option("--command", "Command to execute")->capture_default_str();
app.add_option("--output", "Output file path")->capture_default_str();
app.add_option("--format", "Output format")->capture_default_str();
app.add_flag("--validate", "Validate graph and exit");
app.add_flag("--execute", "Execute graph and exit");
The ArgsMap is already passed to Main(), so all these arguments are available.
Example CLI Commands
# Validate
blueprints-console.exe --headless --file graph.json --command validate
# Execute headless
blueprints-console.exe --headless --file graph.json --command execute
# Export to XML
blueprints-console.exe --headless --file graph.json --command export --output graph.xml --format xml
# Convert format
blueprints-console.exe --headless --file graph.json --command convert --output graph.yaml
# Batch processing
for file in *.json; do
blueprints-console.exe --headless --file "$file" --command validate
done
Next Steps
- Decision: Choose starting approach (recommend Option 1 for quick start)
- Identify: List specific CLI operations needed (validate, export, execute, convert, etc.)
- Implement: Add headless flag and CLI operations
- Test: Verify CLI mode works without creating windows
- Refactor: If needed, move to Option 3 or 4 for better architecture