-
Notifications
You must be signed in to change notification settings - Fork 267
Adds extension metadata capability #6496
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This pull request adds metadata command capability to the Azure Developer CLI extension framework. Extensions can now expose their command structure, configuration schemas, and documentation in machine-readable JSON format through a metadata command. This enables AI agents and tooling to dynamically discover and understand extension capabilities.
Changes:
- Added metadata structures (
ExtensionCommandMetadata,Command,Flag, etc.) to represent extension capabilities - Implemented metadata generation helper (
GenerateExtensionMetadata) that automatically extracts metadata from Cobra commands - Extended extension Manager to fetch, cache, and manage metadata during installation
- Added schema validation utilities for configuration validation using JSON Schema
- Updated demo extension to demonstrate metadata capability with configuration schemas
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| cli/azd/pkg/extensions/metadata.go | Core metadata type definitions for commands, flags, arguments, and configuration schemas |
| cli/azd/pkg/extensions/schema_validator.go | JSON Schema compilation and validation utilities |
| cli/azd/pkg/extensions/metadata_test.go | Comprehensive tests for metadata marshaling and structure |
| cli/azd/pkg/extensions/schema_validator_test.go | Tests for schema validation including edge cases |
| cli/azd/pkg/extensions/registry.go | Added MetadataCapability constant to capability types |
| cli/azd/pkg/extensions/manager.go | Metadata fetching, caching, loading, and deletion functionality |
| cli/azd/pkg/extensions/manager_test.go | Tests for metadata management operations |
| cli/azd/pkg/azdext/metadata_generator.go | Helper to automatically generate metadata from Cobra commands |
| cli/azd/pkg/azdext/metadata_generator_test.go | Tests for metadata generation from Cobra commands |
| cli/azd/cmd/container.go | Lazy runner registration to avoid circular dependencies |
| cli/azd/extensions/microsoft.azd.demo/internal/cmd/metadata.go | Demo implementation of metadata command |
| cli/azd/extensions/microsoft.azd.demo/internal/config/types.go | Example configuration types with JSON Schema tags |
| cli/azd/extensions/microsoft.azd.demo/internal/cmd/root.go | Added metadata command to demo extension |
| cli/azd/extensions/microsoft.azd.demo/extension.yaml | Added metadata capability to demo extension |
| cli/azd/extensions/microsoft.azd.demo/README.md | Documentation for metadata capability and usage examples |
| cli/azd/extensions/extension.schema.json | Updated schema to include metadata capability |
Comments suppressed due to low confidence (3)
cli/azd/pkg/extensions/manager_test.go:1077
- There's no test coverage for the scenario where the metadata command returns a non-zero exit code (line 845-846 in manager.go). Consider adding a test case that verifies the error handling when the extension's metadata command fails.
func Test_FetchAndCacheMetadata(t *testing.T) {
mockContext := mocks.NewMockContext(context.Background())
createRegistryMocks(mockContext)
userConfigManager := config.NewUserConfigManager(mockContext.ConfigManager)
sourceManager := NewSourceManager(mockContext.Container, userConfigManager, mockContext.HttpClient)
// Create a temporary directory for test extensions
tempDir := t.TempDir()
extDir := filepath.Join(tempDir, "extensions", "test.metadata.extension")
err := os.MkdirAll(extDir, os.ModePerm)
require.NoError(t, err)
// Create a dummy extension binary
extBinary := filepath.Join(extDir, "test-ext.exe")
err = os.WriteFile(extBinary, []byte("fake binary"), 0600)
require.NoError(t, err)
// Get relative path from temp dir
relPath, err := filepath.Rel(tempDir, extBinary)
require.NoError(t, err)
// Override user config directory for this test
t.Setenv("AZD_CONFIG_DIR", tempDir)
// Create a mock extension that supports metadata capability
extension := &Extension{
Id: "test.metadata.extension",
Namespace: "test",
DisplayName: "Test Metadata Extension",
Version: "1.0.0",
Path: relPath,
Capabilities: []CapabilityType{MetadataCapability},
}
// Mock the runner to return metadata JSON
mockMetadata := ExtensionCommandMetadata{
SchemaVersion: "1.0",
ID: "test.metadata.extension",
Commands: []Command{
{
Name: []string{"test"},
Short: "Test command",
Usage: "azd test",
},
},
}
metadataJSON, err := json.Marshal(mockMetadata)
require.NoError(t, err)
// Mock CommandRunner to return metadata JSON
mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool {
return strings.Contains(command, "metadata")
}).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) {
return exec.RunResult{
Stdout: string(metadataJSON),
ExitCode: 0,
}, nil
})
lazyRunner := lazy.NewLazy(func() (*Runner, error) {
return NewRunner(mockContext.CommandRunner), nil
})
manager, err := NewManager(userConfigManager, sourceManager, lazyRunner, mockContext.HttpClient)
require.NoError(t, err)
t.Run("fetch metadata for extension with metadata capability", func(t *testing.T) {
// Install extension first (simulate by adding to config)
extensions, err := manager.ListInstalled()
require.NoError(t, err)
extensions[extension.Id] = extension
err = manager.userConfig.Set(installedConfigKey, extensions)
require.NoError(t, err)
// Fetch and cache metadata
err = manager.fetchAndCacheMetadata(*mockContext.Context, extension)
require.NoError(t, err)
// Verify metadata exists
exists := manager.MetadataExists(extension.Id)
require.True(t, exists, "Metadata should exist after fetch")
// Load and verify metadata
loadedMetadata, err := manager.LoadMetadata(extension.Id)
require.NoError(t, err)
require.NotNil(t, loadedMetadata)
require.Equal(t, mockMetadata.ID, loadedMetadata.ID)
require.Len(t, loadedMetadata.Commands, 1)
require.Equal(t, "test", loadedMetadata.Commands[0].Name[0])
})
t.Run("skip metadata fetch for extension without metadata capability", func(t *testing.T) {
extensionNoMetadata := &Extension{
Id: "test.no.metadata",
Namespace: "test",
DisplayName: "Test No Metadata Extension",
Version: "1.0.0",
Path: "extensions/test.no.metadata/test-ext.exe",
Capabilities: []CapabilityType{}, // No metadata capability
}
// Should not error even though extension doesn't support metadata
err := manager.fetchAndCacheMetadata(*mockContext.Context, extensionNoMetadata)
require.NoError(t, err)
// Metadata should not exist
exists := manager.MetadataExists(extensionNoMetadata.Id)
require.False(t, exists, "Metadata should not exist for extension without capability")
})
t.Run("delete metadata", func(t *testing.T) {
// Ensure metadata exists first
exists := manager.MetadataExists(extension.Id)
require.True(t, exists)
// Delete metadata
err := manager.DeleteMetadata(extension.Id)
require.NoError(t, err)
// Verify metadata no longer exists
exists = manager.MetadataExists(extension.Id)
require.False(t, exists, "Metadata should not exist after deletion")
})
t.Run("load non-existent metadata returns error", func(t *testing.T) {
_, err := manager.LoadMetadata("non.existent.extension")
require.Error(t, err)
require.Contains(t, err.Error(), "metadata not found")
})
}
cli/azd/pkg/extensions/manager_test.go:1077
- There's no test coverage for the scenario where the metadata JSON parsing fails (line 851-852 in manager.go). Consider adding a test case that verifies error handling when the extension returns malformed JSON.
func Test_FetchAndCacheMetadata(t *testing.T) {
mockContext := mocks.NewMockContext(context.Background())
createRegistryMocks(mockContext)
userConfigManager := config.NewUserConfigManager(mockContext.ConfigManager)
sourceManager := NewSourceManager(mockContext.Container, userConfigManager, mockContext.HttpClient)
// Create a temporary directory for test extensions
tempDir := t.TempDir()
extDir := filepath.Join(tempDir, "extensions", "test.metadata.extension")
err := os.MkdirAll(extDir, os.ModePerm)
require.NoError(t, err)
// Create a dummy extension binary
extBinary := filepath.Join(extDir, "test-ext.exe")
err = os.WriteFile(extBinary, []byte("fake binary"), 0600)
require.NoError(t, err)
// Get relative path from temp dir
relPath, err := filepath.Rel(tempDir, extBinary)
require.NoError(t, err)
// Override user config directory for this test
t.Setenv("AZD_CONFIG_DIR", tempDir)
// Create a mock extension that supports metadata capability
extension := &Extension{
Id: "test.metadata.extension",
Namespace: "test",
DisplayName: "Test Metadata Extension",
Version: "1.0.0",
Path: relPath,
Capabilities: []CapabilityType{MetadataCapability},
}
// Mock the runner to return metadata JSON
mockMetadata := ExtensionCommandMetadata{
SchemaVersion: "1.0",
ID: "test.metadata.extension",
Commands: []Command{
{
Name: []string{"test"},
Short: "Test command",
Usage: "azd test",
},
},
}
metadataJSON, err := json.Marshal(mockMetadata)
require.NoError(t, err)
// Mock CommandRunner to return metadata JSON
mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool {
return strings.Contains(command, "metadata")
}).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) {
return exec.RunResult{
Stdout: string(metadataJSON),
ExitCode: 0,
}, nil
})
lazyRunner := lazy.NewLazy(func() (*Runner, error) {
return NewRunner(mockContext.CommandRunner), nil
})
manager, err := NewManager(userConfigManager, sourceManager, lazyRunner, mockContext.HttpClient)
require.NoError(t, err)
t.Run("fetch metadata for extension with metadata capability", func(t *testing.T) {
// Install extension first (simulate by adding to config)
extensions, err := manager.ListInstalled()
require.NoError(t, err)
extensions[extension.Id] = extension
err = manager.userConfig.Set(installedConfigKey, extensions)
require.NoError(t, err)
// Fetch and cache metadata
err = manager.fetchAndCacheMetadata(*mockContext.Context, extension)
require.NoError(t, err)
// Verify metadata exists
exists := manager.MetadataExists(extension.Id)
require.True(t, exists, "Metadata should exist after fetch")
// Load and verify metadata
loadedMetadata, err := manager.LoadMetadata(extension.Id)
require.NoError(t, err)
require.NotNil(t, loadedMetadata)
require.Equal(t, mockMetadata.ID, loadedMetadata.ID)
require.Len(t, loadedMetadata.Commands, 1)
require.Equal(t, "test", loadedMetadata.Commands[0].Name[0])
})
t.Run("skip metadata fetch for extension without metadata capability", func(t *testing.T) {
extensionNoMetadata := &Extension{
Id: "test.no.metadata",
Namespace: "test",
DisplayName: "Test No Metadata Extension",
Version: "1.0.0",
Path: "extensions/test.no.metadata/test-ext.exe",
Capabilities: []CapabilityType{}, // No metadata capability
}
// Should not error even though extension doesn't support metadata
err := manager.fetchAndCacheMetadata(*mockContext.Context, extensionNoMetadata)
require.NoError(t, err)
// Metadata should not exist
exists := manager.MetadataExists(extensionNoMetadata.Id)
require.False(t, exists, "Metadata should not exist for extension without capability")
})
t.Run("delete metadata", func(t *testing.T) {
// Ensure metadata exists first
exists := manager.MetadataExists(extension.Id)
require.True(t, exists)
// Delete metadata
err := manager.DeleteMetadata(extension.Id)
require.NoError(t, err)
// Verify metadata no longer exists
exists = manager.MetadataExists(extension.Id)
require.False(t, exists, "Metadata should not exist after deletion")
})
t.Run("load non-existent metadata returns error", func(t *testing.T) {
_, err := manager.LoadMetadata("non.existent.extension")
require.Error(t, err)
require.Contains(t, err.Error(), "metadata not found")
})
}
cli/azd/pkg/extensions/manager_test.go:1077
- There's no test coverage for the scenario where the metadata ID doesn't match the extension ID (lines 856-862 in manager.go). Consider adding a test case to verify this validation works correctly.
func Test_FetchAndCacheMetadata(t *testing.T) {
mockContext := mocks.NewMockContext(context.Background())
createRegistryMocks(mockContext)
userConfigManager := config.NewUserConfigManager(mockContext.ConfigManager)
sourceManager := NewSourceManager(mockContext.Container, userConfigManager, mockContext.HttpClient)
// Create a temporary directory for test extensions
tempDir := t.TempDir()
extDir := filepath.Join(tempDir, "extensions", "test.metadata.extension")
err := os.MkdirAll(extDir, os.ModePerm)
require.NoError(t, err)
// Create a dummy extension binary
extBinary := filepath.Join(extDir, "test-ext.exe")
err = os.WriteFile(extBinary, []byte("fake binary"), 0600)
require.NoError(t, err)
// Get relative path from temp dir
relPath, err := filepath.Rel(tempDir, extBinary)
require.NoError(t, err)
// Override user config directory for this test
t.Setenv("AZD_CONFIG_DIR", tempDir)
// Create a mock extension that supports metadata capability
extension := &Extension{
Id: "test.metadata.extension",
Namespace: "test",
DisplayName: "Test Metadata Extension",
Version: "1.0.0",
Path: relPath,
Capabilities: []CapabilityType{MetadataCapability},
}
// Mock the runner to return metadata JSON
mockMetadata := ExtensionCommandMetadata{
SchemaVersion: "1.0",
ID: "test.metadata.extension",
Commands: []Command{
{
Name: []string{"test"},
Short: "Test command",
Usage: "azd test",
},
},
}
metadataJSON, err := json.Marshal(mockMetadata)
require.NoError(t, err)
// Mock CommandRunner to return metadata JSON
mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool {
return strings.Contains(command, "metadata")
}).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) {
return exec.RunResult{
Stdout: string(metadataJSON),
ExitCode: 0,
}, nil
})
lazyRunner := lazy.NewLazy(func() (*Runner, error) {
return NewRunner(mockContext.CommandRunner), nil
})
manager, err := NewManager(userConfigManager, sourceManager, lazyRunner, mockContext.HttpClient)
require.NoError(t, err)
t.Run("fetch metadata for extension with metadata capability", func(t *testing.T) {
// Install extension first (simulate by adding to config)
extensions, err := manager.ListInstalled()
require.NoError(t, err)
extensions[extension.Id] = extension
err = manager.userConfig.Set(installedConfigKey, extensions)
require.NoError(t, err)
// Fetch and cache metadata
err = manager.fetchAndCacheMetadata(*mockContext.Context, extension)
require.NoError(t, err)
// Verify metadata exists
exists := manager.MetadataExists(extension.Id)
require.True(t, exists, "Metadata should exist after fetch")
// Load and verify metadata
loadedMetadata, err := manager.LoadMetadata(extension.Id)
require.NoError(t, err)
require.NotNil(t, loadedMetadata)
require.Equal(t, mockMetadata.ID, loadedMetadata.ID)
require.Len(t, loadedMetadata.Commands, 1)
require.Equal(t, "test", loadedMetadata.Commands[0].Name[0])
})
t.Run("skip metadata fetch for extension without metadata capability", func(t *testing.T) {
extensionNoMetadata := &Extension{
Id: "test.no.metadata",
Namespace: "test",
DisplayName: "Test No Metadata Extension",
Version: "1.0.0",
Path: "extensions/test.no.metadata/test-ext.exe",
Capabilities: []CapabilityType{}, // No metadata capability
}
// Should not error even though extension doesn't support metadata
err := manager.fetchAndCacheMetadata(*mockContext.Context, extensionNoMetadata)
require.NoError(t, err)
// Metadata should not exist
exists := manager.MetadataExists(extensionNoMetadata.Id)
require.False(t, exists, "Metadata should not exist for extension without capability")
})
t.Run("delete metadata", func(t *testing.T) {
// Ensure metadata exists first
exists := manager.MetadataExists(extension.Id)
require.True(t, exists)
// Delete metadata
err := manager.DeleteMetadata(extension.Id)
require.NoError(t, err)
// Verify metadata no longer exists
exists = manager.MetadataExists(extension.Id)
require.False(t, exists, "Metadata should not exist after deletion")
})
t.Run("load non-existent metadata returns error", func(t *testing.T) {
_, err := manager.LoadMetadata("non.existent.extension")
require.Error(t, err)
require.Contains(t, err.Error(), "metadata not found")
})
}
Azure Dev CLI Install InstructionsInstall scriptsMacOS/Linux
bash: pwsh: WindowsPowerShell install MSI install Standalone Binary
MSI
Documentationlearn.microsoft.com documentationtitle: Azure Developer CLI reference
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The metadata generator seems to generate an empty null command:
Demo extension:
{
"schemaVersion": "1.0",
"id": "microsoft.azd.demo",
"commands": [
{
"name": null,
"short": "",
"usage": "azd ",
"hidden": true
},Agents extension:
{
"schemaVersion": "1.0",
"id": "microsoft.azd.demo",
"commands": [
{
"name": null,
"short": "",
"usage": "agent ",
"hidden": true
},| if !extension.HasCapability(MetadataCapability) { | ||
| return nil // Extension doesn't support metadata - this is fine | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should any of the other methods have capability checks too?
Extension Framework: Metadata Command Capability
Summary
Adds metadata command capability to the extension framework, enabling extensions to expose their command structure, configuration schemas, and documentation in a machine-readable JSON format. This enables AI agents and tooling to discover and understand extension capabilities dynamically.
Key Features
Metadata Command
metadatacommand that outputs structured JSONazdext.GenerateExtensionMetadata()helperArchitecture
MetadataCapabilitymarks extensions supporting metadata retrievalMetadata Schema
{ "schemaVersion": "1.0", "id": "extension.id", "commands": [...], "configuration": { "global": {...}, "project": {...}, "service": {...} } }Configuration Schemas
invopop/jsonschemagenerates schemas from Go types