Skip to content
← All posts
· Delta1 Labs LicensingCLICI/CDGuide

Licensing a .NET CLI tool: headless activation, KEYRIGHT_LICENSE, and runners with no "Register" dialog

A command-line tool has no settings screen, often runs on a machine no human sits at, and is driven by scripts that only read exit codes. That breaks the desktop licensing script — paste a key into a dialog, cache a lease, carry on. Here is how to license a dotnet tool or build task with Keyright without a UI: a `license` subcommand that activates and reports, the six places the SDK looks for a license and which CLI idiom maps to each, fail-closed gating that never prompts when stdin is not a terminal, and the ephemeral-CI-runner problem solved with an offline license file carried in KEYRIGHT_LICENSE instead of a seat per job.

Licensing guidance is written for applications with a window. Paste a key into a dialog, the app calls ActivateAsync, a lease lands in app-data, and Validate() keeps the app licensed offline until the lease needs refreshing. A CLI tool — a dotnet tool, a build task, a code generator, a deploy script — breaks three assumptions of that story at once:

  • There is no dialog. The only input surface is arguments, environment variables and files.
  • The machine often has no human on it. A CI runner, a container, a scheduled job.
  • The caller is a script that reads exit codes and stderr, not a person who reads a message box.

None of this changes how Keyright verifies a license — same signed document, same embedded public key, same offline Validate(). What changes is where the license comes from and how the tool reports about it. This post builds that layer for a tool called mytool.

Where a CLI’s license comes from

The Keyright SDK resolves a license from several sources in a fixed precedence order, highest first: an explicit LicenseString, an explicit LicenseFilePath, the file named by the KEYRIGHT_LICENSE environment variable, a ConfigLicensePath, the OS app-data location ({LocalApplicationData}/Keyright/{product}/license.json), and finally the cached activation lease written by a previous ActivateAsync. A desktop app sets none of these. A CLI maps each one onto an idiom its users already know:

Resolution order (highest first)--license path.jsonLicenseFilePath — one run, one fileKEYRIGHT_LICENSE=path.jsonenv var — CI, containers, scriptsapp-data/Keyright/mytool/license.jsona file the user dropped in placecached leasewritten by mytool license activateCI runnersecret → file → env varWorkstationactivate once, onlineValidate()offline, never throws
  • --license <path> on the command line maps to LicenseFilePath. It wins over everything, which is what you want for “run this one invocation with that file.”
  • KEYRIGHT_LICENSE is the environment-variable path. It’s the CI and container idiom: whoever provisions the job writes a file and exports the variable; the tool itself does nothing special.
  • The app-data location is where a user can drop a license file by hand — useful for the air-gapped customer who received license.json by email.
  • The cached lease is what mytool license activate produces on a developer workstation. It’s the online path, and after the one activation it’s as offline as the others.

Initialize the client once and let the flag feed the option when present:

using Keyright.Client;

static KeyrightClient CreateClient(string? licensePath) =>
    KeyrightClient.Initialize(new KeyrightOptions
    {
        Product         = "mytool",
        PublicKeyBase64 = "MIIBIjANBgkq...",                  // verify-only; safe to embed
        ServiceUrl      = "https://licensing.acme.example",   // your issuing service
        LicenseFilePath = licensePath,                        // --license, or null to fall through
    });

PublicKeyBase64 is the only key material in the binary and it can only verify — there is nothing to steal from a decompiled CLI that would let someone issue a license.

The license subcommand

Everything a dialog did becomes a verb. With System.CommandLine:

var licenseOpt = new Option<string?>("--license", "Path to a license file for this run");

var activate = new Command("activate", "Bind this machine to a license key");
var keyArg   = new Argument<string>("key");
activate.AddArgument(keyArg);
activate.SetHandler(async (string key, string? path) =>
{
    var client = CreateClient(path);
    LicenseInfo info = await client.ActivateAsync(key, CancellationToken.None);

    if (info.IsValid && info.IsPaid)
    {
        Console.WriteLine($"Activated: {info.StatusBadge}. Lease cached; the tool now runs offline.");
        return;
    }
    Console.Error.WriteLine($"mytool: activation failed — {info.Message}");
    Environment.ExitCode = info.Status == LicenseStatus.SeatLimit ? 3 : 2;
}, keyArg, licenseOpt);

var status = new Command("status", "Show the resolved license and where it came from");
status.SetHandler((string? path) =>
{
    var client = CreateClient(path);
    LicenseInfo info = client.Validate(out LicenseSourceKind source);
    Console.WriteLine($"edition : {info.StatusBadge}");
    Console.WriteLine($"status  : {info.Status}");
    Console.WriteLine($"source  : {source}");
    if (info.IsValid) Console.WriteLine($"expires : {info.ExpiryUtc:u}");
    else              Console.WriteLine($"reason  : {info.Message}");
    Environment.ExitCode = info.IsValid ? 0 : 2;
}, licenseOpt);

var license = new Command("license") { activate, status };
license.AddGlobalOption(licenseOpt);

ActivateAsync posts the key plus a stable machine id to your service, which consumes a seat and returns a signed lease the SDK verifies and caches. It does not throw for the ordinary failures — bad key, seat limit, revoked, unreachable — it returns a fail-closed LicenseInfo whose Status and Message say why. That’s exactly what a CLI wants: a value to map to an exit code rather than an exception to catch.

