Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
"plugins": [
{
"name": "microsoft-finops-toolkit",
"version": "15.0-dev",
"source": "./src/templates/claude-plugin",
"description": "AI-powered cloud financial management for Azure. Analyze costs with KQL queries against FinOps hubs, get CFO-level reporting, and access Azure Cost Management insights.",
"version": "15.0.0",
Comment thread
MSBrett marked this conversation as resolved.
"source": "./plugins/microsoft-finops-toolkit",
"description": "AI-powered cloud financial management for Azure. Analyze FinOps hub cost data with KQL, get CFO-level reporting, and assess Azure capacity.",
"category": "finops",
"homepage": "https://learn.microsoft.com/en-us/cloud-computing/finops/toolkit/finops-toolkit-overview"
},
Expand Down
30 changes: 30 additions & 0 deletions .github/plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"name": "finops-toolkit",
"owner": {
"name": "Microsoft"
},
"metadata": {
"description": "Microsoft FinOps Toolkit plugins for AI-powered cloud financial management.",
"version": "15.0.0"
},
"plugins": [
{
"name": "microsoft-finops-toolkit",
Comment thread
MSBrett marked this conversation as resolved.
"version": "15.0.0",
"source": "./plugins/microsoft-finops-toolkit",
"description": "AI-powered cloud financial management for Azure. Analyze FinOps hub cost data with KQL, get CFO-level reporting, and assess Azure capacity.",
"category": "finops",
"homepage": "https://learn.microsoft.com/en-us/cloud-computing/finops/toolkit/finops-toolkit-overview"
},
{
Comment thread
MSBrett marked this conversation as resolved.
"name": "microsoft-learn",
"source": {
"source": "url",
"url": "https://github.com/microsoftdocs/mcp.git"
},
"description": "Access official Microsoft documentation, API references, and code samples for Azure, .NET, Windows, and more.",
"category": "documentation",
"homepage": "https://learn.microsoft.com"
}
]
}
1 change: 1 addition & 0 deletions .plugin
10 changes: 5 additions & 5 deletions docs-mslearn/toolkit/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: FinOps toolkit changelog
description: Review the latest features and enhancements in the FinOps toolkit, including updates to FinOps hubs, Power BI reports, and more.
author: MSBrett
ms.author: brettwil
ms.date: 07/30/2026
ms.date: 07/31/2026
ms.topic: reference
ms.service: finops
ms.subservice: finops-toolkit
Expand Down Expand Up @@ -46,12 +46,12 @@ _Released June 2026_
- **Fixed**
- Corrected stale and incorrect descriptions for `BilledCost`, `EffectiveCost`, `BillingCurrency`, `BillingProfileId`, `BillingProfileName`, `CommitmentDiscountQuantity`, `ListUnitPrice`, `PricingQuantity`, `PricingUnitDescription`, and `TotalSavingsRunningTotal` in the [data dictionary](help/data-dictionary.md) to align with FOCUS 1.2 ([#2112](https://github.com/microsoft/finops-toolkit/pull/2112)).

### Claude Code plugin v15
### FinOps Toolkit agent plugins v15

- **Added**
- Added Claude Code plugin with skills for FinOps hubs and Azure Cost Management ([#2043](https://github.com/microsoft/finops-toolkit/pull/2043)).
- Added 4 agents (CFO, FinOps practitioner, database query, hubs agent), 5 commands (`/ftk-hubs-connect`, `/ftk-hubs-healthCheck`, `/ftk-mom-report`, `/ftk-ytd-report`, `/ftk-cost-optimization`), and an output style.
- Linked to the existing KQL query catalog in `src/queries/` from the plugin.
- Added a shared FinOps Toolkit plugin for Claude Code and GitHub Copilot CLI with 5 agents (CFO, FinOps practitioner, database query, hubs agent, and Azure capacity manager), 4 commands (`/ftk/hubs-connect`, `/ftk/hubs-health-check`, `/ftk/mom-report`, and `/ftk/ytd-report`), a FinOps hubs KQL skill, and a read-only Azure MCP server ([#2043](https://github.com/microsoft/finops-toolkit/pull/2043), [#2167](https://github.com/microsoft/finops-toolkit/pull/2167)).
- Published marketplace entries and repository discovery so the plugin installs with `copilot plugin install microsoft/finops-toolkit`.
- This release includes only the FinOps Toolkit skill. Azure Cost Management and capacity-management skills will be added in subsequent PRs.

### [FinOps hubs](hubs/finops-hubs-overview.md) v15

Expand Down
1 change: 1 addition & 0 deletions plugins/microsoft-finops-toolkit
174 changes: 174 additions & 0 deletions src/powershell/Tests/Unit/AgentPlugins.Tests.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

BeforeAll {
$script:RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../../../..')).Path
$script:Plugin = Join-Path $script:RepoRoot 'src/templates/agent-plugin'
$script:PluginSource = './plugins/microsoft-finops-toolkit'
}

Describe 'Agent plugin manifest' {
It 'Ships a root plugin.json for GitHub Copilot CLI' {
Join-Path $script:Plugin 'plugin.json' | Should -Exist
}

It 'Ships a .claude-plugin/plugin.json for Claude' {
Join-Path $script:Plugin '.claude-plugin/plugin.json' | Should -Exist
}

It 'Uses a consistent plugin name across both manifests' {
$root = Get-Content (Join-Path $script:Plugin 'plugin.json') -Raw | ConvertFrom-Json
$claude = Get-Content (Join-Path $script:Plugin '.claude-plugin/plugin.json') -Raw | ConvertFrom-Json
$root.name | Should -Be $claude.name
}

It 'Uses consistent shared manifest metadata and intentional platform-specific fields' {
$root = Get-Content (Join-Path $script:Plugin 'plugin.json') -Raw | ConvertFrom-Json
$claude = Get-Content (Join-Path $script:Plugin '.claude-plugin/plugin.json') -Raw | ConvertFrom-Json

$root.description | Should -Be $claude.description
$root.version | Should -Be $claude.version
$root.license | Should -Be $claude.license
$root.repository | Should -Be $claude.repository
$root.commands.TrimStart('./') | Should -Be $claude.commands.TrimStart('./')
$root.skills[0].TrimStart('./') | Should -Be $claude.skills[0].TrimStart('./')
$root.agents | Should -Be './agents/'
$root.mcpServers | Should -Be '.mcp.json'
$claude.agents | Should -BeNullOrEmpty
$claude.mcpServers | Should -BeNullOrEmpty
$claude.outputStyles | Should -Be './output-styles/'
}

It 'Points the agents field at a directory that exists' {
$root = Get-Content (Join-Path $script:Plugin 'plugin.json') -Raw | ConvertFrom-Json
Join-Path $script:Plugin ($root.agents -replace '^\./', '') | Should -Exist
}

It 'Loads MCP servers from .mcp.json in the plugin root' {
$root = Get-Content (Join-Path $script:Plugin 'plugin.json') -Raw | ConvertFrom-Json
$claude = Get-Content (Join-Path $script:Plugin '.claude-plugin/plugin.json') -Raw | ConvertFrom-Json

$root.mcpServers | Should -Be '.mcp.json'
$claude.mcpServers | Should -BeNullOrEmpty
$claude.agents | Should -BeNullOrEmpty
Join-Path $script:Plugin '.mcp.json' | Should -Exist
}

It 'Uses unpinned Azure MCP latest package in .mcp.json' {
$mcp = Get-Content (Join-Path $script:Plugin '.mcp.json') -Raw | ConvertFrom-Json
$args = $mcp.mcpServers.'azure-mcp-server'.args
$packageArg = $args | Where-Object { $_ -like '@azure/mcp@*' } | Select-Object -First 1

$packageArg | Should -Not -BeNullOrEmpty
$packageArg | Should -Be '@azure/mcp@latest'
}
}

Describe 'Agent plugin components' {
It 'Ships agent definitions as NAME.agent.md files' {
$agents = Get-ChildItem (Join-Path $script:Plugin 'agents') -Filter '*.agent.md'
$agents.Count | Should -BeGreaterThan 0
}

It 'Gives every agent a name and description in front matter' {
Get-ChildItem (Join-Path $script:Plugin 'agents') -Filter '*.agent.md' | ForEach-Object {
$content = Get-Content $_.FullName -Raw
$content | Should -Match '(?ms)^---\s.*^name:\s*\S.*^description:\s*\S.*^---'
}
}

It 'Ships slash commands under the ftk namespace' {
$commands = Get-ChildItem (Join-Path $script:Plugin 'commands/ftk') -Filter '*.md'
$commands.Count | Should -BeGreaterThan 0
}

It 'Requires explicit invocation for commands that access hub data or write reports' {
foreach ($command in @('hubs-connect.md', 'hubs-health-check.md', 'mom-report.md', 'ytd-report.md'))
{
$content = Get-Content (Join-Path $script:Plugin "commands/ftk/$command") -Raw
$content | Should -Match '(?m)^disable-model-invocation:\s*true\s*$'
}
}

It 'Resolves linked template content from its full source path' {
$buildScript = Get-Content (Join-Path $script:RepoRoot 'src/scripts/Build-Toolkit.ps1') -Raw
$buildScript | Should -Match '\$target = Join-Path \(Split-Path -Path \$source\.FullName -Parent\) \$target'
}

It 'Uses the kebab-case health-check command name throughout the plugin' {
Join-Path $script:Plugin 'commands/ftk/hubs-health-check.md' | Should -Exist
Join-Path $script:Plugin 'skills/finops-toolkit/references/workflows/ftk-hubs-health-check.md' | Should -Exist

$content = (Get-ChildItem $script:Plugin -Recurse -File | Get-Content -Raw) -join [Environment]::NewLine
$content | Should -Not -Match 'hubs-healthCheck|ftk-hubs-healthCheck'
}

It 'Does not reference the deprecated Azure MCP command name' {
$content = (Get-ChildItem $script:Plugin -Recurse -File | Get-Content -Raw) -join [Environment]::NewLine
$content | Should -Not -Match '#?azmcp-kusto-query'
}

It 'Ships the finops-toolkit skill' {
Join-Path $script:Plugin 'skills/finops-toolkit/SKILL.md' | Should -Exist
}

It 'Documents required kusto_query parameters in skill docs' {
foreach ($doc in @('skills/finops-toolkit/SKILL.md', 'skills/finops-toolkit/README.md'))
{
$content = Get-Content (Join-Path $script:Plugin $doc) -Raw
$content | Should -Match 'azure-mcp-server'
$content | Should -Match 'kusto_query'
$content | Should -Match '"subscription"\s*:'
}
}

It 'Ships plugin root README for marketplace onboarding' {
Join-Path $script:Plugin 'README.md' | Should -Exist
}

It 'Bundles only Markdown Learn documentation' {
$bundle = Join-Path $TestDrive 'agent-plugin'
New-Item (Join-Path $bundle 'skills/finops-toolkit') -ItemType Directory -Force | Out-Null

& (Join-Path $script:RepoRoot 'src/scripts/Build-AgentPlugin.ps1') -DestDir $bundle

$docs = Get-ChildItem (Join-Path $bundle 'skills/finops-toolkit/references/docs-mslearn') -File -Recurse
$docs.Count | Should -BeGreaterThan 0
$docs.Extension | Select-Object -Unique | Should -Be @('.md')
}
}

Describe 'Deprecated azure-cost-management skill' {
It 'No longer ships the azure-cost-management skill' {
Join-Path $script:Plugin 'skills/azure-cost-management' | Should -Not -Exist
}

It 'Is not referenced by any plugin manifest' {
foreach ($manifest in @('plugin.json', '.claude-plugin/plugin.json'))
{
$content = Get-Content (Join-Path $script:Plugin $manifest) -Raw
$content | Should -Not -Match 'azure-cost-management'
}
}
}

Describe 'Plugin discovery and marketplaces' {
It 'Resolves the .plugin discovery pointer to a plugin manifest' {
$pluginRoot = Join-Path $script:RepoRoot '.plugin'
if (-not (Test-Path $pluginRoot -PathType Container))
{
$pluginRoot = Join-Path $script:RepoRoot (Get-Content $pluginRoot -Raw).Trim()
}

Join-Path $pluginRoot 'plugin.json' | Should -Exist
}

It 'Points every marketplace source at the plugin directory' {
foreach ($marketplace in @('.github/plugin/marketplace.json', '.claude-plugin/marketplace.json'))
{
$json = Get-Content (Join-Path $script:RepoRoot $marketplace) -Raw | ConvertFrom-Json
$entry = $json.plugins | Where-Object { $_.name -eq 'microsoft-finops-toolkit' }
$entry.source | Should -Be $script:PluginSource
}
}
}
55 changes: 55 additions & 0 deletions src/scripts/Build-AgentPlugin.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]
$DestDir
)

$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path
$skillsDir = Join-Path $DestDir 'skills'

function Remove-PluginBundle([string]$Path)
{
if (-not (Test-Path -LiteralPath $Path))
{
return
}

$item = Get-Item -LiteralPath $Path -Force
if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)
{
Remove-Item -LiteralPath $Path -Force -ErrorAction Stop
}
else
{
Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop
}
}

if (-not (Test-Path $skillsDir))
{
return
}

$finopsSkill = Join-Path $skillsDir 'finops-toolkit'
if (Test-Path $finopsSkill)
{
$queryDest = Join-Path $finopsSkill 'references/queries'
Remove-PluginBundle $queryDest
Copy-Item (Join-Path $repoRoot 'src/queries') -Destination $queryDest -Recurse -Force
Comment thread
MSBrett marked this conversation as resolved.

$docsDest = Join-Path $finopsSkill 'references/docs-mslearn'
Remove-PluginBundle $docsDest
$docsSource = Join-Path $repoRoot 'docs-mslearn'
Get-ChildItem $docsSource -File -Recurse -Filter '*.md' | ForEach-Object {
$relativePath = $_.FullName.Substring($docsSource.Length).TrimStart([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
$destination = Join-Path $docsDest $relativePath
New-Item (Split-Path $destination) -ItemType Directory -Force | Out-Null
Copy-Item $_ -Destination $destination -Force
}
}

Get-ChildItem $DestDir -Force -Recurse -Filter '.DS_Store' | Remove-Item -Force
36 changes: 33 additions & 3 deletions src/scripts/Build-Toolkit.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -193,14 +193,44 @@ $templates | ForEach-Object {
# Create target directory
$destDir = "$outdir/$templateName"
Write-Verbose " Creating target directory: $destDir"
Remove-Item $destDir -Recurse -ErrorAction SilentlyContinue
Remove-Item $destDir -Recurse -Force -ErrorAction SilentlyContinue
& "$PSScriptRoot/New-Directory.ps1" $destDir

# Copy required files
function Copy-TemplateItem($source, [string]$destination)
{
$itemName = $source.Name
if ($source.Attributes -band [IO.FileAttributes]::ReparsePoint)
{
$target = $source.Target
if (-not [IO.Path]::IsPathRooted($target))
{
$target = Join-Path (Split-Path -Path $source.FullName -Parent) $target
}

$source = Get-Item (Resolve-Path -LiteralPath $target -ErrorAction Stop)
}

if ($source.PSIsContainer)
{
$destination = Join-Path $destination $itemName
New-Item $destination -ItemType Directory -Force | Out-Null
Get-ChildItem $source -Force | ForEach-Object {
Copy-TemplateItem $_ $destination
}
}
else
{
Copy-Item $source -Destination (Join-Path $destination $itemName) -Force -ErrorAction Stop
}
}

# Copy required files, resolving nested symlinks into the package.
Write-Host " Copying files..."
$sourceFiles = Get-ChildItem $srcDir -Force | Where-Object { $_.Name -notin @(".build.config", ".buildignore", "scaffold.json") }
Write-Verbose " Copying $($sourceFiles.Count) items from source to destination"
$sourceFiles | Copy-Item -Destination $destDir -Recurse
$sourceFiles | ForEach-Object {
Copy-TemplateItem $_ $destDir
}

Get-ChildItem $destDir -Force -Recurse -Filter ".DS_Store" | Remove-Item -Force

Expand Down
Loading
Loading