End-to-end build steps. Follow top to bottom on a clean machine.
- Windows 10 or 11 (x64)
- .NET 10 SDK installed (
dotnet --versionreturns10.x.x) - Any editor (VS Code with C# Dev Kit, Rider, or Visual Studio)
- Git (optional)
From C:\Users\Patron\OpenKey\:
dotnet new sln -n OpenKey # the repo now uses the newer OpenKey.slnx format
dotnet new console -n OpenKey -o src\OpenKey --framework net10.0
dotnet new classlib -n OpenKey.Core -o src\OpenKey.Core --framework net10.0
dotnet new classlib -n OpenKey.Providers.OpenRouter -o src\OpenKey.Providers.OpenRouter --framework net10.0
dotnet sln add src\OpenKey\OpenKey.csproj
dotnet sln add src\OpenKey.Core\OpenKey.Core.csproj
dotnet sln add src\OpenKey.Providers.OpenRouter\OpenKey.Providers.OpenRouter.csproj
dotnet add src\OpenKey\OpenKey.csproj reference src\OpenKey.Core\OpenKey.Core.csproj
dotnet add src\OpenKey\OpenKey.csproj reference src\OpenKey.Providers.OpenRouter\OpenKey.Providers.OpenRouter.csproj
dotnet add src\OpenKey.Providers.OpenRouter\OpenKey.Providers.OpenRouter.csproj reference src\OpenKey.Core\OpenKey.Core.csprojdotnet add src\OpenKey\OpenKey.csproj package Spectre.Console
dotnet add src\OpenKey\OpenKey.csproj package Markdig
dotnet add src\OpenKey\OpenKey.csproj package Microsoft.Extensions.DependencyInjection
dotnet add src\OpenKey\OpenKey.csproj package System.Security.Cryptography.ProtectedDataTwo corrections to what this step originally said:
- Markdig is required.
MarkdownConsoleRendereris built on it, so omitting it here produced a project that does not compile. Microsoft.Extensions.Hostingis not used. A REPL needs no generic host, hosted-service lifetime, or configuration binding;Program.cscomposes its dozen services explicitly. Seearchitecture/08-decisions.md.
OpenKey.Core and OpenKey.Providers.OpenRouter use only BCL (System.Text.Json, System.Net.Http, System.Security.Cryptography.ProtectedData). DPAPI lives in the System.Security.Cryptography.ProtectedData NuGet (it was removed from the SDK BCL on non-Windows targets):
dotnet add src\OpenKey.Core\OpenKey.Core.csproj package System.Security.Cryptography.ProtectedDataShared settings — TargetFramework, Nullable, ImplicitUsings, Version,
TreatWarningsAsErrors — live in Directory.Build.props at the repo root, not in each project.
Don't repeat them per project; they apply automatically.
src\OpenKey\OpenKey.csproj carries only what is specific to the host:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<!-- -windows only on the host: DPAPI is Windows-only, and Core must stay portable
so a future PWA or Android port doesn't inherit a Windows target framework. -->
<TargetFramework>net10.0-windows</TargetFramework>
<RootNamespace>OpenKey</RootNamespace>
<AssemblyName>OpenKey</AssemblyName>
<RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers>
<InvariantGlobalization>false</InvariantGlobalization>
<!-- The publish profile lives here, not in a README. See docs/06. -->
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>true</SelfContained>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<PublishReadyToRun>true</PublishReadyToRun>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
</Project>OpenKey.Core.csproj and OpenKey.Providers.OpenRouter.csproj need almost nothing beyond
IsAotCompatible and their InternalsVisibleTo entries.
Below: the stub signatures. Implementation details for the OpenRouter parts live in 03-openrouter-integration.md; rotation in 04-model-rotation.md; persistence in 05-persistence-and-reset.md.
See full definition in 01-architecture.md § "IChatProvider contract". Drop it in this file.
See 01-architecture.md § "Error taxonomy". Drop it in this file.
public sealed class ChatEngine
{
private readonly IChatProvider _provider;
private readonly IRotationPolicy _rotation;
private readonly IModelCatalog _catalog;
private readonly ISessionStore _sessions;
private readonly List<ChatMessage> _turns = new();
public ChatEngine(IChatProvider provider, IRotationPolicy rotation,
IModelCatalog catalog, ISessionStore sessions) { ... }
public ModelInfo? ActiveModel { get; private set; }
public Task ResumeAsync(CancellationToken ct); // load last session if any
public Task NewSessionAsync(CancellationToken ct); // clear _turns
public IAsyncEnumerable<ChatChunk> SendAsync(string userText, CancellationToken ct);
public IReadOnlyList<ChatMessage> Turns => _turns;
}Inside SendAsync: append user turn → trim rolling window → loop over _rotation.PickAsync retrying on ChatException (kind in transient set) → yield chunks → on final, append assistant turn → _sessions.SaveAsync.
public interface IRotationPolicy
{
Task<ModelInfo> PickAsync(IReadOnlyList<ModelInfo> candidates, CancellationToken ct);
void MarkFailure(string modelId, ChatErrorKind kind, string? retryAfterHint);
void MarkSuccess(string modelId);
}Default impl RotationPolicy.cs — see 04-model-rotation.md.
public interface IKeyStore
{
bool HasKey();
string? Load(); // returns plaintext, null if missing/corrupt
void Save(string apiKey);
void Clear();
}Default impl DpapiKeyStore.cs — see 05-persistence-and-reset.md.
public interface ISessionStore
{
Task<SessionSnapshot?> LoadAsync(CancellationToken ct);
Task SaveAsync(SessionSnapshot snap, CancellationToken ct);
void Clear();
}
public sealed record SessionSnapshot(
string ModelId,
DateTimeOffset StartedAt,
IReadOnlyList<ChatMessage> Turns);public interface IModelCatalog
{
Task<IReadOnlyList<ModelInfo>> GetFreeModelsAsync(CancellationToken ct);
Task RefreshAsync(CancellationToken ct);
}Default impl calls IChatProvider.ListModelsAsync, filters IsFree, caches to %APPDATA%\OpenKey\models.cache.json with 24h TTL.
Implements IChatProvider. See 03-openrouter-integration.md for the wire details.
var services = new ServiceCollection();
services.AddSingleton<IKeyStore, DpapiKeyStore>();
services.AddSingleton<ISessionStore, JsonSessionStore>();
services.AddSingleton<IRotationPolicy, RotationPolicy>();
services.AddSingleton<IChatProvider, OpenRouterProvider>();
services.AddSingleton<IModelCatalog, ModelCatalog>();
services.AddSingleton<ChatEngine>();
services.AddSingleton<CommandRouter>();
services.AddSingleton<ConsoleHost>();
services.AddSingleton<HttpClient>(_ => new HttpClient { Timeout = TimeSpan.FromSeconds(60) });
var sp = services.BuildServiceProvider();
await sp.GetRequiredService<ConsoleHost>().RunAsync(default);public sealed class ConsoleHost
{
public async Task RunAsync(CancellationToken outerCt)
{
PrintBanner();
await EnsureFirstRunAsync(); // see 05-persistence-and-reset.md
await _engine.ResumeAsync(outerCt); // restore last session if any
ShowResumeRecap(); // print last 2 turns if resumed
using var cts = LinkCtrlC(outerCt);
while (!cts.IsCancellationRequested)
{
var line = AnsiConsole.Ask<string>("[bold cyan]you[/] ❯ ");
if (string.IsNullOrWhiteSpace(line)) continue;
if (_commands.TryHandle(line, out var stop)) { if (stop) break; else continue; }
// Buffer the full reply (engine still streams chunks), then render once.
var sb = new StringBuilder();
await foreach (var chunk in _engine.SendAsync(line, cts.Token))
sb.Append(chunk.DeltaText);
AnsiConsole.MarkupLine("[bold magenta]OpenKey AI[/]:"); // no model id; see /model
MarkdownConsoleRenderer.Render(AnsiConsole.Console, sb.ToString()); // markdown → Spectre
AnsiConsole.WriteLine();
}
}
}public sealed class CommandRouter
{
public bool TryHandle(string input, out bool stop)
{
stop = false;
if (!input.StartsWith("/")) return false;
var parts = input.Trim().Split(' ', 2);
switch (parts[0].ToLowerInvariant())
{
case "/quit": case "/exit": stop = true; return true;
case "/reset": ResetAll(); return true;
case "/cls": ClearAndShowChatHeader(); return true; // clear + reprint banner/getting-started (not /reset)
case "/model": ShowActiveModel(); return true;
case "/models": ShowModelPicker(); return true; // Spectre SelectionPrompt over free models; pins via ChatEngine.PreferredModelId
case "/about": ShowAbout(); return true; // Spectre Panel: version, data dir, active model, pinned, dev
case "/help": ShowHelp(); return true; // Spectre Table of all commands
default:
AnsiConsole.MarkupLine($"[red]unknown command:[/] {parts[0]}");
return true;
}
}
}/reset semantics are normative in
05-persistence-and-reset.md — follow that, not a
copy here. Both files previously spelled out divergent sequences.
OpenKey.exe launched
│
PrintBanner()
│
keyStore.HasKey()? ── no ─→ Spectre SelectionPrompt:
│ ├── "Sign in with browser (OAuth/PKCE)"
│ │ │
│ │ Show hint: "[p] paste, [c] cancel"
│ │ Spawn background key-watcher (Console.ReadKey).
│ │ │
│ │ OpenRouterOAuth.AcquireKeyAsync(ct):
│ │ 1. generate PKCE pair
│ │ 2. start HttpListener on localhost:3000/callback (fixed)
│ │ 3. Process.Start the openrouter.ai/auth URL
│ │ 4. await callback ?code=... (timeout 5min)
│ │ 5. POST /api/v1/auth/keys exchange → user_key
│ │ │
│ │ interrupts:
│ │ 'p' → cancel listener, fall into paste prompt (same attempt)
│ │ 'c' / Esc → cancel listener, return to menu (next attempt)
│ │ ↓
│ │ validate: provider.ListModelsAsync(ct)
│ │ ↓
│ │ keyStore.Save(user_key)
│ │
│ └── "I already have a key — paste it"
│ │
│ AnsiConsole.Prompt(secret) → validate → keyStore.Save
│
│ on either path failure: show error, re-show menu,
│ up to 3 attempts then exit
│ yes
↓
AnsiConsole.Clear() + reprint banner + commands hint
↓
catalog.GetFreeModelsAsync() (cache or refresh)
↓
engine.ResumeAsync() (loads session.json if present)
↓
REPL ("<Environment.UserName> ❯ " prompt; "Thinking" spinner until the FIRST token, then a
"OpenKey AI · <model> · <elapsed>" header and a block-by-block streamed reply)
OAuth wire details live in 03-openrouter-integration.md § "OAuth / PKCE". Storage behavior unchanged (DPAPI-encrypted key.bin) — OAuth is just a UX option for obtaining the key.
All met as of the production-readiness pass. Several items were reworded when the console was
rebuilt — the originals described a buffered spinner and an OpenKey AI: label that no longer
exist. Automated coverage is in tests/; see 09-testing.md.
-
dotnet run --project src\OpenKeylaunches the banner - First-run menu offers (1) Sign in with browser, (2) Paste an existing key
- During OAuth wait, the hint to press
Pto paste orEscto cancel is visible - Pressing
Pduring OAuth wait drops to the paste prompt within the same attempt - Pressing
Escduring OAuth wait returns to the menu - OAuth path: browser opens to
openrouter.ai/authwithcallback_url=http://localhost:3000/callback(fixed), callback returns a key, app validates and persists it DPAPI-encrypted - If port 3000 is in use, OpenKey says so and offers paste in the same attempt
- Paste path: secret prompt accepts a key, validates against
/models, persists it - After validation the screen clears and the banner reprints before the REPL opens
- REPL prompt is
<Environment.UserName> ❯, degrading to>where the glyph is unsafe - Reply header is
OpenKey AI · <model id> · <elapsed>, printed before the first token arrives - Banner reads
OpenKey vX.Y.Zwith sublineDeveloped by Paolo Patron; no data dir on the banner -
/helplists/models,/model,/about,/cls,/help,/reset,/quit -
/aboutshows version, active model, model choice, data dir, key handling, developer -
/modelsopens a picker showing display name and context size; selecting one pins it until restart; "Auto" clears the pin - Reply text is never truncated; the cursor returns to the prompt without a perceived hang
- A
Thinkingspinner shows until the first token, then the reply streams block by block with bold, italic, inline and fenced code, headings and lists rendered - A forced failure on model 1 rotates to model 2 mid-conversation, and the reply appears once (covered by
ChatEngineTests) -
/modelnames the current model -
/resetstates what it erases, confirms, wipes, and re-runs setup in the same process -
/quitexits cleanly, holding the window when double-clicked - Close and reopen restores the previous session and shows the last 2 turns
- Ctrl+C during a reply cancels that reply only; the next message still works
-
dotnet publish -c Release -r win-x64produces a runnable single.exewith no extra flags