From 1e6b9b3f83549f1597d313083944f8df062c8ad3 Mon Sep 17 00:00:00 2001 From: Srikanta Nagaraja Date: Thu, 9 Apr 2026 01:39:21 -0700 Subject: [PATCH 1/3] Enable Azure Skills for GitHub Copilot --- .../azuremcp/AzureSkillsInitializer.java | 244 ++++++++++++++++++ .../azure-intellij-plugin-azuremcp.xml | 3 + 2 files changed, 247 insertions(+) create mode 100644 PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-azuremcp/src/main/java/com/microsoft/azure/toolkit/intellij/azuremcp/AzureSkillsInitializer.java diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-azuremcp/src/main/java/com/microsoft/azure/toolkit/intellij/azuremcp/AzureSkillsInitializer.java b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-azuremcp/src/main/java/com/microsoft/azure/toolkit/intellij/azuremcp/AzureSkillsInitializer.java new file mode 100644 index 00000000000..6426b95b4a8 --- /dev/null +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-azuremcp/src/main/java/com/microsoft/azure/toolkit/intellij/azuremcp/AzureSkillsInitializer.java @@ -0,0 +1,244 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +package com.microsoft.azure.toolkit.intellij.azuremcp; + +import com.intellij.openapi.application.PathManager; +import com.intellij.openapi.project.DumbAware; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.startup.ProjectActivity; +import com.intellij.openapi.util.SystemInfo; +import com.intellij.openapi.util.registry.Registry; +import kotlin.Unit; +import kotlin.coroutines.Continuation; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; + +import javax.annotation.Nullable; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.TimeUnit; + +import static com.microsoft.azure.toolkit.intellij.azuremcp.AzureMcpUtils.logErrorTelemetryEvent; +import static com.microsoft.azure.toolkit.intellij.azuremcp.AzureMcpUtils.logTelemetryEvent; + +/** + * Post-startup activity that installs or updates Azure Skills via {@code npx}. + * + * + *

+ * Can be disabled via the {@code azure.skills.autoconfigure.disabled} registry key. + */ +@Slf4j +public class AzureSkillsInitializer implements ProjectActivity, DumbAware { + private static final String[] NPX_ADD_ARGS = { + "-y", "skills", "add", + "https://github.com/microsoft/azure-skills/tree/main/.github/plugins/azure-skills/skills", + "--all", "github-copilot", "-g" + }; + private static final String[] NPX_UPDATE_ARGS = { + "-y", "skills", "update", "-g" + }; + private static final Duration UPDATE_INTERVAL = Duration.ofHours(24); + private static final String TIMESTAMP_FILE_NAME = "azure-skills-last-update"; + + // This timeout should account for time required to clone the repo and install all the skills. + private static final long TIMEOUT_IN_MINUTES = 5; + + @Override + public Object execute(@NotNull Project project, @NotNull Continuation continuation) { + if (Registry.is("azure.skills.autoconfigure.disabled", false)) { + logTelemetryEvent("azure-skills-initialization-disabled"); + return null; + } + + logTelemetryEvent("azure-skills-initialization-started"); + log.info("Running Azure Skills initializer"); + try { + final String npxPath = findNpxExecutable(); + if (npxPath == null) { + log.warn("npx is not installed or not found on PATH"); + logTelemetryEvent("azure-skills-npx-not-found"); + return null; + } + + if (isSkillsInstalled()) { + updateSkills(npxPath); + } else { + installAzureSkills(npxPath); + } + } catch (final Exception ex) { + log.error("Error initializing Azure Skills: " + ex.getMessage(), ex); + logErrorTelemetryEvent("azure-skills-initialization-failed", ex); + } + return null; + } + + private void installAzureSkills(String npxPath) { + log.info("Azure Skills not found, running fresh install"); + final boolean success = runNpxCommand(npxPath, NPX_ADD_ARGS); + if (success) { + writeTimestamp(); + log.info("Azure Skills installed successfully."); + logTelemetryEvent("azure-skills-install-success"); + } else { + log.warn("Azure Skills npx add command failed"); + logTelemetryEvent("azure-skills-install-failed"); + } + } + + private void updateSkills(String npxPath) { + if (!isUpdateDue()) { + log.info("Azure Skills is up to date, skipping update"); + return; + } + + log.info("Azure Skills update is due, running update"); + final boolean success = runNpxCommand(npxPath, NPX_UPDATE_ARGS); + if (success) { + writeTimestamp(); + log.info("Azure Skills updated successfully."); + logTelemetryEvent("azure-skills-update-success"); + } else { + log.warn("Azure Skills npx update command failed"); + logTelemetryEvent("azure-skills-update-failed"); + } + } + + /** + * Checks whether Azure Skills are already installed by looking for + * skill subdirectories under {@code ~/.agents/skills/}. + */ + private boolean isSkillsInstalled() { + final Path skillsDir = getSkillsDir(); + if (!Files.isDirectory(skillsDir)) { + return false; + } + try (final var entries = Files.list(skillsDir)) { + return entries.anyMatch(Files::isDirectory); + } catch (final IOException ex) { + log.warn("Failed to check skills directory: " + ex.getMessage()); + return false; + } + } + + /** + * Determines whether an update should be attempted based on the last + * install/update timestamp stored in the IntelliJ config directory. + */ + private boolean isUpdateDue() { + final Path timestampFile = getTimestampFile(); + if (!Files.exists(timestampFile)) { + return true; + } + try { + final String content = Files.readString(timestampFile).trim(); + final Instant lastUpdate = Instant.parse(content); + return Duration.between(lastUpdate, Instant.now()).compareTo(UPDATE_INTERVAL) > 0; + } catch (final Exception ex) { + log.warn("Failed to read skills timestamp file, treating as update due: " + ex.getMessage()); + return true; + } + } + + /** + * Writes the current UTC timestamp to the marker file so subsequent + * startups can decide whether an update is needed. + */ + private void writeTimestamp() { + try { + final Path timestampFile = getTimestampFile(); + Files.createDirectories(timestampFile.getParent()); + Files.writeString(timestampFile, Instant.now().toString()); + } catch (final IOException ex) { + log.warn("Failed to write skills timestamp file: " + ex.getMessage()); + } + } + + private Path getSkillsDir() { + return Paths.get(System.getProperty("user.home"), ".agents", "skills"); + } + + private Path getTimestampFile() { + return Paths.get(PathManager.getConfigPath(), TIMESTAMP_FILE_NAME); + } + + /** + * Finds the npx executable on the system PATH. + * On Windows, tries npx.cmd first (npm shim), then npx. + * + * @return the npx command string if found, null otherwise + */ + @Nullable + private String findNpxExecutable() { + final String[] candidates = SystemInfo.isWindows + ? new String[]{"npx.cmd", "npx"} + : new String[]{"npx"}; + + for (final String candidate : candidates) { + final ProcessBuilder pb = new ProcessBuilder(candidate, "--version"); + pb.redirectErrorStream(true); + final int exitCode = handleProcessExecution(pb); + if (exitCode == 0) { + return candidate; + } + } + return null; + } + + private int handleProcessExecution(ProcessBuilder pb) { + try { + final Process process = pb.start(); + try (final BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + log.debug(line); + } + } + final boolean completed = process.waitFor(TIMEOUT_IN_MINUTES, TimeUnit.MINUTES); + if (completed) { + return process.exitValue(); + } else { + process.destroyForcibly(); + log.warn("Process timed out and was killed: " + pb.command()); + return -1; + } + } catch (final IOException | InterruptedException ex) { + log.info("Error executing process command " + pb.command() + ": " + ex.getMessage()); + return -1; + } + } + + /** + * Runs an npx command with the given arguments. + * + * @param npxPath the path/name of the npx executable + * @param args the arguments to pass after npx + * @return true if the command completed successfully + */ + private boolean runNpxCommand(final String npxPath, final String[] args) { + final String[] command = new String[args.length + 1]; + command[0] = npxPath; + System.arraycopy(args, 0, command, 1, args.length); + + final ProcessBuilder pb = new ProcessBuilder(command); + final int exitCode = handleProcessExecution(pb); + log.info("npx skills command exited with code: " + exitCode); + return exitCode == 0; + } + +} diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-azuremcp/src/main/resources/META-INF/azure-intellij-plugin-azuremcp.xml b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-azuremcp/src/main/resources/META-INF/azure-intellij-plugin-azuremcp.xml index f7f4e2d5763..43db61d57c0 100644 --- a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-azuremcp/src/main/resources/META-INF/azure-intellij-plugin-azuremcp.xml +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-azuremcp/src/main/resources/META-INF/azure-intellij-plugin-azuremcp.xml @@ -6,6 +6,9 @@ + + \ No newline at end of file From 2388394608dfc5b30738194a81dfc89bbdb7e012 Mon Sep 17 00:00:00 2001 From: Srikanta Nagaraja Date: Thu, 9 Apr 2026 12:33:34 -0700 Subject: [PATCH 2/3] check copilot plugin is installed --- .../azuremcp/AzureSkillsInitializer.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-azuremcp/src/main/java/com/microsoft/azure/toolkit/intellij/azuremcp/AzureSkillsInitializer.java b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-azuremcp/src/main/java/com/microsoft/azure/toolkit/intellij/azuremcp/AzureSkillsInitializer.java index 6426b95b4a8..bc02cf9940e 100644 --- a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-azuremcp/src/main/java/com/microsoft/azure/toolkit/intellij/azuremcp/AzureSkillsInitializer.java +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-azuremcp/src/main/java/com/microsoft/azure/toolkit/intellij/azuremcp/AzureSkillsInitializer.java @@ -5,7 +5,10 @@ package com.microsoft.azure.toolkit.intellij.azuremcp; +import com.intellij.ide.plugins.IdeaPluginDescriptor; +import com.intellij.ide.plugins.PluginManagerCore; import com.intellij.openapi.application.PathManager; +import com.intellij.openapi.extensions.PluginId; import com.intellij.openapi.project.DumbAware; import com.intellij.openapi.project.Project; import com.intellij.openapi.startup.ProjectActivity; @@ -45,6 +48,7 @@ */ @Slf4j public class AzureSkillsInitializer implements ProjectActivity, DumbAware { + private static final String COPILOT_PLUGIN_ID = "com.github.copilot"; private static final String[] NPX_ADD_ARGS = { "-y", "skills", "add", "https://github.com/microsoft/azure-skills/tree/main/.github/plugins/azure-skills/skills", @@ -69,6 +73,12 @@ public Object execute(@NotNull Project project, @NotNull Continuation Date: Tue, 14 Apr 2026 17:09:33 -0700 Subject: [PATCH 3/3] add notifications --- .../azuremcp/AzureSkillsInitializer.java | 133 ++++++++++++++++-- 1 file changed, 122 insertions(+), 11 deletions(-) diff --git a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-azuremcp/src/main/java/com/microsoft/azure/toolkit/intellij/azuremcp/AzureSkillsInitializer.java b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-azuremcp/src/main/java/com/microsoft/azure/toolkit/intellij/azuremcp/AzureSkillsInitializer.java index bc02cf9940e..ac4764c9f99 100644 --- a/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-azuremcp/src/main/java/com/microsoft/azure/toolkit/intellij/azuremcp/AzureSkillsInitializer.java +++ b/PluginsAndFeatures/azure-toolkit-for-intellij/azure-intellij-plugin-azuremcp/src/main/java/com/microsoft/azure/toolkit/intellij/azuremcp/AzureSkillsInitializer.java @@ -7,6 +7,12 @@ import com.intellij.ide.plugins.IdeaPluginDescriptor; import com.intellij.ide.plugins.PluginManagerCore; +import com.intellij.notification.Notification; +import com.intellij.notification.NotificationAction; +import com.intellij.notification.NotificationType; +import com.intellij.notification.Notifications; +import com.intellij.openapi.actionSystem.AnActionEvent; +import com.intellij.openapi.application.ApplicationManager; import com.intellij.openapi.application.PathManager; import com.intellij.openapi.extensions.PluginId; import com.intellij.openapi.project.DumbAware; @@ -14,6 +20,7 @@ import com.intellij.openapi.startup.ProjectActivity; import com.intellij.openapi.util.SystemInfo; import com.intellij.openapi.util.registry.Registry; +import com.microsoft.azure.toolkit.intellij.common.settings.IntellijStore; import kotlin.Unit; import kotlin.coroutines.Continuation; import lombok.extern.slf4j.Slf4j; @@ -49,6 +56,10 @@ @Slf4j public class AzureSkillsInitializer implements ProjectActivity, DumbAware { private static final String COPILOT_PLUGIN_ID = "com.github.copilot"; + private static final String NOTIFICATION_GROUP_ID = "Azure Toolkit"; + private static final String CONSENT_KEY = "azure-skills-install-consent"; + private static final String CONSENT_ACCEPTED = "accepted"; + private static final String CONSENT_DECLINED = "declined"; private static final String[] NPX_ADD_ARGS = { "-y", "skills", "add", "https://github.com/microsoft/azure-skills/tree/main/.github/plugins/azure-skills/skills", @@ -59,15 +70,23 @@ public class AzureSkillsInitializer implements ProjectActivity, DumbAware { }; private static final Duration UPDATE_INTERVAL = Duration.ofHours(24); private static final String TIMESTAMP_FILE_NAME = "azure-skills-last-update"; - // This timeout should account for time required to clone the repo and install all the skills. private static final long TIMEOUT_IN_MINUTES = 5; @Override public Object execute(@NotNull Project project, @NotNull Continuation continuation) { + try { + initializeAzureSkills(null, project); + } catch (Exception e) { + log.error("Error initializing Azure Skills: " + e.getMessage(), e); + } + return null; + } + + private void initializeAzureSkills(final AnActionEvent event, final Project project) { if (Registry.is("azure.skills.autoconfigure.disabled", false)) { logTelemetryEvent("azure-skills-initialization-disabled"); - return null; + return; } logTelemetryEvent("azure-skills-initialization-started"); @@ -76,29 +95,44 @@ public Object execute(@NotNull Project project, @NotNull Continuation installAzureSkills(npxPath, project)); + } + }); + + notification.addAction(new NotificationAction("Don\u2019t Install") { + @Override + public void actionPerformed(@NotNull AnActionEvent e, @NotNull Notification notification) { + notification.expire(); + setConsentState(CONSENT_DECLINED); + logTelemetryEvent("azure-skills-user-declined"); + } + }); + + Notifications.Bus.notify(notification, project); + } + + private String getConsentState() { + return IntellijStore.getInstance().getProperty(null, CONSENT_KEY); + } + + private void setConsentState(final String value) { + IntellijStore.getInstance().setProperty(null, CONSENT_KEY, value); + } + + private void showNotification(final Project project, final String title, final String content) { + final Notification notification = new Notification(NOTIFICATION_GROUP_ID, title, content, NotificationType.INFORMATION); + Notifications.Bus.notify(notification, project); + } + /** * Checks whether the GitHub Copilot plugin is installed and enabled. */ @@ -137,6 +218,36 @@ private boolean isCopilotPluginInstalled() { return copilotPlugin != null && copilotPlugin.isEnabled(); } + /** + * Checks whether the "Agent Skills" option is enabled in GitHub Copilot settings + * (Settings → Tools → GitHub Copilot → Chat → {@code enableSkills}). + *

+ * Uses reflection because the Copilot plugin is an optional runtime dependency. + * Returns {@code true} if the setting cannot be read (fail-open). + */ + private boolean isCopilotSkillsEnabled() { + try { + final Class settingsClass = Class.forName( + "com.github.copilot.settings.CopilotApplicationSettings"); + final Object settingsInstance = ApplicationManager.getApplication().getService(settingsClass); + if (settingsInstance == null) { + log.warn("CopilotApplicationSettings service not found, assuming skills enabled"); + return true; + } + final Object state = settingsClass.getMethod("getState").invoke(settingsInstance); + if (state == null) { + log.warn("CopilotApplicationSettings state is null, assuming skills enabled"); + return true; + } + final java.lang.reflect.Field enableSkillsField = state.getClass().getDeclaredField("enableSkills"); + enableSkillsField.setAccessible(true); + return enableSkillsField.getBoolean(state); + } catch (final Exception ex) { + log.warn("Failed to read Copilot enableSkills setting, assuming skills enabled: " + ex.getMessage()); + return true; + } + } + /** * Checks whether Azure Skills are already installed by looking for * skill subdirectories under {@code ~/.agents/skills/}.