Repository: microsoft/kiota (csharp)

Issue:
Add plugin node in the manifest.json file if it already exists and create the manifest.json when it doesn't
When creating a plugin, we should add a plugin node to the MOS `manifest.json` file if there is a `manifest.json` file in the output directory.
**Update:** ~If there is no `manifest.json` file, we should **not** create the file.~ And we should create the `manifest.json` when it does not exist in the output directory.

We need to generate or modify the `manifest.json` file for both the VS Code extension and CLI.

Plugins node  will be called “plugins” and it will be an array of plugins (currently only single plugin definition supported).

“id”: `plugin-name` provided by the user when creating the plugin. The id also needs to be unique in the array. if there is an existing id with the same name, the plugin-add should thrown an error. 

“file”: Local reference to the file in app package. Note: URLs to plugin file hosted on a remote server are not supported 

```json
"plugins": [
    {
        "id": {plugin-name}
        "file": "ai-plugin.json"
    }
]
```

Pull request:
Fixes https://github.com/microsoft/kiota/issues/4476

If a `manifest.json` file exists in the folder, the plugin node is added to the file. Otherwise, a default manifest.json is added to the folder. 

At the moment, this will be done when the plugin type selected is of `apiplugin` type.

Patch:
diff --git a/src/Kiota.Builder/Plugins/Models/AppManifestModel.cs b/src/Kiota.Builder/Plugins/Models/AppManifestModel.cs
new file mode 100644
index 0000000000..5ef0fae47a
--- /dev/null
+++ b/src/Kiota.Builder/Plugins/Models/AppManifestModel.cs
@@ -0,0 +1,160 @@
+﻿using System;
+using System.Collections.Generic;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Kiota.Builder.Plugins.Models;
+
+internal class AppManifestModel
+{
+    private const string DefaultSchema = "https://developer.microsoft.com/json-schemas/teams/vDevPreview/MicrosoftTeams.schema.json";
+    private const string DefaultManifestVersion = "devPreview";
+    private const string DefaultVersion = "1.0.0";
+
+    [JsonPropertyName("$schema")]
+    public string? Schema
+    {
+        get;
+        set;
+    } = DefaultSchema;
+
+    public string? ManifestVersion
+    {
+        get;
+        set;
+    } = DefaultManifestVersion;
+
+    public string? Version
+    {
+        get;
+        set;
+    } = DefaultVersion;
+    public string? Id
+    {
+        get; set;
+    }
+    public Developer? Developer
+    {
+        get; init;
+    }
+    public string? PackageName
+    {
+        get; set;
+    }
+    public Name? Name
+    {
+        get; set;
+    }
+    public Description? Description
+    {
+        get; set;
+    }
+    public Icons? Icons
+    {
+        get; set;
+    }
+    public string? AccentColor
+    {
+        get; set;
+    }
+    public CopilotExtensions? CopilotExtensions
+    {
+        get; set;
+    }
+
+    [JsonExtensionData]
+    public Dictionary<string, Object> AdditionalData { get; set; } = new();
+}
+
+[JsonSerializable(typeof(AppManifestModel))]
+[JsonSerializable(typeof(JsonElement))]
+internal partial class AppManifestModelGenerationContext : JsonSerializerContext
+{
+}
+
+#pragma warning disable CA1054
+internal class Developer
+#pragma warning restore CA1054
+{
+    public string? Name
+    {
+        get; set;
+    }
+#pragma warning disable CA1056
+    public string? WebsiteUrl
+    {
+        get; set;
+    }
+    public string? PrivacyUrl
+    {
+        get; set;
+    }
+    public string? TermsOfUseUrl
+    {
+        get; set;
+    }
+#pragma warning restore CA1056
+
+    [JsonExtensionData]
+    public Dictionary<string, Object> AdditionalData { get; set; } = new();
+}
+
+internal class Name
+{
+    [JsonPropertyName("short")]
+    public string? ShortName
+    {
+        get; set;
+    }
+    [JsonPropertyName("full")]
+    public string? FullName
+    {
+        get; set;
+    }
+}
+
+internal class Description
+{
+    [JsonPropertyName("short")]
+    public string? ShortName
+    {
+        get; set;
+    }
+    [JsonPropertyName("full")]
+    public string? FullName
+    {
+        get; set;
+    }
+}
+
+internal class Icons
+{
+    public string? Color
+    {
+        get;
+        set;
+    } = "color.png";
+
+    public string? Outline
+    {
+        get;
+        set;
+    } = "outline.png";
+}
+
+internal class CopilotExtensions
+{
+    public IList<Plugin> Plugins { get; set; } = new List<Plugin>();
+}
+
+internal class Plugin
+{
+    public string? Id
+    {
+        get; set;
+    }
+    public string? File
+    {
+        get; set;
+    }
+}
diff --git a/src/Kiota.Builder/Plugins/PluginsGenerationService.cs b/src/Kiota.Builder/Plugins/PluginsGenerationService.cs
index 026f14b47f..0e38aa92b9 100644
--- a/src/Kiota.Builder/Plugins/PluginsGenerationService.cs
+++ b/src/Kiota.Builder/Plugins/PluginsGenerationService.cs
@@ -3,11 +3,13 @@
 using System.IO;
 using System.Linq;
 using System.Text.Json;
