This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
# Build the whole solution. Bin/obj land in .build/ (centralised).
dotnet build Claudelk.slnx
# Run a CLI command in development (slow: ~5s dotnet run overhead).
dotnet run --project src/Claudelk.Cli -- <subcommand> [args]
# Publish the production exe used by Claude Code hooks.
dotnet publish src/Claudelk.Cli -c Release
# Default output: .build\bin\Claudelk.Cli\Release\<tfm>\publish\
# The committed csproj does NOT hardcode a PublishDir. Per-developer install
# path lives in src\Claudelk.Cli\Claudelk.Cli.csproj.user (gitignored *.user).
# Set <PublishDir> there to redirect publish at whatever path your Claude Code
# hooks invoke. ~1.6s cold per command.CLI subcommands: scan [--debug], pair <id>, ensure [--color <#RRGGBB>] (verify connection; reconnect/re-pair/power-on as needed, then set colour — default #ffffff; used by the SessionStart hook), on, off, color <#RRGGBB>, blink <#RRGGBB> [pulses] [ms] [--end <#RRGGBB>], brightness 0-100, speed 0-100, effect 0x80-0x9f, temp 0-100. All commands accept --device <id> to override the saved default.
# Build + run all NUnit tests (Claudelk.Core.Tests).
dotnet test Claudelk.slnxtests/Claudelk.Core.Tests/ (NUnit 4 on Microsoft.Testing.Platform) covers:
- The pure-protocol byte builders in
ElkBledomProtocol. UserConfigJSON round-trip + missing/malformed-file behaviour.ElkBledomScannername-prefix matching and host-availability propagation, via aFakeBluetoothHost.ElkBledomDevicecommand byte streams andConnectByIdAsyncresolution (paired-list fast path → scan fallback → not-found error), via aFakeBluetoothDevice.
The BLE abstraction layer (IBluetoothHost / IBluetoothDevice + the InTheHand adapters under src/Claudelk.Core/Bluetooth/InTheHand/) exists so the protocol-level code is testable; the InTheHand adapters themselves still need a real ELK-BLEDOM strip to smoke-test.
The test project sets <UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner> so dotnet test invokes MTP rather than legacy VSTest. The output format is Run tests: / Tests succeeded: instead of VSTest's Test run for... / Passed! - Failed: 0....
The csprojs are intentionally minimal — shared concerns live in repo-root MSBuild files. Edit these instead of duplicating settings per-project.
global.json— pins the .NET SDK to 10.0.x.Directory.Build.props—TargetFramework(Windows-specific TFM is mandatory, see gotcha #1 below),Nullable,LangVersion, strict analyzer settings (TreatWarningsAsErrors,WarningLevel=9999,AnalysisMode=AllEnabledByDefault,AnalysisLevel=latest-recommended,EnforceCodeStyleInBuild),NoWarn=NU1903,BaseOutputPath/BaseIntermediateOutputPath→.build/, and analyzerPackageReferences for Meziantou.Analyzer + AsyncFixer.Directory.Packages.props— central package management; bare<PackageReference Include="X" />in csprojs resolves versions from here.nuget.config—nuget.orgonly with package-source mapping locked to it..editorconfig— formatting (4-space, CRLF, UTF-8) plus a small, audited set of analyzer-rule silences: IDE0008/0011/0021/0022/0046/0300 (pure style),MA0004(ConfigureAwait — irrelevant in a console app),MA0136(raw-string EOL nag on help text),MA0165(experimental rule even Meziantou disables by default). All other rules either fire as errors or were fixed in code (the culture-awareint.TryParse/string.Createpatterns inDispatcherare deliberate, not workarounds). The same file also forbids top-level statements and enforces a blank line after every code block (IDE2003).
When adding a new project: just create the csproj — TFM, language version, strictness, and analyzers come from the repo-root files automatically.
The Claude Code integration runs the published exe with "async": true and "shell": "powershell" on lifecycle events. The hook command path in ~/.claude/settings.json must match the <PublishDir> configured in Claudelk.Cli.csproj.user. The repo does not prescribe a specific colour-per-event scheme — see the "Claude Code integration" section in README.md for example configurations to crib from.
Three layers, each in its own folder under src/Claudelk.Core/:
Protocol/ElkBledomProtocol.cs— pure functions that produce 9-byte command packets (7e ... ef). No I/O. This is the only place protocol byte layout lives; reuse it rather than re-encoding bytes elsewhere.Bluetooth/—ElkBledomScanner(BLE discovery) andElkBledomDevice(GATT connect + per-command write helpers). Both wrapInTheHand.BluetoothLE(32feet.NET v4.x).Configuration/UserConfig.cs— persists the last paired device id to%APPDATA%\Claudelk\config.json.
src/Claudelk.Cli/ is thin: Program.cs delegates to Dispatcher.RunAsync, which parses args and calls ElkBledomDevice methods. Device resolution flows through ResolveDeviceAsync → ElkBledomDevice.ConnectByIdAsync, which tries Bluetooth.GetPairedDevicesAsync() first (fast path, no scan) and falls back to a 3s advertisement scan.
The future Claude integration daemon (phase 3 in README.md) is not yet built; it would live in a third project depending on Claudelk.Core.
-
Both csprojs must target
net10.0-windows10.0.19041.0. With plainnet10.0,InTheHand.BluetoothLEselects its Linux/BlueZ backend and every scan fails at runtime withNo path specified for UNIX transport. -
The
InTheHand.Bluetoothnamespace shadowsClaudelk.Core.Bluetooth. When calling the staticBluetooth.ScanForDevicesAsync/GetAvailabilityAsync/GetPairedDevicesAsync, alias it:using IhBluetooth = InTheHand.Bluetooth.Bluetooth;. -
The Web-Bluetooth-spec property is
AcceptAllDevices, notAcceptAllAdvertisements.RequestDeviceOptionsalso has aTimeoutproperty — use it; don't rely on defaults. -
Name-based BLE scan filters are unreliable on Windows (the name is in the scan response, not the advertisement).
ElkBledomScannerscans withAcceptAllDevices = trueand filters in C# against known name prefixes (ELK-BLEDOM,ELK-BLE,LEDBLE,MELK, …). -
Tmds.DBusNU1903advisory is a Linux-only transitive dep pulled in byInTheHand.BluetoothLE. Suppressed via<NoWarn>$(NoWarn);NU1903</NoWarn>inDirectory.Build.props— don't remove that line on Windows builds.
Per one-shot command: ~0.3s .NET startup + ~1s cold GATT connect/service discovery + ~30ms write ≈ ~1.6s floor on the published exe. Use the published exe (not dotnet run) when latency matters. To go below ~1.6s, the daemon (phase 3) is the only path — it would hold the GATT connection open across commands.
Dispatcher.ResolveDeviceAsync skips advertisement scanning entirely when the device is in GetPairedDevicesAsync() — keep this fast path. Slowdowns usually mean the device fell out of Windows' paired list (re-pair via Settings or rerun claudelk pair <id>).
This codebase ports ideas/protocol bytes from upstream projects. See NOTICE.md for the full list — when modifying the protocol module or adding a Claude integration layer, keep the attribution lines in the file headers.