barbatos-package-authoring
Change the Barbatos.Wpf libraries in src/ and their build - public API design (ConfigureXxx on WpfAppBuilder, AddXxx/UseXxx, TryAdd*, Setup.Resolve, Validate(), [Obsolete] renames), csproj/TFMs, Direc
SKILL.md.claude/skills/barbatos-package-authoring/SKILL.md
GitHubChanging the Barbatos.Wpf libraries
You are changing four published NuGet packages, not an app: public API is the product, and a
careless signature is a breaking change in someone else's code. This skill covers API design,
project files, packaging and release. It is not about consuming a feature (load the feature
skill), writing tests (barbatos-wpf-testing), wording the docs (barbatos-docs-sync) or the
dotnet new templates (barbatos-templates). CLAUDE.md's non-negotiables apply throughout;
this skill gives the detail behind them.
Sources of truth
- Build:
Directory.Build.props,Directory.Build.targets,Directory.Packages.props,nuget.config,Barbatos.Wpf.slnx,.editorconfig, the foursrc/*/*.csproj. - Release:
.github/workflows/barbatos-wpf-cd-nuget.yml. - Precedent code to copy: a Core feature entry point,
Essentials/KeepAwake/AppHostBuilderExtensions.cs; the two-call split,AquariusServiceCollectionExtensions.csandAquariusRouterServiceCollectionExtensions.cs; the construction rule,Setup.ResolveinSetup.cs; a rename,IAppInfo.AppGuidinAppInfo.cs; XAML namespaces,AssemblyInfo.cs. - Tests that pin the conventions:
RunOnStartupFeatureTests.ConfigurationOverridesCodeValues,TrayIconFeatureTests(fake platform registered beforeConfigureTrayIcon),McpOAuthTests.OAuthServicesAreRegisteredByConfigureMcpAndAreReplaceable, fakes inFeatureTestFakes.cs(all intests/Barbatos.Wpf.Core.UnitTests/);AquariusDependencyInjectionTests.csandAquariusRouterDependencyInjectionTests.cs. - "Already native" precedent: Aquarius Patterns that are already native, the run-it-yourself table under KeepAlive, Valee Already native: error display.
MSBuild mechanics, the release workflow step by step, the NoWarn precedents and the
new-package checklist: build-and-release.md. Read it before touching
any Directory.* file, a .csproj, the workflow, or before releasing.
Before writing anything
Answer these three, in order:
- Does WPF already do this?
{Binding},ItemsControl+DataTemplate,ContentControl.Content, theINotifyDataErrorInfoadorner,Preview*tunneling events andnew LengthAttribute(n, n)were all recognized as native and got a README note, not new API. Verify claims about WPF's native behaviour by running it: theTabControl/Framekeep-alive table exists because the assumption was wrong. - Which package owns it? Aquarius stays Core-free and container-free. Router and Valee may
use Aquarius. A third
src/→src/reference, or a new package, needs the maintainer's yes. - Is it reachable through DI? If not, redesign it until it is.
When porting a Vue / Vue Router / vee-validate / Pinia concept, read that project's own docs and
copy its usage shape; an invented "better" design gets rejected. Say in <remarks> where the
port differs and why (RouteRecord, RouteMatcher are the model).
Where the code goes
- Core folders do not match namespaces (
IDE0130is off on purpose): the tray lives inEssentials/TrayIcon/but innamespace Barbatos.Wpf.Tray; power inBarbatos.Wpf.Power, hotkeys inBarbatos.Wpf.Input. Copy the neighbouring files' namespace, never derive it from the path. Core'sRootNamespaceisBarbatos.Wpf. - Each opt-in Core feature's
ConfigureXxxlives in apublic static partial class AppHostBuilderExtensionsinnamespace Barbatos.Wpf.Hosting, in a file namedAppHostBuilderExtensions.csinside the feature folder.ConfigureLifecycleEvents(WpfAppHostBuilderExtensions,Barbatos.Wpf.LifecycleEvents) andConfigureEssentials(EssentialsExtensions) are the exceptions; do not copy their shape. - Aquarius / Router / Valee namespaces match folders (
Barbatos.Wpf.AquariusRouter.Matching). A new namespace that holds XAML-usable types needs anXmlnsDefinitionline (below). - Public interface,
internal sealedimplementation, reached through DI:ITrayIconService/TrayIconService,ITrayIconPlatform/Win32TrayIconPlatform. Tests resolve the interface from the container, so the implementation never has to be public.
DI entry points
Core: ConfigureXxx(this WpfAppBuilder)
Adapted from Essentials/KeepAwake/AppHostBuilderExtensions.cs:
namespace Barbatos.Wpf.Hosting;
public static partial class AppHostBuilderExtensions
{
/// <summary>... configured from code via <paramref name="configure"/> and/or from the
/// <c>Barbatos:Xxx</c> configuration section (configuration values override code values) ...</summary>
public static WpfAppBuilder ConfigureXxx(this WpfAppBuilder builder, Action<XxxOptions>? configure = null)
{
var options = builder.Services.AddOptions<XxxOptions>();
if (configure != null)
options.Configure(configure);
options.Bind(builder.Configuration.GetSection(XxxOptions.SectionName));
builder.Services.TryAddSingleton<IXxxPlatform, Win32XxxPlatform>();
builder.Services.TryAddSingleton<IXxxService, XxxService>();
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IWpfInitializeService, XxxInitializer>());
return builder;
}
class XxxInitializer : IWpfInitializeService
{
public void Initialize(IServiceProvider services)
{
if (services.GetRequiredService<IXxxService>() is XxxService service)
service.ApplyOptions();
}
}
}
XxxOptionscarriespublic const string SectionName = "Barbatos:Xxx";.ConfigurebeforeBind. Options run in registration order, so binding last is what makes file values beat code values.RunOnStartupFeatureTests.ConfigurationOverridesCodeValuespins it.- Everything
TryAdd*, so an app (or a test) that registers its own platform or service before callingConfigureXxxkeeps it, and a secondConfigureXxxcall registers no service twice.TryAddEnumerablededupes the initializer by implementation type. - OS interop goes behind a public
IXxxPlatformso the service runs headless in tests (ITrayIconPlatform,INotificationPlatform,IHotkeyPlatform,IStartupRegistrar,IPowerManager; fakes inFeatureTestFakes.cs). - OS side effects start in the initializer's
internal ApplyOptions(), not in a constructor. Theis XxxServicecheck means an app's replacement service is left alone. - An initializer must not throw for a recoverable failure.
IWpfInitializeService.Initializeruns synchronously insideBuild()with no surrounding try/catch, so an exception crashes app startup.McpInitializer(inEssentials/Mcp/AppHostBuilderExtensions.cs) fires each server connection and logs failures instead.
A new Essentials service follows a different shape (interface + static facade with a lazy
Current/Default + a TryAddSingleton<IXxx>(sp => Xxx.Default) line in UseEssentials, and a
warm-up line in the Core tests' ModuleInit.cs); load barbatos-wpf-essentials for it.
Aquarius / Router / Valee: AddXxx declares, UseXxx wires
builder.Services.AddAquariusRouter(sp => AppRoutes.Build(sp.GetRequiredService<AuthState>()));
var app = builder.Build();
app.Services.UseAquarius();
app.Services.UseAquariusRouter();
(from samples/Barbatos.Wpf.AquariusRouter.Sample/WpfProgram.cs)
- XAML-driven resolution (
aq:Setup.Enable,Component.Is,RouterView) has no constructor to inject into, so it reads an ambient static (Setup.ServiceProvider,Router.Current).UseXxxis the one place that assigns it, once, afterBuild(). AddXxxonly declares: no resolving, no statics. It must be safe to omit, andUseXxxmust work without it (UseAquariusStillWiresTheProviderWhenAddAquariusWasNeverCalled).UseXxxreturns the provider when the next call chains (UseAquarius) or the object the next line needs (UseAquariusRouterreturns theRouterforStart).- Take a factory (
Func<IServiceProvider, ...>) whenever the registration needs DI-resolved input, asAddAquariusRouter'sroutesparameter does. - Two static classes per package,
XxxServiceCollectionExtensionsandXxxServiceProviderExtensions, in one file;ArgumentNullException.ThrowIfNullon entry. Aquarius and Router have them; Valee has no DI entry point yet. - Their only
Microsoft.Extensionsreferences areDependencyInjection.AbstractionsandOptions(Valee gets both through Aquarius), besideCommunityToolkit.Mvvm. Never build or own a container; adding any package (a configuration binder included) needs the maintainer's yes. Nothing in them bindsAquariusOptions.SectionNameorRouterOptions.SectionName: theAddAquarius/AddAquariusRouterdocs say options come from code, and show the one-lineAddOptions<…>().Bind(…)an app adds itself. A newAddXxxdocuments the same way. - Tests that call
UseXxxmust save and restore the statics it writes (WithRestoredStatics,WithRestoredCurrentin the two DI test files).
One construction path: Setup.Resolve
Anything the Aquarius family builds on the app's behalf (a View, a ViewModel, a routed window)
goes through Setup.Resolve(Type): Setup.ServiceProvider?.GetService(type), else
Activator.CreateInstance(type). Component, Router and RouterWindows all call it. Do not
add a second path. An unregistered type therefore needs a public parameterless constructor.
Type design
| Convention | Precedent in src/ |
|---|---|
sealed for a new type |
AquariusOptions, RouterOptions, RouteRecord, PeriodicSchedule, InterceptorHandle |
| Never seal a shipped unsealed type - that is breaking | Core's *Options, the MCP descriptors, controls such as SlotHost (subclassed for its protected members) |
Apsu stores non-sealed with virtual [Action] methods |
StoreBase - Castle DynamicProxy subclasses them |
| Reference type when identity is the meaning | RouteRecord ("A deliberate reference type (not a record)") |
sealed record for resolved, compared-by-value data |
RouteLocation, PathToken, NavigationFailure, RouteSearchResult |
required init members for shapes built in code |
RouteRecord.Path, RouteLocation.Path, PushNotification.Title |
Settable, nullable members + Validate() for shapes bound from configuration |
McpServerDescriptor.Name is string?, enforced by Validate() |
Pure Validate(), throws InvalidOperationException, no I/O - a settings UI's pre-flight |
PeriodicSchedule, McpServerDescriptor, McpOAuthOptions, AiProviderDescriptor, AiAgentDescriptor |
ArgumentException for a duplicate name, InvalidOperationException for an invalid one |
IPeriodicServiceScheduler.Register, IMcpServerRegistry.AddServerAsync |
| A registration returns its undo | Router.BeforeEach and Watch.On return IDisposable; AddStoreInterceptor(..., out InterceptorHandle) |
Strictness is opt-in, default false |
Setup.ThrowOnUnresolved, Expr.ThrowOnUnresolvedIdentifiers |
| An attached property throws on the wrong element type | Directives.Model, Valee.Numeric, TeleportHost.RegisterHost, RouterLink.To |
| Generic type parameters that get constructed are annotated, not suppressed | [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] on ConfigurePeriodicServices<TService>, AddStore<TStore>, AddStoreInterceptor<TStore, TInterceptor> |
XML docs on every public type and member (CS1591 is a warning for src/**.cs). <remarks>
records the decision, what it was chosen over, and what was verified by running it.
XAML namespaces
Each Aquarius-family package maps every CLR namespace onto one URI in its AssemblyInfo.cs:
[assembly: XmlnsDefinition("http://schemas.barbatos.co/aquariusvalee/2026/xaml", "Barbatos.Wpf.AquariusValee.Xaml")]
[assembly: XmlnsPrefix("http://schemas.barbatos.co/aquariusvalee/2026/xaml", "aqv")]
- A new CLR namespace gets its own
XmlnsDefinitionline;XmlnsPrefixstays one per URI. Without it XAML reports the tag as missing from theaq:/aqr:/aqv:namespace. - The URIs (including
2026) are public API baked into every consumer's XAML. Never change one. - Core has no URI mapping: XAML reaches it with
clr-namespace:, as the template's and samples'App.xamldo withxmlns:hosting="clr-namespace:Barbatos.Wpf.Hosting;assembly=Barbatos.Wpf.Core".
C# 14 on three runtimes
LangVersion is 14.0 (from Directory.Build.props) but every library compiles for net8,
net9 and net10. Compiler-only features (field, extension(...) blocks, collection
expressions, primary constructors, required, list patterns, obj?.Prop = value) work on all
three. Anything whose type ships in the runtime needs #if NET9_0_OR_GREATER /
#if NET10_0_OR_GREATER or is not used. src/ currently has no such guard: locking uses
readonly object _fileLock = new(); + lock (_fileLock) (PreferencesImplementation.cs), not
System.Threading.Lock. A build only proves this if all three TFMs compiled.
Removing, renaming and other breaks
- Rename = keep the old member,
[Obsolete], returning the identical value.IAppInfo.AppGuidis a default interface member (string AppGuid => AppId;) so existing implementers keep compiling; the static facade'sAppInfo.AppGuidis obsoleted too; the oldBarbatos.Wpf.ApplicationModel.AppInfo.AppGuidmetadata key is still read as a fallback; the message names the replacement and the reason. Docs keep the old member, marked deprecated. - Deleting public API happens only in a deliberate major release.
- Adding a member to a public interface an app may implement (
IRouterHistory,IAiApiKeyProvider,IMcpTokenStore, the platform interfaces) breaks every implementer. Give it a default implementation, asAppGuiddoes, or add a new interface. - Changing a default value, an exception type, or when a callback fires is a behaviour break:
say so in the docs (
barbatos-docs-sync).
No InternalsVisibleTo
Per CLAUDE.md, never. When a test needs internal behaviour, that behaviour becomes real public
API with its own use case (RouteMatcher is public as "a real, independently useful, directly
unit-testable" type; Expr.Evaluate is the C#-callable counterpart of {aq:Expr}), or the test
goes through the public surface: DI-resolved interfaces, or a test subclass that exposes
protected members (TestSlotHost in SlotHostTests.cs). Weigh that before making something
internal in the first place.
Project files, dependencies, packing
Detail and the reasons: build-and-release.md. The rules that bite:
- A
src/project file carries only its identity:PackageId,TargetFrameworks,GeneratePackageOnBuild,UseWPF,CommonTags,Description,RootNamespace,PackageIconSourceFile, a commentedNoWarn, and its references. Never setLangVersion,Nullable,ImplicitUsingsor a version there. - Core uses
net10.0-windows10.0.17763.0;net9.0-windows10.0.17763.0;net8.0-windows10.0.17763.0(WinRT projections for toasts); the other three use barenetX.0-windows. Per CLAUDE.md, never add anetstandard/net6/net7target or a shim for one. - A non-Core package overrides
CommonTagswholesale; the shared value is Core's tag list, whose words would mislead NuGet search for any other package. - Versions only in
Directory.Packages.props.Microsoft.Extensions.AI*moves with the floorModelContextProtocol.Coredepends on (the comment above them names it). - Every new
NoWarncode gets a comment naming the exact reflection call and why it is inherently dynamic. WPF is never trimmed, but the analyzers run on every project. - A project whose name contains
Testis a test project (Directory.Build.propsdecides fromMSBuildProjectName): not packable, not packed, not signed. Never name a package so.
Versioning and release
- One
<Version>(and a matching numeric<AssemblyVersion>) inDirectory.Build.propsversions all four packages together. Thebarbatos-wpf-apptemplate repeats it:template.json(replacesanddefaultValue), the fourVersion="..."attributes inBarbatosWpfApp.csproj, and the default quoted intemplates/README.md. Move them in the same commit (barbatos-templates). - Publishing a GitHub Release (or running the workflow by hand) builds
Releasewith-p:SourceLinkEnabled=true, which also strong-names the libraries withsrc/barbatos.snkwritten from theSTRONG_NAME_KEYsecret, then pushes.nupkg+.snupkgwith--skip-duplicate. The release tag is never read: the version is whateverDirectory.Build.propssays on that commit, and an unchanged version publishes nothing while the run still goes green. - The release workflow runs no tests.
barbatos-wpf-ci.ymlruns every project per TFM on pull requests andmain; release only from a commit whose CI run is green (barbatos-wpf-testing). *.snkis git-ignored; never force-add a key or weaken that entry.
Symptom -> cause -> fix
| Symptom | Cause | Fix |
|---|---|---|
| Release workflow green, nuget.org still on the old version | --skip-duplicate skipped a version that already exists |
Bump <Version>/<AssemblyVersion> and the template copies, release again |
| Workflow fails in "Fetch the certificate" | STRONG_NAME_KEY secret missing, empty or not base64 |
Set it (base64 of the .snk) where the production job can read it |
NuGet/login step fails after a workflow edit |
Trusted publishing on nuget.org is bound to this workflow file (and its environment) | Keep the file name and environment: production, or update the nuget.org policy |
Local dotnet build -c Release -p:SourceLinkEnabled=true fails on the key file |
That flag turns on signing, and src/barbatos.snk exists only in CI |
Build without the flag locally; signing is CI's job |
Signed CI build warns CS8002, local builds never do |
A dependency is not strong-named; only test projects suppress CS8002, and only CI signs |
Check a new package is strong-named before adding it |
Restore NU1605 after bumping Microsoft.Extensions.AI* |
Out of step with ModelContextProtocol.Core's floor |
Bump both together |
NU1008 |
Version= on a PackageReference |
Move the version to Directory.Packages.props |
NU1010 |
PackageReference with no PackageVersion entry |
Add the PackageVersion line |
| Restore cannot find a package from another feed | nuget.config maps every pattern to nuget.org |
Add the source and a packageSourceMapping entry |
An app on bare net10.0-windows cannot restore Core (NU1201) |
Core targets the SDK-suffixed TFM | Consumers use net10.0-windows10.0.17763.0; do not add a bare TFM to Core |
A new control is "not found" in the aq: namespace |
Its CLR namespace has no XmlnsDefinition |
Add the line in that package's AssemblyInfo.cs |
| An app's replacement service is ignored, or both implementations end up registered | Add* instead of TryAdd*, or the app registered after ConfigureXxx |
TryAdd*; document "register yours first" |
Code values beat appsettings.json |
Bind ran before Configure |
Configure, then Bind |
The app crashes inside Build() |
An IWpfInitializeService threw |
Catch and log recoverable failures in the initializer |
A Barbatos:Aquarius / Barbatos:AquariusRouter value has no effect |
Nothing in the package binds those sections | The app binds them itself after AddAquarius/AddAquariusRouter (services.AddOptions<AquariusOptions>().Bind(configuration.GetSection(AquariusOptions.SectionName))); adding binding to the package is a dependency change - ask |
| New package never reaches nuget.org | Not under the /src/ folder of Barbatos.Wpf.slnx, or its name contains Test |
Add it to the solution's /src/ folder; rename |
IDE0073 error in the IDE (the build does not enforce code style, so CI never catches it) |
Missing or altered MIT header | Copy the header from a neighbouring file |
Checklist: a public API change
- Three questions answered; the owning package and namespace chosen as above.
- Reachable through DI (
ConfigureXxx, orAddXxx/UseXxx); replaceable piecesTryAdd*. - Type design follows the table; no
InternalsVisibleTo; renames keep an[Obsolete]twin. - XML docs with
<remarks>;XmlnsDefinitionfor any new XAML namespace. - Builds for all three TFMs with no new warnings; any new
NoWarncommented. - Tests added and run per TFM (
barbatos-wpf-testing). src/<Package>/README.mdandAPI-REFERENCE.mdupdated; rootREADME.mdif a summary, the package list or the templates changed; rootAPI-REFERENCE.mdif a namespace was added or removed (barbatos-docs-sync).- The package's sample demonstrates it; a template updated if the scaffolded shape changed
(
barbatos-templates). grep -rn "<member or option name>" .claude/and correct every skill claim (signature, default, thrown exception, ordering) the change made false; thennode .claude/evals/lint.mjs. A new control, attached property or directive also follows theLoaded-guard and leak/XAML test rules in.claude/rules/library-code.md.
New package or release: the checklists in build-and-release.md.
Related skills
barbatos-wpf-testing- STA helpers, leak probes, per-TFM runs.barbatos-docs-sync- README / API-REFERENCE after the change.barbatos-templates- the version copies and template symbols.- Domain knowledge for the code you are changing:
barbatos-wpf-host,barbatos-wpf-essentials,barbatos-aquarius-view,barbatos-aquarius-router,barbatos-valee-form,barbatos-apsu-store,barbatos-mcp-ai.