status is the piece desktop apps rarely need and CLIs can’t live without. Validate(out LicenseSourceKind source) tells you which source won, so when a user says “it’s licensed on my machine but not in the pipeline,” the two outputs make the difference obvious: one resolved the cached lease, the other resolved nothing because KEYRIGHT_LICENSE wasn’t exported.

Document the exit codes next to the commands. A convention that has served well: 0 licensed, 2 no valid license, 3 seat limit, 4 licensed but the entitlement this command needs is missing. Scripts branch on them; humans read the one line on stderr.

Gating a command without ever prompting

The actual gate is the same as anywhere else — Validate(), offline, fail-closed — but the reporting has to know who is listening:

static int RequireEntitlement(KeyrightClient client, string entitlement)
{
    LicenseInfo info = client.Validate();                // offline; never throws
    if (info.IsValid && info.Entitlements.IsEnabled(entitlement))
        return 0;

    bool interactive = !Console.IsInputRedirected
                       && Environment.GetEnvironmentVariable("CI") is null;

    string fix = info.IsValid
        ? $"this command needs the '{entitlement}' entitlement — upgrade at https://acme.example/pricing"
        : "no valid license — run `mytool license activate <key>` or set KEYRIGHT_LICENSE";

    Console.Error.WriteLine($"mytool: {fix}");
    if (!interactive) return info.IsValid ? 4 : 2;

    // Only a human gets a second line; still no modal, still no blocking prompt.
    Console.Error.WriteLine("mytool: run `mytool license status` for details");
    return info.IsValid ? 4 : 2;
}

// in the command handler
int gate = RequireEntitlement(client, "parallel-build");
if (gate != 0) return gate;

IsEnabled fails closed — an unknown entitlement reads as disabled — so a typo in the entitlement name produces a gated command, never an accidentally free one. Numeric limits work the same way: client.GetLimit("max-projects", fallback: 1) returns your conservative fallback for an unlicensed caller and a maximum for "unlimited".

Never block on Console.ReadLine() to ask for a key when the license is missing. In a non-interactive session that hangs the job until the pipeline’s timeout kills it, which turns a clear “unlicensed” into a mysterious “build took 60 minutes and died.” The check for Console.IsInputRedirected plus the CI variable (set by every major CI system) is cheap insurance.

The ephemeral runner problem

Here’s the trap specific to tools that run in CI. Online activation is node-locked: the SDK fingerprints the machine, the service binds a seat to that fingerprint, and re-activating the same machine is idempotent. On a developer workstation that’s ideal — one seat, one box, forever. On a hosted CI runner the “box” is a fresh VM or container for every job, so every job presents a new fingerprint, every job consumes a new seat, and after N jobs the license hits its seat limit and your build fails with SeatLimit at 3 a.m.

The fix is not a bigger seat count. It’s to use the delivery path that has no seat tracking: a standalone signed license file. You sign it once for the customer (or for your own CI, if the tool is internal):

keyright license --sign --licensee "Acme Corp" --product mytool --tier pro \
  --private-key mytool.private.json --expiry 2027-12-31 --out mytool.license.json

The customer stores that file in their CI secret store and materializes it per job. In GitHub Actions:

- name: Install license
  run: |
    printf '%s' "$MYTOOL_LICENSE_JSON" > "$RUNNER_TEMP/mytool.license.json"
    echo "KEYRIGHT_LICENSE=$RUNNER_TEMP/mytool.license.json" >> "$GITHUB_ENV"
  env:
    MYTOOL_LICENSE_JSON: ${{ secrets.MYTOOL_LICENSE_JSON }}

- name: Build
  run: mytool build --parallel        # Validate() finds KEYRIGHT_LICENSE, verifies offline

No activation call, no network, no seat. Validate() verifies the file’s RSA signature against the embedded public key, checks product, expiry and the optional shipped revocation list, and the command runs. The file is the customer’s own entitlement — it grants nothing beyond what they bought — but it’s transferable, so it belongs in secret storage, not in the repository.

What you give up versus online activation is live revocation. A standalone file can be cut off only by a revocation list you ship with the next release of the tool, and a per-seat count isn’t enforced. For a build tool that trade is almost always right: the expiry date on the file bounds the exposure, and you weren’t going to count CI jobs as seats anyway.

Keep online activation for the machines where a seat really does mean one box: developer workstations, and persistent self-hosted runners that keep their fingerprint between jobs. Those run mytool license activate once; re-activating on later runs, when the network is reachable, silently refreshes the lease and never consumes a second seat, so the tool can call ActivateAsync opportunistically at startup and fall back to the cached lease when offline.

One tool, two kinds of machine

Put the pieces together and the tool has a single, simple policy:

  • Resolution: --license beats KEYRIGHT_LICENSE beats the app-data file beats the cached lease. mytool license status shows which one won.
  • Workstations: mytool license activate <key> once; node-locked seat; offline afterwards; opportunistic refresh.
  • Disposable runners: a signed license file from the secret store via KEYRIGHT_LICENSE; no seat, no network, revoked by list, bounded by expiry.
  • Gating: Validate() + IsEnabled, fail-closed, no prompts, documented exit codes, one line on stderr naming the fix.

The verification underneath never changed. Your users just stopped needing a window to reach it. See offline and air-gapped licensing for the license-file path in depth, activation lifecycle for the lease model the activate verb drives, and licensing containerized services for the related problem of autoscaled replicas.

Try Nebula.NET

Harden your .NET code in minutes — start with the free edition.