feat: add winmd-api-search skill for Windows desktop API discovery#860
feat: add winmd-api-search skill for Windows desktop API discovery#860yeelam-gordon wants to merge 1 commit intogithub:stagedfrom
Conversation
Add a skill that helps find and explore Windows desktop APIs (WinRT/WinAppSDK). It searches a local WinMD metadata cache to discover APIs for platform capabilities like camera, file access, notifications, UI controls, AI/ML, sensors, and networking. Includes bundled scripts: - Update-WinMdCache.ps1: generates the JSON cache from SDK and NuGet packages - Invoke-WinMdQuery.ps1: searches types, members, enums, and namespaces - cache-generator: .NET tool that parses WinMD files into JSON Co-authored-by: Copilot <[email protected]>
There was a problem hiding this comment.
Pull request overview
Adds a new winmd-api-search skill to the skills catalog, enabling GitHub Copilot to discover Windows desktop APIs (WinRT / Windows App SDK) by querying a locally generated WinMD-to-JSON cache.
Changes:
- Introduces a standalone .NET cache generator to parse WinMD files from Windows SDK, NuGet packages, and project outputs into a deduplicated per-package JSON cache.
- Adds PowerShell scripts to generate the cache and query it (projects/packages/stats/namespaces/types/members/enums/search).
- Documents the skill usage in
SKILL.mdand registers it indocs/README.skills.md.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| skills/winmd-api-search/scripts/cache-generator/Program.cs | Implements WinMD parsing, package discovery, and per-package+version JSON cache generation. |
| skills/winmd-api-search/scripts/cache-generator/CacheGenerator.csproj | Defines the standalone generator project and its NuGet dependencies. |
| skills/winmd-api-search/scripts/cache-generator/Directory.Build.props | Isolates the generator from repo-level build props. |
| skills/winmd-api-search/scripts/cache-generator/Directory.Build.targets | Isolates the generator from repo-level build targets. |
| skills/winmd-api-search/scripts/cache-generator/Directory.Packages.props | Isolates the generator from repo-level central package management. |
| skills/winmd-api-search/scripts/Update-WinMdCache.ps1 | Builds/runs the generator to produce or refresh the WinMD JSON cache. |
| skills/winmd-api-search/scripts/Invoke-WinMdQuery.ps1 | Provides CLI-style querying over the generated cache (search, members, enums, etc.). |
| skills/winmd-api-search/SKILL.md | Skill documentation and usage guidance for generating and querying the cache. |
| skills/winmd-api-search/LICENSE.txt | Adds license text for the skill’s bundled assets. |
| docs/README.skills.md | Registers the new skill and lists bundled assets. |
| exit 1 | ||
| } | ||
|
|
||
| $ns = $FullName.Substring(0, $FullName.LastIndexOf('.')) |
There was a problem hiding this comment.
Same issue as members action: this assumes the enum type name contains '.', and will throw if an unqualified name is provided. Add a guard and emit a friendly error prompting for a fully-qualified type name.
| $ns = $FullName.Substring(0, $FullName.LastIndexOf('.')) | |
| $lastDot = $FullName.LastIndexOf('.') | |
| if ($lastDot -lt 0) { | |
| Write-Error "Please provide a fully-qualified type name (e.g. 'Namespace.TypeName') for -TypeName when using the 'enums' action." | |
| exit 1 | |
| } | |
| $ns = $FullName.Substring(0, $lastDot) |
| } | ||
|
|
||
| foreach (var lib in libraries.EnumerateObject()) | ||
| { |
There was a problem hiding this comment.
When parsing project.assets.json, the "libraries" section includes non-package entries (e.g., libraries where "type" is "project"). Add a check for the library "type" and only treat entries with type == "package" as NuGet packages to avoid unnecessary disk scans and misleading IDs/versions.
| { | |
| { | |
| // Only treat libraries with type == "package" as NuGet packages. | |
| if (!lib.Value.TryGetProperty("type", out var typeProp) || | |
| !string.Equals(typeProp.GetString(), "package", StringComparison.OrdinalIgnoreCase)) | |
| { | |
| continue; | |
| } |
|
|
||
| try | ||
| { | ||
| var propSig = prop.DecodeSignature(typeProvider, null); | ||
| var propType = propSig.ReturnType; | ||
| var accessors = prop.GetAccessors(); | ||
| var hasGetter = !accessors.Getter.IsNil; | ||
| var hasSetter = !accessors.Setter.IsNil; |
There was a problem hiding this comment.
Property parsing emits an entry even if both accessors are non-public. Consider checking the Getter/Setter method attributes and only including the property when at least one accessor method is public (consistent with method filtering).
| try | |
| { | |
| var propSig = prop.DecodeSignature(typeProvider, null); | |
| var propType = propSig.ReturnType; | |
| var accessors = prop.GetAccessors(); | |
| var hasGetter = !accessors.Getter.IsNil; | |
| var hasSetter = !accessors.Setter.IsNil; | |
| var accessors = prop.GetAccessors(); | |
| var hasGetter = !accessors.Getter.IsNil; | |
| var hasSetter = !accessors.Setter.IsNil; | |
| // Align property visibility with method filtering: | |
| // only include properties where at least one accessor is public. | |
| var getterIsPublic = hasGetter && | |
| (reader.GetMethodDefinition(accessors.Getter).Attributes & MethodAttributes.Public) != 0; | |
| var setterIsPublic = hasSetter && | |
| (reader.GetMethodDefinition(accessors.Setter).Attributes & MethodAttributes.Public) != 0; | |
| if (!getterIsPublic && !setterIsPublic) | |
| { | |
| continue; | |
| } | |
| try | |
| { | |
| var propSig = prop.DecodeSignature(typeProvider, null); | |
| var propType = propSig.ReturnType; |
| var projectsDir = Path.Combine(outputDir, "projects"); | ||
| Directory.CreateDirectory(projectsDir); | ||
| File.WriteAllText( | ||
| Path.Combine(projectsDir, $"{projectName}.json"), | ||
| JsonSerializer.Serialize(manifest, jsonOptions)); |
There was a problem hiding this comment.
Project manifests are written to "projects/.json" where projectName is just the file name without extension. In --scan mode, different projects with the same name in different directories will overwrite each other’s manifest. Consider including a unique suffix (e.g., relative path hash) in the manifest filename, while keeping ProjectName as a display field inside the JSON.
| var evt = reader.GetEventDefinition(eventHandle); | ||
| var evtName = reader.GetString(evt.Name); | ||
| var evtType = GetHandleTypeName(reader, evt.Type); | ||
|
|
||
| members.Add(new WinMdMemberInfo |
There was a problem hiding this comment.
Event parsing doesn’t currently validate accessor visibility. Consider checking the add/remove (and optionally raise) methods’ attributes and only including the event when at least one relevant accessor is public.
| $nsResults[$n] = @{ BestScore = 0; Types = @(); FilePath = $filePath } | ||
| } | ||
| $entry = $nsResults[$n] | ||
| if ($score -gt $entry.BestScore) { $entry.BestScore = $score } |
There was a problem hiding this comment.
Search groups results by namespace and stores a single FilePath for that namespace. If the same namespace appears in multiple packages, the stored FilePath may not contain the best-matching type(s) that contributed to the score. Consider tracking results per (package, namespace) or outputting multiple file paths with package id/version.
| $nsResults[$n] = @{ BestScore = 0; Types = @(); FilePath = $filePath } | |
| } | |
| $entry = $nsResults[$n] | |
| if ($score -gt $entry.BestScore) { $entry.BestScore = $score } | |
| # Initialize with no file path; it will be set when we record the first best score. | |
| $nsResults[$n] = @{ BestScore = 0; Types = @(); FilePath = $null } | |
| } | |
| $entry = $nsResults[$n] | |
| if ($score -gt $entry.BestScore) { | |
| # Update both the best score and the file path so FilePath always | |
| # corresponds to the highest-scoring match for this namespace. | |
| $entry.BestScore = $score | |
| $entry.FilePath = $filePath | |
| } |
| exit 1 | ||
| } | ||
|
|
||
| $ns = $FullName.Substring(0, $FullName.LastIndexOf('.')) |
There was a problem hiding this comment.
This assumes the type name contains at least one '.' (namespace separator). If a user passes an unqualified type name, LastIndexOf('.') returns -1 and Substring will throw. Handle the no-namespace case with a clear error message instead of throwing.
| $ns = $FullName.Substring(0, $FullName.LastIndexOf('.')) | |
| $nsSeparatorIndex = $FullName.LastIndexOf('.') | |
| if ($nsSeparatorIndex -lt 0) { | |
| Write-Error "Type name must be fully qualified with a namespace (expected at least one '.'): $FullName" | |
| exit 1 | |
| } | |
| $ns = $FullName.Substring(0, $nsSeparatorIndex) |
| .EXAMPLE | ||
| .\Update-WinMdCache.ps1 | ||
| .\Update-WinMdCache.ps1 -ProjectDir BlankWInUI |
There was a problem hiding this comment.
Example project name appears to have a typo: "BlankWInUI" should likely be "BlankWinUI" (capitalization). This is user-facing documentation in the comment help block.
| .\Update-WinMdCache.ps1 -ProjectDir BlankWInUI | |
| .\Update-WinMdCache.ps1 -ProjectDir BlankWinUI |
| <!-- System.Reflection.Metadata is inbox in net9.0+, only needed for net8.0 --> | ||
| <ItemGroup Condition="'$(TargetFramework)' == 'net8.0'"> | ||
| <PackageReference Include="System.Reflection.Metadata" Version="9.*" /> | ||
| </ItemGroup> | ||
|
|
||
| <!-- | ||
| Baseline WinAppSDK packages: downloaded during restore so the cache generator | ||
| can always index WinAppSDK APIs, even if the target project hasn't been restored. | ||
| ExcludeAssets="all" means they're downloaded but don't affect this tool's build. | ||
| --> | ||
| <ItemGroup> | ||
| <PackageReference Include="Microsoft.WindowsAppSDK" Version="*" ExcludeAssets="all" /> | ||
| </ItemGroup> |
There was a problem hiding this comment.
Avoid floating NuGet versions here (System.Reflection.Metadata "9." and Microsoft.WindowsAppSDK "") because it makes restores non-deterministic and can break over time as new versions are released. Pin to specific, known-good versions (and ideally align System.Reflection.Metadata to the chosen target framework) so cache generation is reproducible.
aaronpowell
left a comment
There was a problem hiding this comment.
Just a small nit on the docs links
| - [Windows Platform SDK API reference](https://learn.microsoft.com/en-us/uwp/api/) — documentation for `Windows.*` namespaces | ||
| - [Windows App SDK API reference](https://learn.microsoft.com/en-us/windows/windows-app-sdk/api/winrt/) — documentation for `Microsoft.*` WinAppSDK namespaces |
There was a problem hiding this comment.
| - [Windows Platform SDK API reference](https://learn.microsoft.com/en-us/uwp/api/) — documentation for `Windows.*` namespaces | |
| - [Windows App SDK API reference](https://learn.microsoft.com/en-us/windows/windows-app-sdk/api/winrt/) — documentation for `Microsoft.*` WinAppSDK namespaces | |
| - [Windows Platform SDK API reference](https://learn.microsoft.com/uwp/api/) — documentation for `Windows.*` namespaces | |
| - [Windows App SDK API reference](https://learn.microsoft.com/windows/windows-app-sdk/api/winrt/) — documentation for `Microsoft.*` WinAppSDK namespaces |
Minor nit in there to use the non-localised URLs.
What this adds
A new skill winmd-api-search that helps GitHub Copilot find and explore Windows desktop APIs (WinRT / WinAppSDK).
What it does
Bundled assets
Prerequisites
Checklist
pm run skill:create\
ame, \description)
pm run skill:validate\ passes
pm run build\ ran to update README tables