diff --git a/ScadaLink.slnx b/ScadaLink.slnx
index 11edc1d..a5ae9c3 100644
--- a/ScadaLink.slnx
+++ b/ScadaLink.slnx
@@ -18,6 +18,7 @@
+
diff --git a/src/ScadaLink.CLI/CliConfig.cs b/src/ScadaLink.CLI/CliConfig.cs
new file mode 100644
index 0000000..22bf13b
--- /dev/null
+++ b/src/ScadaLink.CLI/CliConfig.cs
@@ -0,0 +1,66 @@
+using System.Text.Json;
+
+namespace ScadaLink.CLI;
+
+public class CliConfig
+{
+ public List ContactPoints { get; set; } = new();
+ public string? LdapServer { get; set; }
+ public int LdapPort { get; set; } = 636;
+ public bool LdapUseTls { get; set; } = true;
+ public string DefaultFormat { get; set; } = "json";
+
+ public static CliConfig Load()
+ {
+ var config = new CliConfig();
+
+ // Load from config file
+ var configPath = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ ".scadalink", "config.json");
+ if (File.Exists(configPath))
+ {
+ var json = File.ReadAllText(configPath);
+ var fileConfig = JsonSerializer.Deserialize(json,
+ new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
+ if (fileConfig != null)
+ {
+ if (fileConfig.ContactPoints?.Count > 0) config.ContactPoints = fileConfig.ContactPoints;
+ if (fileConfig.Ldap != null)
+ {
+ config.LdapServer = fileConfig.Ldap.Server;
+ config.LdapPort = fileConfig.Ldap.Port;
+ config.LdapUseTls = fileConfig.Ldap.UseTls;
+ }
+ if (!string.IsNullOrEmpty(fileConfig.DefaultFormat)) config.DefaultFormat = fileConfig.DefaultFormat;
+ }
+ }
+
+ // Override from environment variables
+ var envContacts = Environment.GetEnvironmentVariable("SCADALINK_CONTACT_POINTS");
+ if (!string.IsNullOrEmpty(envContacts))
+ config.ContactPoints = envContacts.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList();
+
+ var envLdap = Environment.GetEnvironmentVariable("SCADALINK_LDAP_SERVER");
+ if (!string.IsNullOrEmpty(envLdap)) config.LdapServer = envLdap;
+
+ var envFormat = Environment.GetEnvironmentVariable("SCADALINK_FORMAT");
+ if (!string.IsNullOrEmpty(envFormat)) config.DefaultFormat = envFormat;
+
+ return config;
+ }
+
+ private class CliConfigFile
+ {
+ public List? ContactPoints { get; set; }
+ public LdapConfig? Ldap { get; set; }
+ public string? DefaultFormat { get; set; }
+ }
+
+ private class LdapConfig
+ {
+ public string? Server { get; set; }
+ public int Port { get; set; } = 636;
+ public bool UseTls { get; set; } = true;
+ }
+}
diff --git a/src/ScadaLink.CLI/ClusterConnection.cs b/src/ScadaLink.CLI/ClusterConnection.cs
new file mode 100644
index 0000000..89ab021
--- /dev/null
+++ b/src/ScadaLink.CLI/ClusterConnection.cs
@@ -0,0 +1,63 @@
+using System.Collections.Immutable;
+using Akka.Actor;
+using Akka.Cluster.Tools.Client;
+using Akka.Configuration;
+using ScadaLink.Commons.Messages.Management;
+
+namespace ScadaLink.CLI;
+
+public class ClusterConnection : IAsyncDisposable
+{
+ private ActorSystem? _system;
+ private IActorRef? _clusterClient;
+
+ public async Task ConnectAsync(IReadOnlyList contactPoints, TimeSpan timeout)
+ {
+ var seedNodes = string.Join(",", contactPoints.Select(cp => $"\"{cp}\""));
+ var config = ConfigurationFactory.ParseString($@"
+ akka {{
+ actor.provider = remote
+ remote.dot-netty.tcp {{
+ hostname = ""127.0.0.1""
+ port = 0
+ }}
+ }}
+ ");
+
+ _system = ActorSystem.Create("scadalink-cli", config);
+
+ var initialContacts = contactPoints
+ .Select(cp => $"{cp}/system/receptionist")
+ .Select(path => ActorPath.Parse(path))
+ .ToImmutableHashSet();
+
+ var clientSettings = ClusterClientSettings.Create(_system)
+ .WithInitialContacts(initialContacts);
+
+ _clusterClient = _system.ActorOf(ClusterClient.Props(clientSettings), "cluster-client");
+
+ // Wait for connection by sending a ping
+ // ClusterClient doesn't have a direct "connected" signal, so we rely on the first Ask succeeding
+ await Task.CompletedTask;
+ }
+
+ public async Task