+using System.Text.Json.Serialization;
 using System.Threading;
 using System.Threading.Tasks;
 using Kiota.Builder.Configuration;
 using Kiota.Builder.Extensions;
 using Kiota.Builder.OpenApiExtensions;
+using Kiota.Builder.Plugins.Models;
 using Microsoft.Kiota.Abstractions.Extensions;
 using Microsoft.OpenApi.ApiManifest;
 using Microsoft.OpenApi.Models;
@@ -38,9 +40,10 @@ public PluginsGenerationService(OpenApiDocument document, OpenApiUrlTreeNode ope
     private const string ManifestFileNameSuffix = ".json";
     private const string DescriptionPathSuffix = "openapi.yml";
     private const string OpenAIManifestFileName = "openai-plugins";
+    private const string AppManifestFileName = "manifest.json";
     public async Task GenerateManifestAsync(CancellationToken cancellationToken = default)
     {
-        // write the description
+        // 1. write the OpenApi description
         var descriptionRelativePath = $"{Configuration.ClientClassName.ToLowerInvariant()}-{DescriptionPathSuffix}";
         var descriptionFullPath = Path.Combine(Configuration.OutputPath, descriptionRelativePath);
         var directory = Path.GetDirectoryName(descriptionFullPath);
@@ -55,7 +58,8 @@ public async Task GenerateManifestAsync(CancellationToken cancellationToken = de
         trimmedPluginDocument.SerializeAsV3(descriptionWriter);
         descriptionWriter.Flush();
 
-        // write the plugins
+        // 2. write the plugins
+
         foreach (var pluginType in Configuration.PluginTypes)
         {
             var manifestFileName = pluginType == PluginType.OpenAI ? OpenAIManifestFileName : $"{Configuration.ClientClassName.ToLowerInvariant()}-{pluginType.ToString().ToLowerInvariant()}";
@@ -93,8 +97,84 @@ public async Task GenerateManifestAsync(CancellationToken cancellationToken = de
             }
             await writer.FlushAsync(cancellationToken).ConfigureAwait(false);
         }
+
+        // 3. write the app manifest if its an Api Plugin
+        if (Configuration.PluginTypes.Any(static plugin => plugin == PluginType.APIPlugin))
+        {
+            var manifestFullPath = Path.Combine(Configuration.OutputPath, AppManifestFileName);
+            var pluginFileName = $"{Configuration.ClientClassName.ToLowerInvariant()}-{PluginType.APIPlugin.ToString().ToLowerInvariant()}{ManifestFileNameSuffix}";
+            var appManifestModel = await GetAppManifestModelAsync(pluginFileName, manifestFullPath, cancellationToken).ConfigureAwait(false);
+#pragma warning disable CA2007
+            await using var appManifestStream = File.Open(manifestFullPath, FileMode.Create);
+#pragma warning restore CA2007
+            await JsonSerializer.SerializeAsync(appManifestStream, appManifestModel, AppManifestModelGenerationContext.AppManifestModel, cancellationToken).ConfigureAwait(false);
+        }
+    }
+
+    private async Task<AppManifestModel> GetAppManifestModelAsync(string pluginFileName, string manifestFullPath, CancellationToken cancellationToken)
+    {
+        var manifestInfo = ExtractInfoFromDocument(OAIDocument.Info);
+        // create default model
+        var manifestModel = new AppManifestModel
+        {
+            Id = Guid.NewGuid().ToString(),
+            Developer = new Developer
+            {
+                Name = !string.IsNullOrEmpty(OAIDocument.Info?.Contact?.Name) ? OAIDocument.Info?.Contact?.Name : "Microsoft Kiota.",
+                WebsiteUrl = !string.IsNullOrEmpty(OAIDocument.Info?.Contact?.Url?.OriginalString) ? OAIDocument.Info?.Contact?.Url?.OriginalString : "https://www.example.com/contact/",
+                PrivacyUrl = !string.IsNullOrEmpty(manifestInfo.PrivacyUrl) ? manifestInfo.PrivacyUrl : "https://www.example.com/privacy/",
+                TermsOfUseUrl = !string.IsNullOrEmpty(OAIDocument.Info?.TermsOfService?.OriginalString) ? OAIDocument.Info?.TermsOfService?.OriginalString : "https://www.example.com/terms/",
+            },
+            PackageName = $"com.microsoft.kiota.plugin.{Configuration.ClientClassName}",
+            Name = new Name
+            {
+                ShortName = Configuration.ClientClassName,
+                FullName = $"API Plugin {Configuration.ClientClassName} for {OAIDocument.Info?.Title.CleanupXMLString() ?? "OpenApi Document"}"
+            },
+            Description = new Description
+            {
+                ShortName = !string.IsNullOrEmpty(OAIDocument.Info?.Description.CleanupXMLString()) ? $"API Plugin for {OAIDocument.Info?.Description.CleanupXMLString()}." : OAIDocument.Info?.Title.CleanupXMLString() ?? "OpenApi Document",
+                FullName = !string.IsNullOrEmpty(OAIDocument.Info?.Description.CleanupXMLString()) ? $"API Plugin for {OAIDocument.Info?.Description.CleanupXMLString()}." : OAIDocument.Info?.Title.CleanupXMLString() ?? "OpenApi Document"
+            },
+            Icons = new Icons(),
+            AccentColor = "#FFFFFF"
+        };
+
+        if (File.Exists(manifestFullPath)) // No need for default, try to update the model from the file
+        {
+#pragma warning disable CA2007 // Consider calling ConfigureAwait on the awaited task
+            await using var fileStream = File.OpenRead(manifestFullPath);
+#pragma warning restore CA2007 // Consider calling ConfigureAwait on the awaited task
+            var manifestModelFromFile = await JsonSerializer.DeserializeAsync(fileStream, AppManifestModelGenerationContext.AppManifestModel, cancellationToken).ConfigureAwait(false);
+            if (manifestModelFromFile != null)
+                manifestModel = manifestModelFromFile;
+        }
+
+        manifestModel.CopilotExtensions ??= new CopilotExtensions();// ensure its not null.
+
+        if (manifestModel.CopilotExtensions.Plugins.FirstOrDefault(pluginItem => Configuration.ClientClassName.Equals(pluginItem.Id, StringComparison.OrdinalIgnoreCase)) is { } plugin)
+        {
+            plugin.File = pluginFileName; // id is already consitent so make sure the file name is ok
+        }
+        else
+        {
+            // Add a new plugin entry
+            manifestModel.CopilotExtensions.Plugins.Add(new Plugin
+            {
+                File = pluginFileName,
+                Id = Configuration.ClientClassName
+            });
+        }
+
+        return manifestModel;
     }
 
+    internal static readonly AppManifestModelGenerationContext AppManifestModelGenerationContext = new(new JsonSerializerOptions
+    {
+        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+        WriteIndented = true,
+        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
+    });
 
     private OpenApiDocument GetDocumentWithTrimmedComponentsAndResponses(OpenApiDocument doc)
     {


Test patch:
diff --git a/tests/Kiota.Builder.Tests/Plugins/PluginsGenerationServiceTests.cs b/tests/Kiota.Builder.Tests/Plugins/PluginsGenerationServiceTests.cs
index 509004f814..77469edd74 100644
--- a/tests/Kiota.Builder.Tests/Plugins/PluginsGenerationServiceTests.cs
+++ b/tests/Kiota.Builder.Tests/Plugins/PluginsGenerationServiceTests.cs
@@ -6,6 +6,7 @@
 using System.Threading.Tasks;
 using Kiota.Builder.Configuration;
 using Kiota.Builder.Plugins;
+using Kiota.Builder.Plugins.Models;
 using Microsoft.Extensions.Logging;
 using Microsoft.OpenApi.Models;
 using Microsoft.OpenApi.Readers;
@@ -90,6 +91,7 @@ public async Task GeneratesManifest()
         Assert.True(File.Exists(Path.Combine(outputDirectory, "client-apimanifest.json")));
         Assert.True(File.Exists(Path.Combine(outputDirectory, OpenAIPluginFileName)));
         Assert.True(File.Exists(Path.Combine(outputDirectory, OpenApiFileName)));
+        Assert.True(File.Exists(Path.Combine(outputDirectory, AppManifestFileName)));
 
         // Validate the v2 plugin
         var manifestContent = await File.ReadAllTextAsync(Path.Combine(outputDirectory, ManifestFileName));
@@ -107,11 +109,527 @@ public async Task GeneratesManifest()
         Assert.NotNull(resultingManifest.Document);
         Assert.Equal(OpenApiFileName, v1Manifest.Document.Api.URL);
         Assert.Empty(v1Manifest.Problems);
+
+        // Validate the manifest file
+        var appManifestFile = await File.ReadAllTextAsync(Path.Combine(outputDirectory, AppManifestFileName));
+        var appManifestModelObject = JsonSerializer.Deserialize<AppManifestModel>(appManifestFile, PluginsGenerationService.AppManifestModelGenerationContext.AppManifestModel);
+        Assert.Equal("com.microsoft.kiota.plugin.client", appManifestModelObject.PackageName);
+        Assert.Equal("client", appManifestModelObject.Name.ShortName);
+        Assert.Equal("Microsoft Kiota.", appManifestModelObject.Developer.Name);
+        Assert.Equal("color.png", appManifestModelObject.Icons.Color);
+        Assert.Equal("client", appManifestModelObject.CopilotExtensions.Plugins[0].Id);
+        Assert.Equal(ManifestFileName, appManifestModelObject.CopilotExtensions.Plugins[0].File);
     }
     private const string ManifestFileName = "client-apiplugin.json";
     private const string OpenAIPluginFileName = "openai-plugins.json";
     private const string OpenApiFileName = "client-openapi.yml";
+    private const string AppManifestFileName = "manifest.json";
+
+    [Fact]
+    public async Task GeneratesManifestAndUpdatesExistingAppManifest()
+    {
+        var simpleDescriptionContent = @"openapi: 3.0.0
+info:
+  title: test
+  version: 1.0
+  description: A sample test api
+servers:
+  - url: http://localhost/
+    description: There's no place like home
+paths:
+  /test/{id}:
+    get:
+      description: description for test path with id
+      operationId: test.WithId
+      parameters:
+      - name: id
+        in: path
+        required: true
+        description: The id of the test
+        schema:
+          type: integer
+          format: int32
+      responses:
+        '200':
+          description: test";
+        var workingDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+        var simpleDescriptionPath = Path.Combine(workingDirectory) + "description.yaml";
+        await File.WriteAllTextAsync(simpleDescriptionPath, simpleDescriptionContent);
+        var mockLogger = new Mock<ILogger<PluginsGenerationService>>();
+        var openAPIDocumentDS = new OpenApiDocumentDownloadService(_httpClient, mockLogger.Object);
+        var outputDirectory = Path.Combine(workingDirectory, "output");
+        Directory.CreateDirectory(outputDirectory);
+        var preExistingManifestContents = @"{
+    ""$schema"": ""https://developer.microsoft.com/json-schemas/teams/v1.17/MicrosoftTeams.schema.json"",
+    ""manifestVersion"": ""1.17"",
+    ""version"": ""1.0.0"",
+    ""id"": ""%MICROSOFT-APP-ID%"",
+    ""localizationInfo"": {
+        ""defaultLanguageTag"": ""en-us"",
+        ""additionalLanguages"": [
+            {
+                ""languageTag"": ""es-es"",
+                ""file"": ""en-us.json""
+            }
+        ]
+    },
+    ""developer"": {
+        ""name"": ""Publisher Name"",
+        ""websiteUrl"": ""https://example.com/"",
+        ""privacyUrl"": ""https://example.com/privacy"",
+        ""termsOfUseUrl"": ""https://example.com/app-tos"",
+        ""mpnId"": ""1234567890""
+    },
+    ""name"": {
+        ""short"": ""Name of your app"",
+        ""full"": ""Full name of app, if longer than 30 characters""
+    },
+    ""description"": {
+        ""short"": ""Short description of your app (<= 80 chars)"",
+        ""full"": ""Full description of your app (<= 4000 chars)""
+    },
+    ""icons"": {
+        ""outline"": ""A relative path to a transparent .png icon — 32px X 32px"",
+        ""color"": ""A relative path to a full color .png icon — 192px X 192px""
+    },
+    ""accentColor"": ""A valid HTML color code."",
+    ""configurableTabs"": [
+        {
+            ""configurationUrl"": ""https://contoso.com/teamstab/configure"",
+            ""scopes"": [
+                ""team"",
+                ""groupChat""
+            ],
+            ""canUpdateConfiguration"": true,
+            ""context"": [
+                ""channelTab"",
+                ""privateChatTab"",
+                ""meetingChatTab"",
+                ""meetingDetailsTab"",
+                ""meetingSidePanel"",
+                ""meetingStage""
+            ],
+            ""sharePointPreviewImage"": ""Relative path to a tab preview image for use in SharePoint — 1024px X 768"",
+            ""supportedSharePointHosts"": [
+                ""sharePointFullPage"",
+                ""sharePointWebPart""
+            ]
+        }
+    ],
+    ""staticTabs"": [
+        {
+            ""entityId"": ""unique Id for the page entity"",
+            ""scopes"": [
+                ""personal""
+            ],
+            ""context"": [
+                ""personalTab"",
+                ""channelTab""
+            ],
+            ""name"": ""Display name of tab"",
+            ""contentUrl"": ""https://contoso.com/content (displayed in Teams canvas)"",
+            ""websiteUrl"": ""https://contoso.com/content (displayed in web browser)"",
+            ""searchUrl"": ""https://contoso.com/content (displayed in web browser)""
+        }
+    ],
+    ""supportedChannelTypes"": [
+        ""sharedChannels"",
+        ""privateChannels""
+    ],
+    ""bots"": [
+        {
+            ""botId"": ""%MICROSOFT-APP-ID-REGISTERED-WITH-BOT-FRAMEWORK%"",
+            ""scopes"": [
+                ""team"",
+                ""personal"",
+                ""groupChat""
+            ],
+            ""needsChannelSelector"": false,
+            ""isNotificationOnly"": false,
+            ""supportsFiles"": true,
+            ""supportsCalling"": false,
+            ""supportsVideo"": true,
+            ""commandLists"": [
+                {
+                    ""scopes"": [
+                        ""team"",
+                        ""groupChat""
+                    ],
+                    ""commands"": [
+                        {
+                            ""title"": ""Command 1"",
+                            ""description"": ""Description of Command 1""
+                        },
+                        {
+                            ""title"": ""Command 2"",
+                            ""description"": ""Description of Command 2""
+                        }
+                    ]
+                },
+                {
+                    ""scopes"": [
+                        ""personal"",
+                        ""groupChat""
+                    ],
+                    ""commands"": [
+                        {
+                            ""title"": ""Personal command 1"",
+                            ""description"": ""Description of Personal command 1""
+                        },
+                        {
+                            ""title"": ""Personal command N"",
+                            ""description"": ""Description of Personal command N""
+                        }
+                    ]
+                }
+            ]
+        }
+    ],
+    ""connectors"": [
+        {
+            ""connectorId"": ""GUID-FROM-CONNECTOR-DEV-PORTAL%"",
+            ""scopes"": [
+                ""team""
+            ],
+            ""configurationUrl"": ""https://contoso.com/teamsconnector/configure""
+        }
+    ],
+    ""composeExtensions"": [
+        {
+            ""canUpdateConfiguration"": true,
+            ""botId"": ""%MICROSOFT-APP-ID-REGISTERED-WITH-BOT-FRAMEWORK%"",
+            ""commands"": [
+                {
+                    ""id"": ""exampleCmd1"",
+                    ""title"": ""Example Command"",
+                    ""type"": ""query"",
+                    ""context"": [
+                        ""compose"",
+                        ""commandBox""
+                    ],
+                    ""description"": ""Command Description; e.g., Search on the web"",
+                    ""initialRun"": true,
+                    ""fetchTask"": false,
+                    ""parameters"": [
+                        {
+                            ""name"": ""keyword"",
+                            ""title"": ""Search keywords"",
+                            ""inputType"": ""choiceset"",
+                            ""description"": ""Enter the keywords to search for"",
+                            ""value"": ""Initial value for the parameter"",
+                            ""choices"": [
+                                {
+                                    ""title"": ""Title of the choice"",
+                                    ""value"": ""Value of the choice""
+                                }
+                            ]
+                        }
+                    ]
+                },
+                {
+                    ""id"": ""exampleCmd2"",
+                    ""title"": ""Example Command 2"",
+                    ""type"": ""action"",
+                    ""context"": [
+                        ""message""
+                    ],
+                    ""description"": ""Command Description; e.g., Add a customer"",
+                    ""initialRun"": true,
+                    ""fetchTask"": false ,
+                    ""parameters"": [
+                        {
+                            ""name"": ""custinfo"",
+                            ""title"": ""Customer name"",
+                            ""description"": ""Enter a customer name"",
+                            ""inputType"": ""text""
+                        }
+                    ]
+                },
+                {
+                    ""id"": ""exampleCmd3"",
+                    ""title"": ""Example Command 3"",
+                    ""type"": ""action"",
+                    ""context"": [
+                        ""compose"",
+                        ""commandBox"",
+                        ""message""
+                    ],
+                    ""description"": ""Command Description; e.g., Add a customer"",
+                    ""fetchTask"": false,
+                    ""taskInfo"": {
+                        ""title"": ""Initial dialog title"",
+                        ""width"": ""Dialog width"",
+                        ""height"": ""Dialog height"",
+                        ""url"": ""Initial webview URL""
+                    }
+                }
+            ],
+            ""messageHandlers"": [
+                {
+                    ""type"": ""link"",
+                    ""value"": {
+                        ""domains"": [
+                            ""mysite.someplace.com"",
+                            ""othersite.someplace.com""
+                        ],
+                        ""supportsAnonymizedPayloads"": false
+                    }
+                }
+            ]
+        }
+    ],
+    ""permissions"": [
+        ""identity"",
+        ""messageTeamMembers""
+    ],
+    ""devicePermissions"": [
+        ""geolocation"",
+        ""media"",
+        ""notifications"",
+        ""midi"",
+        ""openExternal""
+    ],
+    ""validDomains"": [
+        ""contoso.com"",
+        ""mysite.someplace.com"",
+        ""othersite.someplace.com""
+    ],
+    ""webApplicationInfo"": {
+        ""id"": ""AAD App ID"",
+        ""resource"": ""Resource URL for acquiring auth token for SSO""
+    },
+    ""authorization"": {
+        ""permissions"": {
+            ""resourceSpecific"": [
+                {
+                    ""type"": ""Application"",
+                    ""name"": ""ChannelSettings.Read.Group""
+                },
+                {
+                    ""type"": ""Delegated"",
+                    ""name"": ""ChannelMeetingParticipant.Read.Group""
+                }
+            ]
+        }
+    },
+    ""showLoadingIndicator"": false,
+    ""isFullScreen"": false,
+    ""activities"": {
+        ""activityTypes"": [
+            {
+                ""type"": ""taskCreated"",
+                ""description"": ""Task created activity"",
+                ""templateText"": ""<team member> created task <taskId> for you""
+            },
+            {
+                ""type"": ""userMention"",
+                ""description"": ""Personal mention activity"",
+                ""templateText"": ""<team member> mentioned you""
+            }
+        ]
+    },
+    ""defaultBlockUntilAdminAction"": true,
+    ""publisherDocsUrl"": ""https://example.com/app-info"",
+    ""defaultInstallScope"": ""meetings"",
+    ""defaultGroupCapability"": {
+        ""meetings"": ""tab"",
+        ""team"": ""bot"",
+        ""groupChat"": ""bot""
+    },
+    ""configurableProperties"": [
+        ""name"",
+        ""shortDescription"",
+        ""longDescription"",
+        ""smallImageUrl"",
+        ""largeImageUrl"",
+        ""accentColor"",
+        ""developerUrl"",
+        ""privacyUrl"",
+        ""termsOfUseUrl""
+    ],
+    ""subscriptionOffer"": {
+        ""offerId"": ""publisherId.offerId""
+    },
+    ""meetingExtensionDefinition"": {
+        ""scenes"": [
+            {
+                ""id"": ""9082c811-7e6a-4174-8173-6ccd57d377e6"",
+                ""name"": ""Getting started sample"",
+                ""file"": ""scenes/sceneMetadata.json"",
+                ""preview"": ""scenes/scenePreview.png"",
+                ""maxAudience"": 15,
+                ""seatsReservedForOrganizersOrPresenters"": 0
+            },
+            {
+                ""id"": ""afeaed22-f89b-48e1-98b4-46a514344e4a"",
+                ""name"": ""Sample-1"",
+                ""file"": ""scenes/sceneMetadata.json"",
+                ""preview"": ""scenes/scenePreview.png"",
+                ""maxAudience"": 15,
+                ""seatsReservedForOrganizersOrPresenters"": 3
+            }
+        ]
+    }
+}";
+        var preExistingManifestPath = Path.Combine(outputDirectory, "manifest.json");
+        await File.WriteAllTextAsync(preExistingManifestPath, preExistingManifestContents);
+        var generationConfiguration = new GenerationConfiguration
+        {
+            OutputPath = outputDirectory,
+            OpenAPIFilePath = "openapiPath",
+            PluginTypes = [PluginType.APIPlugin],
+            ClientClassName = "client",
+            ApiRootUrl = "http://localhost/", //Kiota builder would set this for us
+        };
+        var (openAPIDocumentStream, _) = await openAPIDocumentDS.LoadStreamAsync(simpleDescriptionPath, generationConfiguration, null, false);
+        var openApiDocument = await openAPIDocumentDS.GetDocumentFromStreamAsync(openAPIDocumentStream, generationConfiguration);
+        KiotaBuilder.CleanupOperationIdForPlugins(openApiDocument);
+        var urlTreeNode = OpenApiUrlTreeNode.Create(openApiDocument, Constants.DefaultOpenApiLabel);
 
+        // Assert manifest exists before generation and is parsable
+        Assert.True(File.Exists(Path.Combine(outputDirectory, "manifest.json")));
+        var originalManifestFile = await File.ReadAllTextAsync(Path.Combine(outputDirectory, AppManifestFileName));
+        var originalAppManifestModelObject = JsonSerializer.Deserialize<AppManifestModel>(originalManifestFile, PluginsGenerationService.AppManifestModelGenerationContext.AppManifestModel);
+        Assert.Null(originalAppManifestModelObject.PackageName);// package wasn't present
+        Assert.Equal("Name of your app", originalAppManifestModelObject.Name.ShortName); // app name is same
+        Assert.Equal("Publisher Name", originalAppManifestModelObject.Developer.Name); // app name is same
+        Assert.Null(originalAppManifestModelObject.CopilotExtensions?.Plugins); // no plugins present
+
+        // Run the plugin generation
+        var pluginsGenerationService = new PluginsGenerationService(openApiDocument, urlTreeNode, generationConfiguration, workingDirectory);
+        await pluginsGenerationService.GenerateManifestAsync();
+
+        Assert.True(File.Exists(Path.Combine(outputDirectory, ManifestFileName)));
+        Assert.True(File.Exists(Path.Combine(outputDirectory, OpenApiFileName)));
+        Assert.True(File.Exists(Path.Combine(outputDirectory, "manifest.json")));// Assert manifest exists after generation
+
+        // Validate the manifest file
+        var appManifestFile = await File.ReadAllTextAsync(Path.Combine(outputDirectory, AppManifestFileName));
+        var appManifestModelObject = JsonSerializer.Deserialize<AppManifestModel>(appManifestFile, PluginsGenerationService.AppManifestModelGenerationContext.AppManifestModel);
+        Assert.Null(appManifestModelObject.PackageName);// package wasn't present
+        Assert.Equal("Name of your app", appManifestModelObject.Name.ShortName); // app name is same
+        Assert.Equal("Publisher Name", originalAppManifestModelObject.Developer.Name); // developer name is same
+        Assert.Equal("client", appManifestModelObject.CopilotExtensions.Plugins[0].Id);
+        Assert.Equal(ManifestFileName, appManifestModelObject.CopilotExtensions.Plugins[0].File);
+        var rootJsonElement = JsonDocument.Parse(appManifestFile).RootElement;
+        Assert.True(rootJsonElement.TryGetProperty("subscriptionOffer", out _));// no loss of information
+        Assert.True(rootJsonElement.TryGetProperty("meetingExtensionDefinition", out _));// no loss of information
+        Assert.True(rootJsonElement.TryGetProperty("activities", out _));// no loss of information
+        Assert.True(rootJsonElement.TryGetProperty("devicePermissions", out _));// no loss of information
+        Assert.True(rootJsonElement.TryGetProperty("composeExtensions", out _));// no loss of information
+    }
+
+    [Fact]
+    public async Task GeneratesManifestAndUpdatesExistingAppManifestWithExistingPlugins()
+    {
+        var simpleDescriptionContent = @"openapi: 3.0.0
+info:
+  termsOfService: http://example.com/terms/
+  contact:
+    name: API Support
+    email: support@example.com
+    url: http://example.com/support
+servers:
+  - url: http://localhost/
+    description: There's no place like home
+paths:
+  /test/{id}:
+    get:
+      description: description for test path with id
+      operationId: test.WithId
+      parameters:
+      - name: id
+        in: path
+        required: true
+        description: The id of the test
+        schema:
+          type: integer
+          format: int32
+      responses:
+        '200':
+          description: test";
+        var workingDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+        var simpleDescriptionPath = Path.Combine(workingDirectory) + "description.yaml";
+        await File.WriteAllTextAsync(simpleDescriptionPath, simpleDescriptionContent);
+        var mockLogger = new Mock<ILogger<PluginsGenerationService>>();
+        var openAPIDocumentDS = new OpenApiDocumentDownloadService(_httpClient, mockLogger.Object);
+        var outputDirectory = Path.Combine(workingDirectory, "output");
+        Directory.CreateDirectory(outputDirectory);
+        var preExistingManifestContents = @"{
+    ""$schema"": ""https://developer.microsoft.com/json-schemas/teams/vDevPreview/MicrosoftTeams.schema.json"",
+    ""manifestVersion"": ""devPreview"",
+    ""version"": ""1.0.0"",
+    ""id"": ""<generated_GUID>"",
+    ""developer"": {
+        ""name"": ""Test Name"",
+        ""websiteUrl"": ""<Defaults to `contact.url` from the OpenAPI document. If the `contact.url` is not available, it defaults to `https://www.example.com/contact/`>"",
+        ""privacyUrl"": ""<Defaults to `x-privacy-policy-url` extension from the OpenAPI document. If the `x-privacy-policy-url` is not available, it defaults to `https://www.example.com/privacy/`>"",
+        ""termsOfUseUrl"": ""<Defaults to `termsOfService` from the OpenAPI document. If the `termsOfService` is not available, it defaults to `https://www.example.com/terms/`>""
+    },
+    ""packageName"": ""com.microsoft.kiota.plugin.client"",
+    ""name"": {
+        ""short"": ""client"",
+        ""full"": ""API Plugin <plugin_name> for <OpenAPI document title>""
+    },
+   ""description"": {
+        ""short"": ""API Plugin for <description from the OpenAPI document>. If the description is not available, it defaults to `API Plugin for <OpenAPI document title>`"",
+        ""full"": ""API Plugin for <description from the OpenAPI document>. If the description is not available, it defaults to `API Plugin for <OpenAPI document title>`""
+    },
+    ""icons"": {
+        ""color"": ""color.png"", 
+        ""outline"": ""outline.png""
+    },
+   ""accentColor"": ""#FFFFFF"",
+   ""copilotExtensions"": {
+        ""plugins"": [
+            {
+                ""id"": ""client"",
+                ""file"": ""dummyFile.json""
+            }
+        ]
+    }
+}";
+        var preExistingManifestPath = Path.Combine(outputDirectory, "manifest.json");
+        await File.WriteAllTextAsync(preExistingManifestPath, preExistingManifestContents);
+        var generationConfiguration = new GenerationConfiguration
+        {
+            OutputPath = outputDirectory,
+            OpenAPIFilePath = "openapiPath",
+            PluginTypes = [PluginType.APIPlugin],
+            ClientClassName = "client",
+            ApiRootUrl = "http://localhost/", //Kiota builder would set this for us
+        };
+        var (openAPIDocumentStream, _) = await openAPIDocumentDS.LoadStreamAsync(simpleDescriptionPath, generationConfiguration, null, false);
+        var openApiDocument = await openAPIDocumentDS.GetDocumentFromStreamAsync(openAPIDocumentStream, generationConfiguration);
+        KiotaBuilder.CleanupOperationIdForPlugins(openApiDocument);
+        var urlTreeNode = OpenApiUrlTreeNode.Create(openApiDocument, Constants.DefaultOpenApiLabel);
+
+        // Assert manifest exists before generation and is parsable
+        Assert.True(File.Exists(Path.Combine(outputDirectory, "manifest.json")));
+        var originalManifestFile = await File.ReadAllTextAsync(Path.Combine(outputDirectory, AppManifestFileName));
+        var originalAppManifestModelObject = JsonSerializer.Deserialize<AppManifestModel>(originalManifestFile, PluginsGenerationService.AppManifestModelGenerationContext.AppManifestModel);
+        Assert.Equal("com.microsoft.kiota.plugin.client", originalAppManifestModelObject.PackageName);// package was present
+        Assert.NotNull(originalAppManifestModelObject.CopilotExtensions);
+        Assert.Single(originalAppManifestModelObject.CopilotExtensions.Plugins);//one plugin present
+        Assert.Equal("dummyFile.json", originalAppManifestModelObject.CopilotExtensions.Plugins[0].File); // no plugins present
+
+        // Run the plugin generation
+        var pluginsGenerationService = new PluginsGenerationService(openApiDocument, urlTreeNode, generationConfiguration, workingDirectory);
+        await pluginsGenerationService.GenerateManifestAsync();
+
+        Assert.True(File.Exists(Path.Combine(outputDirectory, ManifestFileName)));
+        Assert.True(File.Exists(Path.Combine(outputDirectory, OpenApiFileName)));
+        Assert.True(File.Exists(Path.Combine(outputDirectory, "manifest.json")));// Assert manifest exists after generation
+
+        // Validate the manifest file
+        var appManifestFile = await File.ReadAllTextAsync(Path.Combine(outputDirectory, AppManifestFileName));
+        var appManifestModelObject = JsonSerializer.Deserialize<AppManifestModel>(appManifestFile, PluginsGenerationService.AppManifestModelGenerationContext.AppManifestModel);
+        Assert.Equal("com.microsoft.kiota.plugin.client", originalAppManifestModelObject.PackageName);// package was present
+        Assert.Equal("client", appManifestModelObject.Name.ShortName); // app name is same
+        Assert.Equal("Test Name", originalAppManifestModelObject.Developer.Name); // developer name is same
+        Assert.Equal("client", appManifestModelObject.CopilotExtensions.Plugins[0].Id);
+        Assert.Equal(ManifestFileName, appManifestModelObject.CopilotExtensions.Plugins[0].File);// file name is updated
+    }
     [Fact]
     public async Task GeneratesManifestAndCleansUpInputDescription()
     {