Generated banner for Hve Core Installer
Generated logo for Hve Core Installer

Hve Core Installer

MicrosoftOfficialPublished by Microsoft

  • Skill
  • Workflow
  • Automation and integrations
  • MIT
  • Low risk
  • Has scripts

From the author

Decision-driven HVE-Core installer for the Copilot harness: the plugin, a team default in repository settings, a full component copy into the repository, the VS Code extension, and clone-based storage, with environment detection, validation, and MCP setup
— the description by Microsoft

AutoGineer summary

Written by AutoGineer from the skill’s own files, not by the author

The HVE-Core Installer is a guided, multi-phase tool designed to help users integrate HVE-Core components into their development environment. It supports multiple installation routes, including a direct plugin, team-wide defaults, native repository adoption, a VS Code extension, or a clone-based setup. The skill operates through two personas: an Installer that handles environment detection and execution, and a Validator that ensures the configuration is correctly recognized by Copilot. It includes robust checks for existing installations, environment-specific recommendations, and post-installation setup for MCP servers and repository settings.

What it does

  • Detects existing HVE-Core installations and offers upgrade or cleanup paths.
  • Provides a decision matrix to recommend the optimal installation method based on environment and team needs.
  • Automates the installation of plugins, extensions, or repository-native component copies.
  • Configures repository-level settings for team-wide HVE-Core availability.
  • Validates installations to ensure agents and skills are correctly loaded in Copilot Chat.
  • Handles post-installation tasks like gitignore updates and MCP server configuration.

When to use it

  • When you need to integrate HVE-Core agents and skills into a new or existing project.
  • When you require a team-wide, reproducible HVE-Core configuration.
  • When you need to pin a specific version of HVE-Core for development or customization.
  • When you want to migrate from an older, manual installation to a supported harness-based setup.

Advantages

  • Flexible installation options tailored to specific development environments.
  • Reduces configuration errors through automated validation and environment detection.
  • Supports both plugin-based and native repository-owned installation models.
  • Provides clear guidance on updating and maintaining HVE-Core components.
  • Ensures compatibility with the Copilot harness by managing settings and cleanup.

Usage examples

“Install HVE-Core in my project.”
The agent initiates the environment detection phase, asks for consent, and presents the five available installation routes.
“I want to set up HVE-Core for my whole team.”
The agent recommends the Team Default route and guides the user through creating the necessary .github/copilot/settings.json file.
“How do I update my HVE-Core installation?”
The agent identifies the current installation route and provides the specific commands or steps required to perform an upgrade.

Install

  1. Download the skill as a ZIP:

    https://autogineer.com/agent-skills/microsoft/hve-core-installer/download
  2. Unzip it into the cross-client skills folder of your project:

    mkdir -p .agents/skills && unzip hve-core-installer.zip -d .agents/skills

Documentation by Microsoft

HVE-Core Installer Skill

Decision-driven installer for HVE-Core in VS Code's Copilot agent harness. It recommends the HVE-Core plugin first, then a team default in .github/copilot/settings.json, then native repository adoption, which copies every HVE-Core component into the project's own .github folders.

The VS Code extension remains an editor option. A clone is a storage choice that Copilot uses as a plugin from source or, for a copy inside the project's checkout, through folders declared in .github/copilot/settings.json. The skill also validates the result and configures MCP servers.

Role Definition

Operate as two collaborating personas:

  • The Installer persona detects the environment, guides route selection, and executes installation steps

  • The Validator persona verifies installed folders and repository settings, then has the user confirm that HVE-Core agents and skills appear in Copilot Chat

The Installer persona handles all detection and execution. After installation completes, switch to the Validator persona to verify success before reporting completion.

Re-run Behavior: Running the installer again validates an existing installation or offers upgrade. Safe to re-run anytime.

Required Phases

PhaseNamePurpose
1Environment DetectionObtain consent and check for earlier HVE-Core installs
2Install Route SelectionChoose the plugin, team default, native adoption, extension, or clone route
3Environment Detection & Decision MatrixFor native adoption and clones: detect the environment and recommend where to keep the clone
4Installation MethodsClone HVE-Core and, for the clone route, connect the clone to Copilot
5ValidationVerify the clone before it is used
6Post-Installation SetupConfigure gitignore and MCP servers, then reload the window and check Copilot Chat
7Component InstallationNative adoption: copy every component into the project's .github folders

Flow paths:

  • Plugin, team default, and extension routes: Phase 1 → Phase 2 → Phase 6 → Complete

  • Native adoption: Phase 1 → Phase 2 → Phase 3 → Phase 4 → Phase 5 → Phase 7 → Phase 6 → Complete

  • Clone route: Phase 1 → Phase 2 → Phase 3 → Phase 4 → Phase 5 → Phase 6 → Complete

For native adoption from a clone the user already has, skip Phases 3 and 4 and validate that clone in Phase 5.

[!IMPORTANT] Install HVE-Core through one route at a time. Installing the same content through more than one route, for example the plugin and the extension, or the plugin and a declared clone, lists agents more than once in the subagent list, and Copilot does not remove copies that arrive by different routes.

Phase 1: Environment Detection

Before presenting options, obtain consent and check what is already installed.

Present the following and await explicit consent:

🚀 HVE-Core Installer

I'll help you install HVE-Core agents, instructions and skills.

Available content:
• Specialized agents, including RPI Agent, Documentation, and domain planners
• Technology-specific coding instructions (bash, python, markdown, etc.)
• Domain-specific skills (pr-reference, etc.)

I'll recommend an install route. If your route needs a clone, I'll ask 2-3 questions about your setup first.

Would you like to proceed?

If user declines, respond: "Installation cancelled. You can invoke this skill anytime to restart."

Existing Install Check

Upon consent, look for HVE-Core content that is already installed:

  • .hve-tracking.json at the workspace root means components were copied into the repository. Offer to upgrade them through native adoption, which runs Phase 7 in upgrade mode.

  • .github/copilot/settings.json that enables hve-core@hve-core or declares folders from an HVE-Core clone means the team default or a declared clone is in place.

  • Ask whether the user has installed the HVE-Core plugin or the HVE Core extension.

  • VS Code settings that list HVE-Core folders come from an earlier version of this installer. Note the files that hold them. Phase 6 offers the cleanup below once the user confirms HVE-Core in a Copilot session.

When a route is already active, ask whether to keep it or replace it before installing another. Then proceed to Phase 2.

Earlier Location Settings Cleanup

Earlier versions of this installer added HVE-Core folders to chat.agentFilesLocations, chat.instructionsFilesLocations, chat.agentSkillsLocations, chat.promptFilesLocations, and chat.hookFilesLocations. Those settings are editor-only: VS Code forwards files from those locations, but Copilot CLI and the GitHub Copilot app do not read them. Kept beside a new route, they deliver the same content twice.

HVE-Core supports only the Copilot harness. VS Code's Local harness doesn't load the skills: preloads that HVE-Core agents depend on, and removing these settings while the user still works in Local hides the agents Local found through them. Offer the cleanup only after the user confirms both of these:

  • Their chat sessions use the Copilot harness. VS Code shows the harness at the start of the chat title.

  • A Copilot session shows HVE-Core, using the agent and skill checks from the final completion report in Phase 6.

When the user is still on Local, explain that HVE-Core supports only the Copilot harness and that they switch by starting a new chat and selecting Copilot in the Session Target control before the first prompt. Keep the settings until the user confirms both points, and report "⏭️ Kept earlier location settings until a Copilot session shows HVE-Core".

Look in .vscode/settings.json, any .code-workspace file, and the customizations.vscode.settings block of .devcontainer/devcontainer.json for entries whose paths point into an HVE-Core clone, such as ../hve-core, .hve-core, /workspaces/hve-core, or lib/hve-core. For each file with such entries:

  1. Show the user the HVE-Core entries it holds.

  2. Get approval under Checkpoint 3 (see Authorization Guardrails).

  3. Remove only the HVE-Core entries. Remove a setting only when it has no entries left, and leave unrelated entries alone.

  4. Report "✅ Removed earlier HVE-Core location settings from [file]", or "⏭️ Kept earlier location settings in [file]" when the user declines.

After removing entries, ask the user to run Developer: Reload Window and repeat the agent and skill checks, so they confirm that the installed route, not the old settings, delivers HVE-Core.

Phase 2: Install Route Selection

Present the route choice before any environment detection. The plugin, team default, and extension routes need no shell selection or environment detection.

Checkpoint 2: Install Route Choice

Present the following choice:

<!-- <install-route-checkpoint> -->

🚀 Choose How to Install HVE-Core

**Option 1: Plugin (Recommended)**
Install the HVE-Core plugin with two commands in Copilot Chat.
• 📦 All agents, skills, and instructions
• 🔄 Updates from the Plugins page

**Option 2: Team Default**
I add .github/copilot/settings.json so Copilot offers the plugin
to everyone who opens this repository.

**Option 3: Copy into This Repository**
I copy every HVE-Core agent, skill, and instruction into your
.github folders. Your repository owns the copies.

**Option 4: VS Code Extension**
Install from the VS Code Marketplace. Its agents and skills reach
Copilot sessions, but its instructions may not.

**Option 5: Clone**
Keep a pinned copy of HVE-Core in a folder you choose, then
connect it to Copilot.

Use one route at a time. Which would you prefer? (1-5)

<!-- </install-route-checkpoint> -->

User input handling:

  • "1", "plugin" → Plugin Route

  • "2", "team", "team default" → Team Default Route

  • "3", "copy", "adopt", "native", "components" → Native adoption: continue to Shell Selection and Prerequisites

  • "4", "extension", "vsix" → Extension Installation Execution

  • "5", "clone", "submodule" → Clone route: continue to Shell Selection and Prerequisites

  • Unclear response → Ask for clarification

Recommend a route other than the plugin when:

  • A team wants Copilot to offer HVE-Core to everyone who opens the repository (Option 2)

  • The repository should own and customize its copy of HVE-Core (Option 3)

  • The user needs a pinned copy of HVE-Core or plans to edit HVE-Core itself (Option 5)

Plugin Route

The plugin carries the full set: agents, skills, and path-specific instructions. Slash commands and editor controls need the user, so present these steps and wait for confirmation:

<!-- <plugin-install-steps> -->

📦 Install the HVE-Core Plugin

In Copilot Chat with the Copilot harness, send these commands one at a time:
  /plugin marketplace add microsoft/hve-core
  /plugin install hve-core@hve-core

Or open the Agent Customizations editor, select Plugins, add the
microsoft/hve-core marketplace, and install hve-core.

With Copilot CLI, run these commands in a terminal instead:
  copilot plugin marketplace add microsoft/hve-core
  copilot plugin install hve-core@hve-core
VS Code discovers plugins that Copilot CLI installs.

Reply "done" when the plugin is installed.

<!-- </plugin-install-steps> -->

Run the Copilot CLI commands only when the user asks you to. After the user confirms, proceed to Phase 6.

Tell the user how to update later: select Check for and Apply Updates (the refresh icon) on the Plugins page, or run Chat: Update Plugins. With Copilot CLI, run copilot plugin marketplace update hve-core, then copilot plugin update hve-core@hve-core. After any update, run Developer: Reload Window.

Team Default Route

A project can commit .github/copilot/settings.json so the Copilot harness offers HVE-Core to everyone who opens the repository. Each person can turn optional plugins off on the Plugins page, and enterprise-managed settings take precedence over these defaults.

Get approval under Checkpoint 3, then create .github/copilot/settings.json with the content below. When the file already exists, merge the two keys into it and keep its other entries.

<!-- <team-default-settings> -->

{
  "extraKnownMarketplaces": {
    "hve-core": {
      "source": {
        "source": "github",
        "repo": "microsoft/hve-core"
      }
    }
  },
  "enabledPlugins": {
    "hve-core@hve-core": true
  }
}

<!-- </team-default-settings> -->

The marketplace key must match the marketplace name, hve-core. Report "✅ Added the HVE-Core plugin default to .github/copilot/settings.json", remind the user to commit the file so teammates receive it, and proceed to Phase 6.

Extension Installation Execution

The ise-hve-essentials.hve-core extension is an editor option. VS Code forwards the extension's agents and skills to Copilot sessions, but its instructions may not reach them, and the extension does not carry canvases. Recommend the plugin when the user needs the instructions.

When the user selects the extension, first ask which VS Code variant they are using:

<!-- <vscode-variant-prompt> -->

Which VS Code variant are you using?

  [1] VS Code (stable)
  [2] VS Code Insiders

Your choice? (1/2)

<!-- </vscode-variant-prompt> -->

User input handling:

  • "1", "code", "stable" → Use code CLI

  • "2", "insiders", "code-insiders" → Use code-insiders CLI

  • Unclear response → Ask for clarification

Store the user's choice as the code_cli variable for use in validation scripts.

Display progress message:

📥 Installing HVE Core extension from marketplace...

Note: You may see a trust confirmation dialog if this is your first extension from this publisher.

Execute VS Code CLI command:

<code_cli> --install-extension ise-hve-essentials.hve-core

After command execution, proceed to Extension Validation.

Extension Validation

Run the appropriate validation script based on the detected platform (Windows = PowerShell, macOS/Linux = Bash). Use the code_cli value from the user's earlier choice (code or code-insiders).

PowerShell: Run scripts/validate-extension.ps1 with the code_cli variable set.

Bash: Run scripts/validate-extension.sh with the code_cli variable set.

Extension Success Report

Upon successful validation, display:

<!-- <extension-success-report> -->

✅ Extension Installation Complete!

The HVE Core extension has been installed from the VS Code Marketplace.

📦 Extension: ise-hve-essentials.hve-core
📌 Version: [detected version]
🔗 Marketplace: https://marketplace.visualstudio.com/items?itemName=ise-hve-essentials.hve-core

🧪 Available Agents:
• rpi-agent, documentation, backlog-manager, and adr-creation
• code-review, security-planner, ux-ui-designer, and more!

ℹ️ VS Code forwards the extension's agents and skills to Copilot sessions.
   Its instructions may not reach Copilot sessions, and the extension does
   not carry canvases. Use the plugin when you need the instructions.

📋 Configuring optional settings...

<!-- </extension-success-report> -->

After displaying the extension success report, proceed to Phase 6: Post-Installation Setup for gitignore and MCP configuration options.

Extension Error Recovery

If extension installation fails, provide targeted guidance:

<!-- <extension-error-recovery> -->

Error ScenarioUser MessageRecovery Action
Trust dialog declined"Installation was cancelled. You may have declined the publisher trust prompt."Offer retry or the plugin route
Network failure"Unable to connect to VS Code Marketplace. Check your network connection."Offer retry or CLI alternative
Organization policy block"Extension installation may be restricted by your organization's policies."Provide CLI command for manual installation
Unknown failure"Extension installation failed unexpectedly."Offer the plugin route as a fallback

<!-- </extension-error-recovery> -->

Flow Control After Failure:

If extension installation fails and user cannot resolve:

  • Offer: "Would you like to install the HVE-Core plugin instead? (yes/no)"

  • If yes: Continue to the Plugin Route

  • If no: End session with manual installation instructions

Shell Selection and Prerequisites

Native adoption and the clone route run scripts, so ask for a shell first:

  • Ask: "Which shell would you prefer? (powershell/bash)"

  • Shell detection rules:

    • "powershell", "pwsh", "ps1", "ps" → PowerShell

    • "bash", "sh", "zsh" → Bash

    • Unclear response → Windows = PowerShell, macOS/Linux = Bash

Before cloning, verify git is available:

  • Run: git --version

  • If fails: "Git is required to clone HVE-Core. Install git, or choose the plugin route instead."

When the user selects Bash, also verify jq is available:

  • Run: jq --version

  • If fails: "jq is required by the Bash component scripts. Install jq or choose PowerShell."

Then run the Environment Detection Script and continue to Phase 3.

Environment Detection Script

Run the appropriate detection script based on the user's shell:

PowerShell: Run scripts/detect-environment.ps1

Bash: Run scripts/detect-environment.sh

Phase 3: Environment Detection & Decision Matrix

Phase 3 recommends where to keep the clone. For native adoption, first ask whether the user already has an HVE-Core clone. When they do, use its path as the copy source and skip to Phase 5.

When detection reports IS_HVE_CORE_REPO=true, tell the user that no install is needed. Copilot discovers the repository's own .github/agents, .github/skills, and .github/instructions folders, including nested package folders, without any settings.

Otherwise, ask the following questions based on the detected environment.

Question 1: Environment Confirmation

Present options filtered by detection results:

<!-- <question-1-environment> -->

### Question 1: What's your development environment?

Based on my detection, you appear to be in: [DETECTED_ENV_TYPE]

Please confirm or correct:

| Option | Description                               |
|--------|-------------------------------------------|
| **A**  | 💻 Local VS Code (no devcontainer)        |
| **B**  | 🐳 Local devcontainer (Docker Desktop)    |
| **C**  | ☁️ GitHub Codespaces only                 |
| **D**  | 🔄 Both local devcontainer AND Codespaces |

Which best describes your setup? (A/B/C/D)

<!-- </question-1-environment> -->

Question 2: Team or Solo

<!-- <question-2-team> -->

### Question 2: Team or solo development?

| Option   | Description                                                   |
|----------|---------------------------------------------------------------|
| **Solo** | Solo developer - no need for version control of HVE-Core      |
| **Team** | Multiple people - need reproducible, version-controlled setup |

Are you working solo or with a team? (solo/team)

<!-- </question-2-team> -->

Question 3: Update Preference

Ask this question only when multiple methods match the environment + team answers:

<!-- <question-3-updates> -->

### Question 3: Update preference?

| Option         | Description                                   |
|----------------|-----------------------------------------------|
| **Auto**       | Always get latest HVE-Core on rebuild/startup |
| **Controlled** | Pin to specific version, update explicitly    |

How would you like to receive updates? (auto/controlled)

<!-- </question-3-updates> -->

Decision Matrix

Use this matrix to recommend where to keep the clone and how Copilot uses it. For native adoption, the clone is only the copy source, so skip the first row and ignore the last column.

<!-- <decision-matrix> -->

EnvironmentTeamUpdatesRecommended MethodCopilot Uses It Through
Any (simplest)Any-Plugin (Option 1, no clone needed)The plugin
Local (no container)Solo-Method 1: Peer ClonePlugin from source
Local (no container)TeamControlledMethod 6: Submodule.github/copilot/settings.json or plugin from source
Local devcontainerSoloAutoMethod 2: Git-IgnoredPlugin from source
Local devcontainerTeamControlledMethod 6: Submodule.github/copilot/settings.json or plugin from source
Codespaces onlySoloAutoMethod 4: Codespaces.github/copilot/settings.json
Codespaces onlyTeamControlledMethod 6: Submodule.github/copilot/settings.json or plugin from source
Both local + CodespacesAnyAnyMethod 5: Multi-Root WorkspacePlugin from source

<!-- </decision-matrix> -->

Folders declared in .github/copilot/settings.json must sit inside the project's checkout. Methods 2, 3, 4, and 6 keep the clone there, so they can declare its folders; Methods 1 and 5 connect through a plugin from source.

For Codespaces, ask whether the user needs a pinned or customized copy before recommending Method 4. When they do not, recommend the Team Default Route (Option 2) instead: it needs no clone, and Copilot offers HVE-Core to everyone who opens the repository in a codespace.

Method Selection Logic

After gathering answers:

  1. Match answers to decision matrix

  2. Present recommendation with rationale

  3. Offer alternative if user prefers different approach

<!-- <recommendation-template> -->

## 📋 Your Recommended Setup

Based on your answers:
* **Environment**: [answer]
* **Team**: [answer]
* **Updates**: [answer]

### ✅ Recommended: Method [N] - [Name]

**Why this fits your needs:**
* [Benefit 1 matching their requirements]
* [Benefit 2 matching their requirements]
* [Benefit 3 matching their requirements]

**How Copilot uses it:** [Plugin from source, folders declared in .github/copilot/settings.json, or copy source for native adoption]

Would you like to proceed with this method, or see alternatives?

<!-- </recommendation-template> -->

Phase 4: Installation Methods

Each clone method, and both ways to connect a clone to Copilot, are documented end to end in references/installation-methods.md. Follow the method resolved by the decision matrix there. For native adoption, clone the source and leave it unconnected. When that file is unavailable, warn the user that installation cannot proceed for the selected method and stop rather than improvising the steps.

Phase 5: Validation (Validator Persona)

After installation completes, switch to the Validator persona and verify the clone.

[!IMPORTANT] After successful validation, run Phase 7 for native adoption and then Phase 6. For the clone route, proceed to Phase 6.

Validation Workflow

Run validation based on the selected method. Set the base path variable before running:

MethodBase Path
1../hve-core
2, 3, 4.hve-core
5Check workspace file
6lib/hve-core

For a clone the user already had, use its path as the base path with method 1, which checks only the agents, instructions, and skills folders.

PowerShell: Run scripts/validate-installation.ps1 with the method and basePath variables set.

Bash: Run scripts/validate-installation.sh with the method number and base path as arguments.

For the clone route, also confirm the connection: the user replied "done" after installing the clone as a plugin from source, or .github/copilot/settings.json declares the clone's three folders.

Success Report

Upon successful validation, display:

<!-- <success-report> -->

✅ Core Installation Complete!

Method [N]: [Name] installed successfully.

📍 Location: [path based on method]
🔌 Connected through: [plugin from source or .github/copilot/settings.json]
📖 Documentation: https://github.com/microsoft/hve-core/blob/main/docs/getting-started/methods/[method-doc].md

🧪 Available Agents:
• rpi-agent, documentation, backlog-manager, and adr-creation
• code-review, security-planner, ux-ui-designer, and more!

📋 Configuring optional settings...

<!-- </success-report> -->

For native adoption, skip this report. Display "✅ HVE-Core source ready at [path]" and proceed to Phase 7.

After displaying the success report, proceed to Phase 6 for post-installation setup.

Phase 6: Post-Installation Setup

Read references/post-installation-setup.md and perform every step it specifies before continuing. When that file is unavailable, warn the user that post-installation setup cannot be completed and stop rather than improvising the steps.

Phase 7: Component Installation and Upgrade

Read references/component-installation.md and follow its component installation and upgrade procedures exactly, including every confirmation gate it defines. Phase 7 runs only for native adoption, including upgrade mode when .hve-tracking.json exists.

For Methods 3 and 5, and for a clone the user already had, verify the pre-existing HVE-Core clone's configured upstream source before collision detection or copy, using the warning and confirmation behavior in that reference. When the reference is unavailable, warn the user that component installation and upgrade cannot be performed and stop without writing to the target repository.

Error Recovery

Provide targeted guidance when steps fail:

<!-- <error-recovery> -->

ErrorTroubleshooting
Not in git repoRun from within a git workspace; verify git --version
Clone failedCheck network to github.com; verify git credentials and write permissions
Validation failedRepository may be incomplete; delete HVE-Core directory and re-run installer
Settings update failedVerify .github/copilot/settings.json is valid JSON and check write permissions
Declared folders not loadingTrust the workspace folder; declared folders load only in a trusted folder and must be literal paths inside the checkout
Agents listed twiceMore than one route is installed; keep one route and remove the others
Agents or skills missingConfirm the chat uses the Copilot harness, run Developer: Reload Window, then start a new chat session

<!-- </error-recovery> -->

Rollback

To remove a failed or unwanted installation:

Route or MethodCleanup
PluginManage hve-core on the Plugins page of the Agent Customizations editor
Team defaultRemove the hve-core entries from extraKnownMarketplaces and enabledPlugins in .github/copilot/settings.json
Native adoptionDelete .hve-tracking.json and any copied .github/agents/, .github/prompts/, .github/instructions/, or .github/skills/ content you no longer need
ExtensionVS Code → Extensions → HVE Core → Uninstall
1 (Peer Clone)rm -rf ../hve-core
2 (Git-Ignored)rm -rf .hve-core
3-4 (Mounted/Codespaces)Remove the mount or postCreateCommand from devcontainer.json and rebuild the container; for Method 4, also delete .hve-core
5 (Multi-Root)Delete .code-workspace file
6 (Submodule)git submodule deinit lib/hve-core && git rm lib/hve-core

For a clone, also undo its connection: manage a plugin installed from source on the Plugins page, or remove the clone's folders from agentDirectories, skillDirectories, and instructionDirectories in .github/copilot/settings.json. When an earlier installer version added HVE-Core entries to the chat.*Locations settings, remove them as described in Earlier Location Settings Cleanup. Then run Developer: Reload Window.

Authorization Guardrails

Never modify files without explicit user authorization. Always explain changes before making them. Respect denial at any checkpoint.

Checkpoints requiring authorization:

  1. Initial Consent (Phase 1), before starting detection

  2. File Change Authorization (Checkpoint 3), before writing .github/copilot/settings.json, devcontainer.json, or a .code-workspace file, and before removing earlier location settings

Checkpoint 3: File Change Authorization

Before each of those changes, present the following:

⚙️ File Update

I will now update [file] for HVE-Core.

Changes to be made:
• [Each key, path, or entry to add or remove]

⚠️ Authorization Required: Do you authorize these changes? (yes/no)

If the user declines, respond "No changes were made to [file]." Then skip the change and explain how the route is affected, or stop when the route cannot work without it.

Output Format Requirements

Progress Reporting

Use these exact emojis for consistency:

In-progress indicators (always end with ellipsis ...):

  • "📂 Detecting environment..."

  • "🔍 Asking configuration questions..."

  • "📋 Recommending installation method..."

  • "📥 Installing HVE-Core..."

  • "🔍 Validating installation..."

  • "⚙️ Updating settings..."

  • "🛡️ Configuring gitignore..."

  • "📡 Configuring MCP servers..."

Completion indicators:

  • "✅ [Success message]"

  • "❌ [Error message]"

  • "⏭️ [Skipped message]"

Success Criteria

The installation succeeds when the user has one install route in place, every file change was authorized, any clone passed validation, the user ran Developer: Reload Window, and the agent and skill checks for that route passed in Copilot Chat.

It fails when detection fails, a clone or submodule fails, validation finds missing directories, a settings or copy step fails, or the agent and skill checks still fail after a reload.

Files

tests/component-copy.Tests.ps1

#Requires -Modules Pester
# Copyright (c) 2026 Microsoft Corporation. All rights reserved.
# SPDX-License-Identifier: MIT

# Discovery-time capability probe: the Bash parity fixtures execute the real
# component-copy.sh, which needs both an interpreter and jq. On Windows `bash`
# resolves to the WSL launcher in System32, which cannot execute a script named
# by a Windows path, so it does not count as an available interpreter.
$script:BashCommand = Get-Command bash -ErrorAction SilentlyContinue
if ($IsWindows -and $script:BashCommand -and
    $script:BashCommand.Source -eq (Join-Path $env:SystemRoot 'System32/bash.exe')) {
    $script:BashCommand = $null
}
$script:BashAvailable = [bool]$script:BashCommand -and [bool](Get-Command jq -ErrorAction SilentlyContinue)

BeforeAll {
    $script:PowerShellScript = (Resolve-Path (Join-Path $PSScriptRoot '../scripts/component-copy.ps1')).Path
    $script:BashScript = (Resolve-Path (Join-Path $PSScriptRoot '../scripts/component-copy.sh')).Path
    $script:RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../../../../..')).Path
    $script:FixtureCounter = 0

    function script:New-ComponentCopyFixture {
        param([string]$Version = '3.3.106')

        $script:FixtureCounter++
        $root = Join-Path $TestDrive "component-copy-$($script:FixtureCounter)"
        $source = Join-Path $root 'source'
        $target = Join-Path $root 'target'

        $sourceFiles = @{
            '.github/agents/hve-core/rpi-agent.agent.md'                  = '# RPI Agent'
            '.github/agents/hve-core/subagents/rpi-planner.agent.md'      = '# RPI Planner'
            '.github/agents/experimental/pptx.agent.md'                   = '# PPTX'
            '.github/prompts/hve-core/rpi.prompt.md'                      = '# RPI Prompt'
            '.github/instructions/hve-core/copilot-tracking.instructions.md' = '# Tracking'
            '.github/skills/rpi/rpi-plan/SKILL.md'                        = '# RPI Plan Skill'
            '.github/skills/rpi/rpi-plan/references/notes.md'             = '# Notes'
            '.github/skills/rpi/rpi-plan/.venv/lib/site.py'               = 'sentinel_venv'
            '.github/skills/rpi/rpi-plan/.VENV/lib/upper.py'              = 'sentinel_upper_venv'
            '.github/skills/rpi/rpi-plan/tests/test_plan.py'              = 'sentinel_tests'
            '.github/skills/rpi/rpi-plan/.PyTest_Cache/result.txt'        = 'sentinel_upper_cache'
            '.github/skills/rpi/rpi-plan/.git/config'                     = 'sentinel_git'
            '.github/skills/rpi/rpi-plan/.env'                            = 'sentinel_env'
            '.github/skills/rpi/rpi-plan/.env.local'                      = 'sentinel_env_local'
            '.github/skills/rpi/rpi-plan/.DS_Store'                       = 'sentinel_ds_store'
            '.github/skills/rpi/rpi-plan/Thumbs.db'                       = 'sentinel_thumbs_db'
            '.github/hooks/shared/telemetry.json'                         = '{}'
        }
        foreach ($relative in $sourceFiles.Keys) {
            $full = Join-Path $source $relative
            New-Item -ItemType Directory -Path (Split-Path $full -Parent) -Force | Out-Null
            Set-Content -LiteralPath $full -Value $sourceFiles[$relative] -NoNewline
        }

        $symlinkAvailable = $true
        try {
            New-Item -ItemType SymbolicLink `
                -Path (Join-Path $source '.github/skills/rpi/rpi-plan/linked-notes.md') `
                -Target (Join-Path $source '.github/skills/rpi/rpi-plan/references/notes.md') `
                -ErrorAction Stop | Out-Null
            New-Item -ItemType SymbolicLink `
                -Path (Join-Path $source '.github/skills/rpi/rpi-plan/linked-references') `
                -Target (Join-Path $source '.github/skills/rpi/rpi-plan/references') `
                -ErrorAction Stop | Out-Null
        }
        catch {
            $symlinkAvailable = $false
        }

        $manifest = [ordered]@{
            name     = 'hve-core'
            version  = $Version
            agents   = @(
                '.github/agents/hve-core/rpi-agent.agent.md'
                '.github/agents/hve-core/subagents/rpi-planner.agent.md'
                '.github/agents/experimental/pptx.agent.md'
            )
            commands = @('.github/prompts/hve-core/rpi.prompt.md')
            rules    = @('.github/instructions/hve-core/copilot-tracking.instructions.md')
            skills   = @('.github/skills/rpi/rpi-plan')
        }
        $pluginManifestPath = Join-Path $source 'plugin.json'
        $manifest | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $pluginManifestPath -NoNewline

        Set-Content -LiteralPath (Join-Path $source 'package.json') -Value "{ `"version`": `"$Version`" }" -NoNewline
        New-Item -ItemType Directory -Path $target -Force | Out-Null

        return [pscustomobject]@{ Root = $root; Source = $source; Target = $target; SymlinkAvailable = $symlinkAvailable }
    }

    function script:Invoke-ComponentCopy {
        param(
            [pscustomobject]$Fixture,
            [string[]]$Component,
            [string]$SelectionName = 'custom',
            [switch]$ReportOnly,
            [switch]$KeepExisting,
            [string[]]$Collisions = @()
        )

        $arguments = @{
            HveCoreBasePath = $Fixture.Source
            TargetRoot      = $Fixture.Target
            SelectionName   = $SelectionName
            Component       = $Component
        }
        if ($ReportOnly) { $arguments['ReportOnly'] = $true }
        if ($KeepExisting) { $arguments['KeepExisting'] = $true }
        if ($Collisions.Count -gt 0) { $arguments['Collisions'] = $Collisions }

        return (& $script:PowerShellScript @arguments 6>&1 | Out-String)
    }

    function script:Invoke-BashComponentCopy {
        param(
            [pscustomobject]$Fixture,
            [string[]]$Component,
            [string]$SelectionName = 'custom',
            [hashtable]$Environment = @{}
        )

        $saved = @{}
        foreach ($key in $Environment.Keys) {
            $saved[$key] = [System.Environment]::GetEnvironmentVariable($key)
            [System.Environment]::SetEnvironmentVariable($key, $Environment[$key])
        }
        try {
            $output = & bash $script:BashScript $Fixture.Source $Fixture.Target $SelectionName @Component 2>&1 | Out-String
        }
        finally {
            foreach ($key in $Environment.Keys) { [System.Environment]::SetEnvironmentVariable($key, $saved[$key]) }
        }
        return $output
    }

    function script:Get-TrackingManifest {
        param([pscustomobject]$Fixture)
        return Get-Content -LiteralPath (Join-Path $Fixture.Target '.hve-tracking.json') -Raw | ConvertFrom-Json -AsHashtable
    }

    function script:Get-TargetFile {
        param([pscustomobject]$Fixture)
        $base = (Resolve-Path -LiteralPath $Fixture.Target).Path
        return @(Get-ChildItem -LiteralPath $Fixture.Target -Recurse -File -Force |
                ForEach-Object { ($_.FullName.Substring($base.Length) -replace '\\', '/').TrimStart('/') } |
                Sort-Object)
    }
}

Describe 'component-copy parameter contract' -Tag 'Unit' {
    BeforeAll {
        $script:command = Get-Command -Name $script:PowerShellScript
        $script:powerShellSource = Get-Content -LiteralPath $script:PowerShellScript -Raw
        $script:bashSource = Get-Content -LiteralPath $script:BashScript -Raw
    }

    It 'Declares the mandatory selection and target parameters' {
        foreach ($name in @('HveCoreBasePath', 'TargetRoot', 'SelectionName', 'Component')) {
            $script:command.Parameters.Keys | Should -Contain $name
            $attributes = @($script:command.Parameters[$name].Attributes | Where-Object { $_ -is [System.Management.Automation.ParameterAttribute] })
            @($attributes | Where-Object { $_.Mandatory }).Count | Should -Be 1 -Because "$name identifies what is copied and where"
        }
    }

    It 'Declares no package identity parameter' {
        $script:command.Parameters.Keys | Should -Not -Contain 'PackageName'
        $script:powerShellSource | Should -Not -Match '(?i)PackageName'
        $script:bashSource | Should -Not -Match 'package_name'
        $script:bashSource | Should -Match '<selection_name> <component\.\.\.>'
    }

    It 'Resolves membership from the plugin manifest in the PowerShell implementation' {
        $script:powerShellSource | Should -Match "Join-Path \`$sourceRoot 'plugin\.json'"
        $script:powerShellSource | Should -Not -Match '\.github/plugin\.json'
        $script:powerShellSource | Should -Not -Match '(?i)marketplace'
        $script:powerShellSource | Should -Match '\$SelectionName'
        $script:powerShellSource | Should -Match "schemaVersion = \`$schemaVersion"
    }

    It 'Resolves membership from the plugin manifest in the Bash implementation' {
        $script:bashSource | Should -Match '\$source_root/plugin\.json'
        $script:bashSource | Should -Not -Match '\.github/plugin\.json'
        $script:bashSource | Should -Not -Match '(?i)marketplace'
        $script:bashSource | Should -Match 'selection_name'
        $script:bashSource | Should -Match 'schemaVersion: \$schema'
    }
}

Describe 'component-copy path mapping' -Tag 'Unit' {
    BeforeEach { $script:fixture = New-ComponentCopyFixture }

    It 'Maps every kind to its canonical target root without flattening paths' {
        Invoke-ComponentCopy -Fixture $script:fixture -Component @(
            'agents/hve-core/subagents/rpi-planner.md'
            'commands/hve-core/rpi.md'
            'rules/hve-core/copilot-tracking.instructions.md'
        ) | Out-Null

        Get-TargetFile -Fixture $script:fixture | Should -Be @(
            '.github/agents/hve-core/subagents/rpi-planner.agent.md'
            '.github/instructions/hve-core/copilot-tracking.instructions.md'
            '.github/prompts/hve-core/rpi.prompt.md'
            '.hve-tracking.json'
        )
    }

    It 'Copies source content verbatim' {
        Invoke-ComponentCopy -Fixture $script:fixture -Component @('agents/hve-core/rpi-agent.md') | Out-Null

        Get-Content -LiteralPath (Join-Path $script:fixture.Target '.github/agents/hve-core/rpi-agent.agent.md') -Raw |
            Should -Be '# RPI Agent'
    }

    It 'Copies a skill as a complete directory' {
        Invoke-ComponentCopy -Fixture $script:fixture -Component @('skills/rpi/rpi-plan') | Out-Null

        Test-Path -LiteralPath (Join-Path $script:fixture.Target '.github/skills/rpi/rpi-plan/SKILL.md') | Should -BeTrue
        Test-Path -LiteralPath (Join-Path $script:fixture.Target '.github/skills/rpi/rpi-plan/references/notes.md') | Should -BeTrue
    }

    It 'Excludes local environment, test, VCS, secret-prone, and OS artifact paths from a skill copy' {
        Invoke-ComponentCopy -Fixture $script:fixture -Component @('skills/rpi/rpi-plan') | Out-Null

        Test-Path -LiteralPath (Join-Path $script:fixture.Target '.github/skills/rpi/rpi-plan/.venv') | Should -BeFalse
        Test-Path -LiteralPath (Join-Path $script:fixture.Target '.github/skills/rpi/rpi-plan/.VENV') | Should -BeFalse
        Test-Path -LiteralPath (Join-Path $script:fixture.Target '.github/skills/rpi/rpi-plan/tests') | Should -BeFalse
        Test-Path -LiteralPath (Join-Path $script:fixture.Target '.github/skills/rpi/rpi-plan/.PyTest_Cache') | Should -BeFalse
        Test-Path -LiteralPath (Join-Path $script:fixture.Target '.github/skills/rpi/rpi-plan/.git') | Should -BeFalse
        Test-Path -LiteralPath (Join-Path $script:fixture.Target '.github/skills/rpi/rpi-plan/.env') | Should -BeFalse
        Test-Path -LiteralPath (Join-Path $script:fixture.Target '.github/skills/rpi/rpi-plan/.env.local') | Should -BeFalse
        Test-Path -LiteralPath (Join-Path $script:fixture.Target '.github/skills/rpi/rpi-plan/.DS_Store') | Should -BeFalse
        Test-Path -LiteralPath (Join-Path $script:fixture.Target '.github/skills/rpi/rpi-plan/Thumbs.db') | Should -BeFalse

        $trackedPaths = @((Get-TrackingManifest -Fixture $script:fixture).files.Keys)
        @($trackedPaths | Where-Object { $_ -match '/(\.git|\.env(?:\..+)?|\.DS_Store|Thumbs\.db)(/|$)' }) |
            Should -BeNullOrEmpty
    }

    It 'Copies and tracks a skill filename containing a newline through the Bash implementation' -Skip:($IsWindows -or -not $script:BashAvailable) {
        $relative = ".github/skills/rpi/rpi-plan/line`nbreak.md"
        $sourceFile = Join-Path $script:fixture.Source $relative
        Set-Content -LiteralPath $sourceFile -Value '# Newline name' -NoNewline

        Invoke-BashComponentCopy -Fixture $script:fixture -Component @('skills/rpi/rpi-plan') | Out-Null

        $LASTEXITCODE | Should -Be 0
        Test-Path -LiteralPath (Join-Path $script:fixture.Target $relative) | Should -BeTrue
        (Get-TrackingManifest -Fixture $script:fixture).files.Keys | Should -Contain $relative
    }

    It 'Omits file symlinks and does not traverse directory symlinks' {
        if (-not $script:fixture.SymlinkAvailable) { Set-ItResult -Skipped -Because 'symbolic links are unavailable'; return }

        Invoke-ComponentCopy -Fixture $script:fixture -Component @('skills/rpi/rpi-plan') | Out-Null

        Test-Path -LiteralPath (Join-Path $script:fixture.Target '.github/skills/rpi/rpi-plan/linked-notes.md') | Should -BeFalse
        Test-Path -LiteralPath (Join-Path $script:fixture.Target '.github/skills/rpi/rpi-plan/linked-references') | Should -BeFalse
        @((Get-TrackingManifest -Fixture $script:fixture).files.Keys | Where-Object { $_ -match 'linked-' }) | Should -BeNullOrEmpty
    }

    It 'Copies a repeated component once' {
        Invoke-ComponentCopy -Fixture $script:fixture -Component @('skills/rpi/rpi-plan', 'skills/rpi/rpi-plan/') | Out-Null

        @((Get-TrackingManifest -Fixture $script:fixture).selection.components) | Should -Be @('skills/rpi/rpi-plan')
    }

    It 'Overwrites an existing managed file' {
        $existing = Join-Path $script:fixture.Target '.github/agents/hve-core/rpi-agent.agent.md'
        New-Item -ItemType Directory -Path (Split-Path $existing -Parent) -Force | Out-Null
        Set-Content -LiteralPath $existing -Value '# Local edit' -NoNewline

        Invoke-ComponentCopy -Fixture $script:fixture -Component @('agents/hve-core/rpi-agent.md') | Out-Null

        Get-Content -LiteralPath $existing -Raw | Should -Be '# RPI Agent'
    }
}

Describe 'component-copy tracking manifest' -Tag 'Unit' {
    BeforeEach { $script:fixture = New-ComponentCopyFixture -Version '3.3.106' }

    It 'Writes exactly the version 2 manifest keys' {
        Invoke-ComponentCopy -Fixture $script:fixture -Component @('agents/hve-core/rpi-agent.md') -SelectionName 'starter' | Out-Null

        $manifest = Get-TrackingManifest -Fixture $script:fixture
        @($manifest.Keys | Sort-Object) | Should -Be @('files', 'installed', 'schemaVersion', 'selection', 'source', 'version')
        $manifest.schemaVersion | Should -Be 2
        $manifest.Keys | Should -Not -Contain 'package'
    }

    It 'Records the source repository, version, and ISO 8601 install timestamp' {
        Invoke-ComponentCopy -Fixture $script:fixture -Component @('agents/hve-core/rpi-agent.md') | Out-Null

        $manifest = Get-TrackingManifest -Fixture $script:fixture
        $manifest.source | Should -Be 'microsoft/hve-core'
        $manifest.version | Should -Be '3.3.106'
        [System.DateTimeOffset]::TryParse([string]$manifest.installed, [ref]([System.DateTimeOffset]::MinValue)) | Should -BeTrue
    }

    It 'Records the selection profile and component paths' {
        Invoke-ComponentCopy -Fixture $script:fixture -SelectionName 'starter' -Component @('skills/rpi/rpi-plan', 'agents/hve-core/rpi-agent.md') | Out-Null

        $selection = (Get-TrackingManifest -Fixture $script:fixture).selection
        @($selection.Keys) | Should -Be @('profile', 'components')
        $selection.profile | Should -Be 'starter'
        @($selection.components) | Should -Be @('agents/hve-core/rpi-agent.md', 'skills/rpi/rpi-plan')
    }

    It 'Persists no package identity' {
        Invoke-ComponentCopy -Fixture $script:fixture -Component @('agents/hve-core/rpi-agent.md') | Out-Null

        Get-Content -LiteralPath (Join-Path $script:fixture.Target '.hve-tracking.json') -Raw |
            Should -Not -Match '"package"'
    }

    It 'Records component, kind, maturity, version, hash, and status for each file' {
        Invoke-ComponentCopy -Fixture $script:fixture -Component @('agents/experimental/pptx.md') | Out-Null

        $entry = (Get-TrackingManifest -Fixture $script:fixture).files['.github/agents/experimental/pptx.agent.md']
        @($entry.Keys | Sort-Object) | Should -Be @('component', 'kind', 'maturity', 'sha256', 'status', 'version')
        $entry.component | Should -Be 'agents/experimental/pptx.md'
        $entry.kind | Should -Be 'agent'
        $entry.maturity | Should -Be 'stable'
        $entry.version | Should -Be '3.3.106'
        $entry.status | Should -Be 'managed'
        $entry.sha256 | Should -Be (Get-FileHash -LiteralPath (Join-Path $script:fixture.Target '.github/agents/experimental/pptx.agent.md') -Algorithm SHA256).Hash.ToLower()
    }

    It 'Records the schema-default maturity the manifest does not declare' {
        Invoke-ComponentCopy -Fixture $script:fixture -Component @('agents/hve-core/rpi-agent.md') | Out-Null

        (Get-TrackingManifest -Fixture $script:fixture).files['.github/agents/hve-core/rpi-agent.agent.md'].maturity | Should -Be 'stable'
    }

    It 'Keys every skill file to its owning skill component' {
        Invoke-ComponentCopy -Fixture $script:fixture -Component @('skills/rpi/rpi-plan') | Out-Null

        $files = (Get-TrackingManifest -Fixture $script:fixture).files
        $trackedKeys = [string[]]@($files.Keys)
        [array]::Sort($trackedKeys, [System.StringComparer]::Ordinal)
        $trackedKeys | Should -Be @(
            '.github/skills/rpi/rpi-plan/SKILL.md'
            '.github/skills/rpi/rpi-plan/references/notes.md'
        )
        foreach ($key in $files.Keys) {
            $files[$key].component | Should -Be 'skills/rpi/rpi-plan'
            $files[$key].kind | Should -Be 'skill'
        }
    }

    It 'Persists no absolute target root' {
        Invoke-ComponentCopy -Fixture $script:fixture -Component @('agents/hve-core/rpi-agent.md') | Out-Null

        Get-Content -LiteralPath (Join-Path $script:fixture.Target '.hve-tracking.json') -Raw |
            Should -Not -Match ([regex]::Escape((Resolve-Path -LiteralPath $script:fixture.Target).Path))
    }
}

Describe 'component-copy preflight rejection' -Tag 'Unit' {
    BeforeEach { $script:fixture = New-ComponentCopyFixture }

    It 'Rejects a traversal component path before any write' {
        { Invoke-ComponentCopy -Fixture $script:fixture -Component @('agents/../../etc/passwd') } |
            Should -Throw -ExpectedMessage '*relative path segments*'

        Get-TargetFile -Fixture $script:fixture | Should -BeNullOrEmpty
    }

    It 'Rejects an absolute component path' {
        { Invoke-ComponentCopy -Fixture $script:fixture -Component @('/etc/passwd') } |
            Should -Throw -ExpectedMessage '*relative to the package root*'
    }

    It 'Rejects a backslash component path' {
        { Invoke-ComponentCopy -Fixture $script:fixture -Component @('agents\hve-core\rpi-agent.md') } |
            Should -Throw -ExpectedMessage '*forward slashes*'
    }

    It 'Rejects a component outside the four installable kinds' {
        { Invoke-ComponentCopy -Fixture $script:fixture -Component @('hooks/shared/telemetry.json') } |
            Should -Throw -ExpectedMessage '*agents, commands, rules, skills*'
    }

    It 'Rejects a partial skill selection' {
        { Invoke-ComponentCopy -Fixture $script:fixture -Component @('skills/rpi/rpi-plan/SKILL.md') } |
            Should -Throw -ExpectedMessage '*not declared in the plugin manifest*'

        Get-TargetFile -Fixture $script:fixture | Should -BeNullOrEmpty
    }

    It 'Rejects a component that the plugin manifest does not declare' {
        { Invoke-ComponentCopy -Fixture $script:fixture -Component @('agents/hve-core/absent.md') } |
            Should -Throw -ExpectedMessage '*not declared in the plugin manifest*'
    }

    It 'Rejects a declared component whose source is missing' {
        Remove-Item -LiteralPath (Join-Path $script:fixture.Source '.github/agents/hve-core/rpi-agent.agent.md') -Force

        { Invoke-ComponentCopy -Fixture $script:fixture -Component @('agents/hve-core/rpi-agent.md') } |
            Should -Throw -ExpectedMessage '*has no source file*'

        Test-Path -LiteralPath (Join-Path $script:fixture.Target '.hve-tracking.json') | Should -BeFalse
    }

    It 'Rejects a target root that does not exist' {
        { & $script:PowerShellScript -HveCoreBasePath $script:fixture.Source -TargetRoot (Join-Path $script:fixture.Root 'absent') `
            -SelectionName 'custom' -Component @('agents/hve-core/rpi-agent.md') } | Should -Throw -ExpectedMessage '*TargetRoot*'
    }

    It 'Rejects a source without a plugin manifest' {
        Remove-Item -LiteralPath (Join-Path $script:fixture.Source 'plugin.json') -Force

        { Invoke-ComponentCopy -Fixture $script:fixture -Component @('agents/hve-core/rpi-agent.md') } |
            Should -Throw -ExpectedMessage '*Plugin manifest not found*'
    }

    It 'Rejects a plugin manifest that declares no installable component' {
        '{ "name": "hve-core", "version": "1.0.0" }' |
            Set-Content -LiteralPath (Join-Path $script:fixture.Source 'plugin.json') -NoNewline

        { Invoke-ComponentCopy -Fixture $script:fixture -Component @('agents/hve-core/rpi-agent.md') } |
            Should -Throw -ExpectedMessage '*declares no installable components*'
    }

    It 'Rejects installable manifest entries outside .github' {
        $manifestPath = Join-Path $script:fixture.Source 'plugin.json'
        $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
        $manifest.agents = @('agents/hve-core/rpi-agent.agent.md')
        $manifest | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $manifestPath -NoNewline

        { Invoke-ComponentCopy -Fixture $script:fixture -Component @('agents/hve-core/rpi-agent.md') } |
            Should -Throw -ExpectedMessage "*must start with '.github/'*"
    }

    It 'Rejects installable manifest entries outside .github in the Bash implementation' -Skip:(-not $script:BashAvailable) {
        $bashFixture = New-ComponentCopyFixture
        $manifestPath = Join-Path $bashFixture.Source 'plugin.json'
        $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
        $manifest.agents = @('agents/hve-core/rpi-agent.agent.md')
        $manifest | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $manifestPath -NoNewline

        $bashOutput = Invoke-BashComponentCopy -Fixture $bashFixture -Component @('agents/hve-core/rpi-agent.md')

        $LASTEXITCODE | Should -Not -Be 0
        $bashOutput | Should -Match "must start with '\.github/'"
    }
}

Describe 'component-copy manifest schema gate' -Tag 'Unit' {
    BeforeEach { $script:fixture = New-ComponentCopyFixture }

    It 'Rejects a version 1 manifest with clean-reinstall guidance before any write' {
        $legacy = @{ source = 'microsoft/hve-core'; version = '3.3.100'; package = 'hve-core'; files = @{} }
        $legacy | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath (Join-Path $script:fixture.Target '.hve-tracking.json')

        { Invoke-ComponentCopy -Fixture $script:fixture -Component @('agents/hve-core/rpi-agent.md') } |
            Should -Throw -ExpectedMessage '*clean reinstall*'

        Test-Path -LiteralPath (Join-Path $script:fixture.Target '.github/agents') | Should -BeFalse
    }

    It 'Rejects an unsupported future schema version' {
        @{ schemaVersion = 3; files = @{} } | ConvertTo-Json -Depth 10 |
            Set-Content -LiteralPath (Join-Path $script:fixture.Target '.hve-tracking.json')

        { Invoke-ComponentCopy -Fixture $script:fixture -Component @('agents/hve-core/rpi-agent.md') } |
            Should -Throw -ExpectedMessage "*schemaVersion '3'*"
    }
}

Describe 'component-copy collision retention and eject' -Tag 'Unit' {
    BeforeEach { $script:fixture = New-ComponentCopyFixture }

    It 'Leaves a retained collision component untouched' {
        $existing = Join-Path $script:fixture.Target '.github/skills/rpi/rpi-plan/SKILL.md'
        New-Item -ItemType Directory -Path (Split-Path $existing -Parent) -Force | Out-Null
        Set-Content -LiteralPath $existing -Value '# Local skill' -NoNewline

        $output = Invoke-ComponentCopy -Fixture $script:fixture -Component @('skills/rpi/rpi-plan', 'agents/hve-core/rpi-agent.md') `
            -KeepExisting -Collisions @('skills/rpi/rpi-plan')

        Get-Content -LiteralPath $existing -Raw | Should -Be '# Local skill'
        $output | Should -Match 'Kept existing: skills/rpi/rpi-plan'
        @((Get-TrackingManifest -Fixture $script:fixture).files.Keys) | Should -Be @('.github/agents/hve-core/rpi-agent.agent.md')
    }

    It 'Preserves an ejected entry and leaves its file untouched' {
        Invoke-ComponentCopy -Fixture $script:fixture -Component @('agents/hve-core/rpi-agent.md') | Out-Null
        $manifestPath = Join-Path $script:fixture.Target '.hve-tracking.json'
        $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json -AsHashtable
        $manifest.files['.github/agents/hve-core/rpi-agent.agent.md'].status = 'ejected'
        $manifest.files['.github/agents/hve-core/rpi-agent.agent.md'].ejectedAt = '2026-08-02T00:00:00Z'
        $manifest | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $manifestPath
        Set-Content -LiteralPath (Join-Path $script:fixture.Target '.github/agents/hve-core/rpi-agent.agent.md') -Value '# User owned' -NoNewline

        $output = Invoke-ComponentCopy -Fixture $script:fixture -Component @('agents/hve-core/rpi-agent.md')

        $output | Should -Match 'Skipped ejected: \.github/agents/hve-core/rpi-agent\.agent\.md'
        Get-Content -LiteralPath (Join-Path $script:fixture.Target '.github/agents/hve-core/rpi-agent.agent.md') -Raw | Should -Be '# User owned'
        $entry = (Get-TrackingManifest -Fixture $script:fixture).files['.github/agents/hve-core/rpi-agent.agent.md']
        $entry.status | Should -Be 'ejected'
        Get-Content -LiteralPath $manifestPath -Raw | Should -Match '"ejectedAt": "2026-08-02T00:00:00Z"'
    }

    It 'Preserves omitted managed records across a narrower selection' {
        Invoke-ComponentCopy -Fixture $script:fixture -Component @('agents/hve-core/rpi-agent.md', 'skills/rpi/rpi-plan') | Out-Null
        $before = Get-TrackingManifest -Fixture $script:fixture

        Invoke-ComponentCopy -Fixture $script:fixture -Component @('agents/hve-core/rpi-agent.md') | Out-Null
        $after = Get-TrackingManifest -Fixture $script:fixture

        @($after.files.Keys | Sort-Object) | Should -Be @($before.files.Keys | Sort-Object)
        @($after.selection.components) | Should -Be @('agents/hve-core/rpi-agent.md', 'skills/rpi/rpi-plan')
        $after.files['.github/skills/rpi/rpi-plan/SKILL.md'].status | Should -Be 'managed'
    }

    It 'Preserves an omitted ejected record and never overwrites it after re-inclusion' {
        Invoke-ComponentCopy -Fixture $script:fixture -Component @('agents/hve-core/rpi-agent.md', 'skills/rpi/rpi-plan') | Out-Null
        $manifestPath = Join-Path $script:fixture.Target '.hve-tracking.json'
        $manifest = Get-TrackingManifest -Fixture $script:fixture
        $agentPath = '.github/agents/hve-core/rpi-agent.agent.md'
        $manifest.files[$agentPath].status = 'ejected'
        $manifest.files[$agentPath].ejectedAt = '2026-08-03T00:00:00Z'
        $manifest | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $manifestPath
        Set-Content -LiteralPath (Join-Path $script:fixture.Target $agentPath) -Value '# User owned' -NoNewline

        Invoke-ComponentCopy -Fixture $script:fixture -Component @('skills/rpi/rpi-plan') | Out-Null
        Invoke-ComponentCopy -Fixture $script:fixture -Component @('agents/hve-core/rpi-agent.md') | Out-Null

        $after = Get-TrackingManifest -Fixture $script:fixture
        $after.files[$agentPath].status | Should -Be 'ejected'
        Get-Content -LiteralPath $manifestPath -Raw | Should -Match '"ejectedAt": "2026-08-03T00:00:00Z"'
        Get-Content -LiteralPath (Join-Path $script:fixture.Target $agentPath) -Raw | Should -Be '# User owned'
        @($after.selection.components) | Should -Be @('agents/hve-core/rpi-agent.md', 'skills/rpi/rpi-plan')
    }
}

Describe 'component-copy report-only preflight' -Tag 'Unit' {
    BeforeEach { $script:fixture = New-ComponentCopyFixture }

    It 'Reports canonical maturity for every component without writing' {
        $output = Invoke-ComponentCopy -Fixture $script:fixture -ReportOnly -Component @('agents/experimental/pptx.md', 'skills/rpi/rpi-plan')

        $output | Should -Match 'COMPONENT=agents/experimental/pptx\.md\|KIND=agent\|MATURITY=stable\|TARGET=\.github/agents/experimental/pptx\.agent\.md\|EXISTS=false'
        $output | Should -Match 'COMPONENT=skills/rpi/rpi-plan\|KIND=skill\|MATURITY=stable\|TARGET=\.github/skills/rpi/rpi-plan\|EXISTS=false'
        $output | Should -Match 'COLLISIONS_DETECTED=false'
        Get-TargetFile -Fixture $script:fixture | Should -BeNullOrEmpty
    }

    It 'Reports a file collision on the full target path' {
        $existing = Join-Path $script:fixture.Target '.github/agents/hve-core/rpi-agent.agent.md'
        New-Item -ItemType Directory -Path (Split-Path $existing -Parent) -Force | Out-Null
        Set-Content -LiteralPath $existing -Value '# Local' -NoNewline

        $output = Invoke-ComponentCopy -Fixture $script:fixture -ReportOnly -Component @('agents/hve-core/rpi-agent.md')

        $output | Should -Match 'COLLISIONS_DETECTED=true'
        $output | Should -Match 'COLLISION_COMPONENTS=agents/hve-core/rpi-agent\.md'
        $output | Should -Match 'COLLISION_TARGETS=\.github/agents/hve-core/rpi-agent\.agent\.md'
    }

    It 'Reports a skill collision on the target directory' {
        New-Item -ItemType Directory -Path (Join-Path $script:fixture.Target '.github/skills/rpi/rpi-plan') -Force | Out-Null

        $output = Invoke-ComponentCopy -Fixture $script:fixture -ReportOnly -Component @('skills/rpi/rpi-plan')

        $output | Should -Match 'COLLISION_COMPONENTS=skills/rpi/rpi-plan'
        $output | Should -Match 'COLLISION_TARGETS=\.github/skills/rpi/rpi-plan'
    }
}

Describe 'component-copy production manifest selection' -Tag 'Unit' {
    BeforeAll {
        # A real subset of root plugin.json membership, so the production
        # manifest itself gates the selection rather than a test-local recipe.
        $script:ProductionSelection = @(
            'agents/hve-core/rpi-agent.md'
            'rules/hve-core/hve-builder.instructions.md'
            'skills/hve-core/git-commit'
            'skills/rpi/rpi-plan'
        )
        $script:ProductionTarget = Join-Path $TestDrive 'production-selection'
        New-Item -ItemType Directory -Path $script:ProductionTarget -Force | Out-Null
    }

    It 'Copies the selected components into the canonical target layout' {
        & $script:PowerShellScript -HveCoreBasePath $script:RepoRoot -TargetRoot $script:ProductionTarget `
            -SelectionName 'starter' -Component $script:ProductionSelection 6>&1 | Out-Null

        Test-Path -LiteralPath (Join-Path $script:ProductionTarget '.github/agents/hve-core/rpi-agent.agent.md') | Should -BeTrue
        Test-Path -LiteralPath (Join-Path $script:ProductionTarget '.github/skills/hve-core/git-commit/SKILL.md') | Should -BeTrue
        Test-Path -LiteralPath (Join-Path $script:ProductionTarget '.github/instructions/hve-core/hve-builder.instructions.md') | Should -BeTrue
        Test-Path -LiteralPath (Join-Path $script:ProductionTarget '.github/skills/rpi/rpi-plan/SKILL.md') | Should -BeTrue
    }

    It 'Tracks every selected component without a package field' {
        $manifest = Get-Content -LiteralPath (Join-Path $script:ProductionTarget '.hve-tracking.json') -Raw | ConvertFrom-Json -AsHashtable

        $manifest.schemaVersion | Should -Be 2
        $manifest.selection.Keys | Should -Not -Contain 'package'
        $manifest.selection.profile | Should -Be 'starter'
        @($manifest.selection.components | Sort-Object) | Should -Be @($script:ProductionSelection | Sort-Object)
    }

    It 'Rejects a real component that the production manifest excludes' {
        $rejectTarget = Join-Path $TestDrive 'production-reject'
        New-Item -ItemType Directory -Path $rejectTarget -Force | Out-Null

        { & $script:PowerShellScript -HveCoreBasePath $script:RepoRoot -TargetRoot $rejectTarget `
                -SelectionName 'custom' -Component @('skills/security/owasp-docker') } |
            Should -Throw -ExpectedMessage '*not declared in the plugin manifest*'

        @(Get-ChildItem -LiteralPath $rejectTarget -Recurse -File -Force) | Should -BeNullOrEmpty
    }

    It 'Copies the same selection with Bash against the live manifest' -Skip:(-not $script:BashAvailable) {
        $bashTarget = Join-Path $TestDrive 'production-selection-bash'
        New-Item -ItemType Directory -Path $bashTarget -Force | Out-Null

        Invoke-BashComponentCopy -Fixture ([pscustomobject]@{ Source = $script:RepoRoot; Target = $bashTarget }) `
            -SelectionName 'starter' -Component $script:ProductionSelection | Out-Null
        $LASTEXITCODE | Should -Be 0

        Test-Path -LiteralPath (Join-Path $bashTarget '.github/skills/hve-core/git-commit/SKILL.md') | Should -BeTrue
        Test-Path -LiteralPath (Join-Path $bashTarget '.github/instructions/hve-core/hve-builder.instructions.md') | Should -BeTrue

        $manifest = Get-Content -LiteralPath (Join-Path $bashTarget '.hve-tracking.json') -Raw | ConvertFrom-Json -AsHashtable
        $manifest.schemaVersion | Should -Be 2
        $manifest.selection.Keys | Should -Not -Contain 'package'
        @($manifest.selection.components | Sort-Object) |
            Should -Be @($script:ProductionSelection | Sort-Object) -Because 'Bash must install exactly the selected components regardless of emitted order'
    }
}

Describe 'component-copy PowerShell and Bash parity' -Tag 'Unit' -Skip:(-not $script:BashAvailable) {
    BeforeEach {
        $script:powerShellFixture = New-ComponentCopyFixture -Version '3.3.106'
        $script:bashFixture = New-ComponentCopyFixture -Version '3.3.106'
        $script:AllComponents = @(
            'agents/experimental/pptx.md'
            'agents/hve-core/subagents/rpi-planner.md'
            'commands/hve-core/rpi.md'
            'rules/hve-core/copilot-tracking.instructions.md'
            'skills/rpi/rpi-plan'
        )
    }

    It 'Copies the same files with the same content' {
        Invoke-ComponentCopy -Fixture $script:powerShellFixture -SelectionName 'starter' -Component $script:AllComponents | Out-Null
        Invoke-BashComponentCopy -Fixture $script:bashFixture -SelectionName 'starter' -Component $script:AllComponents | Out-Null

        $powerShellFiles = Get-TargetFile -Fixture $script:powerShellFixture
        Get-TargetFile -Fixture $script:bashFixture | Should -Be $powerShellFiles

        foreach ($relative in ($powerShellFiles | Where-Object { $_ -ne '.hve-tracking.json' })) {
            Get-Content -LiteralPath (Join-Path $script:bashFixture.Target $relative) -Raw |
                Should -Be (Get-Content -LiteralPath (Join-Path $script:powerShellFixture.Target $relative) -Raw) -Because "'$relative' must not depend on the interpreter"
        }
    }

    It 'Writes equivalent manifests' {
        Invoke-ComponentCopy -Fixture $script:powerShellFixture -SelectionName 'starter' -Component $script:AllComponents | Out-Null
        Invoke-BashComponentCopy -Fixture $script:bashFixture -SelectionName 'starter' -Component $script:AllComponents | Out-Null

        $powerShellManifest = Get-TrackingManifest -Fixture $script:powerShellFixture
        $bashManifest = Get-TrackingManifest -Fixture $script:bashFixture

        @($bashManifest.Keys | Sort-Object) | Should -Be @($powerShellManifest.Keys | Sort-Object)
        $bashManifest.schemaVersion | Should -Be $powerShellManifest.schemaVersion
        $bashManifest.source | Should -Be $powerShellManifest.source
        $bashManifest.version | Should -Be $powerShellManifest.version
        @($bashManifest.selection.Keys) | Should -Be @($powerShellManifest.selection.Keys)
        $bashManifest.selection.profile | Should -Be $powerShellManifest.selection.profile
        @($bashManifest.selection.components) | Should -Be @($powerShellManifest.selection.components)
        @($bashManifest.files.Keys | Sort-Object) | Should -Be @($powerShellManifest.files.Keys | Sort-Object)

        foreach ($key in $powerShellManifest.files.Keys) {
            foreach ($field in @('component', 'kind', 'maturity', 'version', 'sha256', 'status')) {
                $bashManifest.files[$key][$field] | Should -Be $powerShellManifest.files[$key][$field] -Because "'$key' field '$field' must match"
            }
        }
        [System.DateTimeOffset]::TryParse([string]$bashManifest.installed, [ref]([System.DateTimeOffset]::MinValue)) | Should -BeTrue
    }

    It 'Produces identical copy output' {
        $powerShellOutput = (Invoke-ComponentCopy -Fixture $script:powerShellFixture -SelectionName 'starter' -Component $script:AllComponents).Trim()
        $bashOutput = (Invoke-BashComponentCopy -Fixture $script:bashFixture -SelectionName 'starter' -Component $script:AllComponents).Trim()

        $bashOutput | Should -Be $powerShellOutput
    }

    It 'Accepts adjacent dots inside a valid filename in both implementations' {
        $sourceRelative = '.github/agents/hve-core/foo..bar.agent.md'
        $manifestRelative = '.github/agents/hve-core/foo..bar.agent.md'
        $component = 'agents/hve-core/foo..bar.md'
        foreach ($fixture in @($script:powerShellFixture, $script:bashFixture)) {
            Set-Content -LiteralPath (Join-Path $fixture.Source $sourceRelative) -Value '# Adjacent dots' -NoNewline
            $manifestPath = Join-Path $fixture.Source 'plugin.json'
            $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
            $manifest.agents = @($manifest.agents) + $manifestRelative
            $manifest | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $manifestPath -NoNewline
        }

        $powerShellOutput = (Invoke-ComponentCopy -Fixture $script:powerShellFixture -Component @($component)).Trim()
        $bashOutput = (Invoke-BashComponentCopy -Fixture $script:bashFixture -Component @($component)).Trim()

        $LASTEXITCODE | Should -Be 0
        $bashOutput | Should -Be $powerShellOutput
        foreach ($fixture in @($script:powerShellFixture, $script:bashFixture)) {
            Test-Path -LiteralPath (Join-Path $fixture.Target $sourceRelative) | Should -BeTrue
        }
    }

    It 'Produces identical report-only output' {
        New-Item -ItemType Directory -Path (Join-Path $script:powerShellFixture.Target '.github/skills/rpi/rpi-plan') -Force | Out-Null
        New-Item -ItemType Directory -Path (Join-Path $script:bashFixture.Target '.github/skills/rpi/rpi-plan') -Force | Out-Null

        $powerShellOutput = (Invoke-ComponentCopy -Fixture $script:powerShellFixture -ReportOnly -Component $script:AllComponents).Trim()
        $bashOutput = (Invoke-BashComponentCopy -Fixture $script:bashFixture -Component $script:AllComponents -Environment @{ REPORT_ONLY = 'true' }).Trim()

        $bashOutput | Should -Be $powerShellOutput
        $powerShellOutput | Should -Match 'COLLISIONS_DETECTED=true'
    }

    It 'Retains the same components under KEEP_EXISTING' {
        foreach ($fixture in @($script:powerShellFixture, $script:bashFixture)) {
            $existing = Join-Path $fixture.Target '.github/skills/rpi/rpi-plan/SKILL.md'
            New-Item -ItemType Directory -Path (Split-Path $existing -Parent) -Force | Out-Null
            Set-Content -LiteralPath $existing -Value '# Local skill' -NoNewline
        }
        $collisionsFile = Join-Path $script:bashFixture.Root 'collisions.txt'
        Set-Content -LiteralPath $collisionsFile -Value 'skills/rpi/rpi-plan'

        Invoke-ComponentCopy -Fixture $script:powerShellFixture -Component $script:AllComponents -KeepExisting -Collisions @('skills/rpi/rpi-plan') | Out-Null
        Invoke-BashComponentCopy -Fixture $script:bashFixture -Component $script:AllComponents -Environment @{ KEEP_EXISTING = 'true'; COLLISIONS_FILE = $collisionsFile } | Out-Null

        foreach ($fixture in @($script:powerShellFixture, $script:bashFixture)) {
            Get-Content -LiteralPath (Join-Path $fixture.Target '.github/skills/rpi/rpi-plan/SKILL.md') -Raw | Should -Be '# Local skill'
            @((Get-TrackingManifest -Fixture $fixture).files.Keys) | Should -Not -Contain '.github/skills/rpi/rpi-plan/SKILL.md'
        }
    }

    It 'Preserves identical tracking across broad and narrow reruns' {
        foreach ($fixture in @($script:powerShellFixture, $script:bashFixture)) {
            if ($fixture -eq $script:powerShellFixture) {
                Invoke-ComponentCopy -Fixture $fixture -Component $script:AllComponents | Out-Null
                Invoke-ComponentCopy -Fixture $fixture -Component @('agents/hve-core/subagents/rpi-planner.md') | Out-Null
            }
            else {
                Invoke-BashComponentCopy -Fixture $fixture -Component $script:AllComponents | Out-Null
                $LASTEXITCODE | Should -Be 0
                Invoke-BashComponentCopy -Fixture $fixture -Component @('agents/hve-core/subagents/rpi-planner.md') | Out-Null
                $LASTEXITCODE | Should -Be 0
            }
        }

        $powerShellManifest = Get-TrackingManifest -Fixture $script:powerShellFixture
        $bashManifest = Get-TrackingManifest -Fixture $script:bashFixture
        @($powerShellManifest.files.Keys | Sort-Object) | Should -Be @($bashManifest.files.Keys | Sort-Object)
        @($powerShellManifest.selection.components) | Should -Be @($bashManifest.selection.components)
        @($powerShellManifest.selection.components) | Should -Be $script:AllComponents
    }

    It 'Exits non-zero and writes nothing for a component outside manifest membership' {
        Invoke-BashComponentCopy -Fixture $script:bashFixture -Component @('agents/hve-core/absent.md') | Out-Null
        $LASTEXITCODE | Should -Not -Be 0

        Test-Path -LiteralPath (Join-Path $script:bashFixture.Target '.hve-tracking.json') | Should -BeFalse
    }

    It 'Exits non-zero for an unsupported manifest schema version' {
        $legacy = @{ source = 'microsoft/hve-core'; version = '3.3.100'; package = 'hve-core'; files = @{} }
        $legacy | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath (Join-Path $script:bashFixture.Target '.hve-tracking.json')

        $output = Invoke-BashComponentCopy -Fixture $script:bashFixture -Component @('agents/hve-core/rpi-agent.md')
        $LASTEXITCODE | Should -Not -Be 0
        $output | Should -Match 'clean reinstall'
        Test-Path -LiteralPath (Join-Path $script:bashFixture.Target '.github/agents') | Should -BeFalse
    }

    It 'Exits non-zero in both implementations when the plugin manifest is absent' {
        foreach ($fixture in @($script:powerShellFixture, $script:bashFixture)) {
            Remove-Item -LiteralPath (Join-Path $fixture.Source 'plugin.json') -Force
        }

        { Invoke-ComponentCopy -Fixture $script:powerShellFixture -Component @('agents/hve-core/rpi-agent.md') } |
            Should -Throw -ExpectedMessage '*Plugin manifest not found*'
        $bashOutput = Invoke-BashComponentCopy -Fixture $script:bashFixture -Component @('agents/hve-core/rpi-agent.md')
        $LASTEXITCODE | Should -Not -Be 0
        $bashOutput | Should -Match 'Plugin manifest not found'
    }
}

Describe 'component-copy destination containment' -Tag 'Unit' {
    BeforeEach {
        $script:containmentFixture = New-ComponentCopyFixture
        $script:outsideRoot = Join-Path $script:containmentFixture.Root 'outside'
        New-Item -ItemType Directory -Path $script:outsideRoot -Force | Out-Null
        Set-Content -LiteralPath (Join-Path $script:outsideRoot 'bystander.txt') -Value 'original' -NoNewline
    }

    It 'Refuses a component whose destination parent is a symbolic link' {
        if (-not $script:containmentFixture.SymlinkAvailable) {
            Set-ItResult -Skipped -Because 'symbolic links are unavailable'
            return
        }

        $linkPath = Join-Path $script:containmentFixture.Target '.github/skills'
        New-Item -ItemType Directory -Path (Split-Path $linkPath -Parent) -Force | Out-Null
        New-Item -ItemType SymbolicLink -Path $linkPath -Target $script:outsideRoot -ErrorAction Stop | Out-Null

        { Invoke-ComponentCopy -Fixture $script:containmentFixture -Component @('skills/rpi/rpi-plan') } |
            Should -Throw -ExpectedMessage '*resolves through a link*'

        # The escape is what matters, not only the exception: nothing may land
        # outside the target root.
        @(Get-ChildItem -LiteralPath $script:outsideRoot -File).Name | Should -Be @('bystander.txt')
    }

    It 'Refuses a component whose destination parent is a directory junction' {
        $junctionPath = Join-Path $script:containmentFixture.Target '.github/skills'
        New-Item -ItemType Directory -Path (Split-Path $junctionPath -Parent) -Force | Out-Null

        # New-Item -ItemType Junction reports success on non-Windows hosts while
        # creating nothing, so the junction is confirmed to exist and to carry
        # the ReparsePoint attribute before the case is treated as applicable.
        $junctionCreated = $false
        try {
            New-Item -ItemType Junction -Path $junctionPath -Target $script:outsideRoot -ErrorAction Stop | Out-Null
            if (Test-Path -LiteralPath $junctionPath) {
                $entry = Get-Item -LiteralPath $junctionPath -Force
                $junctionCreated = [bool]($entry.Attributes -band [System.IO.FileAttributes]::ReparsePoint)
            }
        }
        catch {
            $junctionCreated = $false
        }

        if (-not $junctionCreated) {
            Set-ItResult -Skipped -Because 'directory junctions are unavailable on this platform'
            return
        }

        { Invoke-ComponentCopy -Fixture $script:containmentFixture -Component @('skills/rpi/rpi-plan') } |
            Should -Throw -ExpectedMessage '*resolves through a link*'

        @(Get-ChildItem -LiteralPath $script:outsideRoot -File).Name | Should -Be @('bystander.txt')
    }

    It 'Installs normally when no destination ancestor is a link' {
        Invoke-ComponentCopy -Fixture $script:containmentFixture -Component @('skills/rpi/rpi-plan') | Out-Null

        Test-Path -LiteralPath (Join-Path $script:containmentFixture.Target '.github/skills/rpi/rpi-plan/SKILL.md') |
            Should -BeTrue
    }

    It 'Refuses a link planted between the preflight check and the write' {
        if (-not $script:containmentFixture.SymlinkAvailable) {
            Set-ItResult -Skipped -Because 'symbolic links are unavailable'
            return
        }

        # Preflight passes because the path is clean, then the parent is
        # replaced by a link before the copy. The write-site re-verification is
        # the only thing standing between that race and an escaped write.
        $agentsDir = Join-Path $script:containmentFixture.Target '.github/agents'
        New-Item -ItemType Directory -Path $agentsDir -Force | Out-Null
        Invoke-ComponentCopy -Fixture $script:containmentFixture -Component @('agents/hve-core/rpi-agent.md') | Out-Null

        Remove-Item -LiteralPath $agentsDir -Recurse -Force
        New-Item -ItemType SymbolicLink -Path $agentsDir -Target $script:outsideRoot -ErrorAction Stop | Out-Null

        { Invoke-ComponentCopy -Fixture $script:containmentFixture -Component @('agents/hve-core/rpi-agent.md') } |
            Should -Throw -ExpectedMessage '*resolves through a link*'

        @(Get-ChildItem -LiteralPath $script:outsideRoot -File).Name | Should -Be @('bystander.txt')
    }
}

Describe 'component-copy source and manifest link refusal' -Tag 'Unit' {
    BeforeEach {
        $script:linkFixture = New-ComponentCopyFixture
        $script:linkOutside = Join-Path $script:linkFixture.Root 'outside'
        New-Item -ItemType Directory -Path $script:linkOutside -Force | Out-Null
        Set-Content -LiteralPath (Join-Path $script:linkOutside 'secret.txt') -Value 'outside-secret' -NoNewline
    }

    It 'Refuses a skill whose source directory is a link, in normal and report-only modes' {
        if (-not $script:linkFixture.SymlinkAvailable) {
            Set-ItResult -Skipped -Because 'symbolic links are unavailable'
            return
        }

        $skillPath = Join-Path $script:linkFixture.Source '.github/skills/rpi/rpi-plan'
        Remove-Item -LiteralPath $skillPath -Recurse -Force
        Set-Content -LiteralPath (Join-Path $script:linkOutside 'SKILL.md') -Value '# Outside' -NoNewline
        New-Item -ItemType SymbolicLink -Path $skillPath -Target $script:linkOutside -ErrorAction Stop | Out-Null

        { Invoke-ComponentCopy -Fixture $script:linkFixture -Component @('skills/rpi/rpi-plan') } |
            Should -Throw -ExpectedMessage '*source resolves through a link*'
        { Invoke-ComponentCopy -Fixture $script:linkFixture -Component @('skills/rpi/rpi-plan') -ReportOnly } |
            Should -Throw -ExpectedMessage '*source resolves through a link*'

        Get-TargetFile -Fixture $script:linkFixture | Should -BeNullOrEmpty
    }

    It 'Refuses a component whose source ancestor directory is a link' {
        if (-not $script:linkFixture.SymlinkAvailable) {
            Set-ItResult -Skipped -Because 'symbolic links are unavailable'
            return
        }

        $ancestorPath = Join-Path $script:linkFixture.Source '.github/agents/hve-core'
        Remove-Item -LiteralPath $ancestorPath -Recurse -Force
        Set-Content -LiteralPath (Join-Path $script:linkOutside 'rpi-agent.agent.md') -Value '# Outside agent' -NoNewline
        New-Item -ItemType SymbolicLink -Path $ancestorPath -Target $script:linkOutside -ErrorAction Stop | Out-Null

        { Invoke-ComponentCopy -Fixture $script:linkFixture -Component @('agents/hve-core/rpi-agent.md') } |
            Should -Throw -ExpectedMessage '*source resolves through a link*'

        Get-TargetFile -Fixture $script:linkFixture | Should -BeNullOrEmpty
    }

    It 'Refuses a single-file component whose source file is a link' {
        if (-not $script:linkFixture.SymlinkAvailable) {
            Set-ItResult -Skipped -Because 'symbolic links are unavailable'
            return
        }

        $agentPath = Join-Path $script:linkFixture.Source '.github/agents/hve-core/rpi-agent.agent.md'
        Remove-Item -LiteralPath $agentPath -Force
        New-Item -ItemType SymbolicLink -Path $agentPath -Target (Join-Path $script:linkOutside 'secret.txt') -ErrorAction Stop | Out-Null

        { Invoke-ComponentCopy -Fixture $script:linkFixture -Component @('agents/hve-core/rpi-agent.md') } |
            Should -Throw -ExpectedMessage '*source resolves through a link*'

        Get-TargetFile -Fixture $script:linkFixture | Should -BeNullOrEmpty
    }

    It 'Refuses a tracking manifest that is a link and leaves its target unchanged' {
        if (-not $script:linkFixture.SymlinkAvailable) {
            Set-ItResult -Skipped -Because 'symbolic links are unavailable'
            return
        }

        $victim = Join-Path $script:linkOutside 'victim.json'
        Set-Content -LiteralPath $victim -Value '{"schemaVersion":2,"files":{}}' -NoNewline
        $before = [System.IO.File]::ReadAllBytes($victim)
        New-Item -ItemType SymbolicLink -Path (Join-Path $script:linkFixture.Target '.hve-tracking.json') -Target $victim -ErrorAction Stop | Out-Null

        { Invoke-ComponentCopy -Fixture $script:linkFixture -Component @('agents/hve-core/rpi-agent.md') } |
            Should -Throw -ExpectedMessage '*Tracking manifest*is a link*'

        [System.IO.File]::ReadAllBytes($victim) | Should -Be $before
        Test-Path -LiteralPath (Join-Path $script:linkFixture.Target '.github') | Should -BeFalse

        # A dangling link must be refused too, even where Test-Path reports it as
        # absent; the mock simulates PowerShell releases that behave that way.
        $manifestLink = Join-Path $script:linkFixture.Target '.hve-tracking.json'
        Remove-Item -LiteralPath $manifestLink -Force
        $danglingTarget = Join-Path $script:linkOutside 'dangling-manifest.json'
        New-Item -ItemType SymbolicLink -Path $manifestLink -Target $danglingTarget -ErrorAction Stop | Out-Null
        $realTestPath = Get-Command -Name Test-Path -CommandType Cmdlet
        Mock Test-Path { & $realTestPath @PesterBoundParameters }
        Mock Test-Path { return $false } -ParameterFilter { $LiteralPath -like '*.hve-tracking.json' }

        { Invoke-ComponentCopy -Fixture $script:linkFixture -Component @('agents/hve-core/rpi-agent.md') } |
            Should -Throw -ExpectedMessage '*Tracking manifest*is a link*'

        [System.IO.File]::Exists($danglingTarget) | Should -BeFalse
        [System.IO.Directory]::Exists((Join-Path $script:linkFixture.Target '.github')) | Should -BeFalse
    }
}

Describe 'component-copy containment parity' -Tag 'Unit' -Skip:(-not $script:BashAvailable) {
    BeforeEach {
        $script:parityFixture = New-ComponentCopyFixture
        $script:parityOutside = Join-Path $script:parityFixture.Root 'outside'
        New-Item -ItemType Directory -Path $script:parityOutside -Force | Out-Null
        Set-Content -LiteralPath (Join-Path $script:parityOutside 'bystander.txt') -Value 'original' -NoNewline
    }

    It 'Reaches the same refusal verdict in both shells for a symlinked destination parent' {
        if (-not $script:parityFixture.SymlinkAvailable) {
            Set-ItResult -Skipped -Because 'symbolic links are unavailable'
            return
        }

        $linkPath = Join-Path $script:parityFixture.Target '.github/skills'
        New-Item -ItemType Directory -Path (Split-Path $linkPath -Parent) -Force | Out-Null
        New-Item -ItemType SymbolicLink -Path $linkPath -Target $script:parityOutside -ErrorAction Stop | Out-Null

        $powerShellRefused = $false
        try { Invoke-ComponentCopy -Fixture $script:parityFixture -Component @('skills/rpi/rpi-plan') | Out-Null }
        catch { $powerShellRefused = $true }

        $bashOutput = Invoke-BashComponentCopy -Fixture $script:parityFixture -Component @('skills/rpi/rpi-plan')
        $bashRefused = $LASTEXITCODE -ne 0

        # Behavioural equivalence is the contract; message text is not required
        # to match between the two implementations.
        $bashRefused | Should -Be $powerShellRefused
        $bashOutput | Should -Match 'resolves through a link'
        @(Get-ChildItem -LiteralPath $script:parityOutside -File).Name | Should -Be @('bystander.txt')
    }

    It 'Reaches the same accept verdict in both shells for a clean destination' {
        Invoke-ComponentCopy -Fixture $script:parityFixture -Component @('agents/hve-core/rpi-agent.md') | Out-Null
        $powerShellInstalled = Test-Path -LiteralPath (Join-Path $script:parityFixture.Target '.github/agents/hve-core/rpi-agent.agent.md')

        $bashFixture = New-ComponentCopyFixture
        Invoke-BashComponentCopy -Fixture $bashFixture -Component @('agents/hve-core/rpi-agent.md') | Out-Null
        $bashInstalled = Test-Path -LiteralPath (Join-Path $bashFixture.Target '.github/agents/hve-core/rpi-agent.agent.md')

        $bashInstalled | Should -Be $powerShellInstalled
        $powerShellInstalled | Should -BeTrue
    }

    It 'Reaches the same refusal verdict in both shells for a linked source skill directory' {
        if (-not $script:parityFixture.SymlinkAvailable) {
            Set-ItResult -Skipped -Because 'symbolic links are unavailable'
            return
        }

        $skillPath = Join-Path $script:parityFixture.Source '.github/skills/rpi/rpi-plan'
        Remove-Item -LiteralPath $skillPath -Recurse -Force
        Set-Content -LiteralPath (Join-Path $script:parityOutside 'SKILL.md') -Value '# Outside' -NoNewline
        New-Item -ItemType SymbolicLink -Path $skillPath -Target $script:parityOutside -ErrorAction Stop | Out-Null

        $powerShellRefused = $false
        try { Invoke-ComponentCopy -Fixture $script:parityFixture -Component @('skills/rpi/rpi-plan') | Out-Null }
        catch { $powerShellRefused = $true }

        $bashOutput = Invoke-BashComponentCopy -Fixture $script:parityFixture -Component @('skills/rpi/rpi-plan')
        $bashRefused = $LASTEXITCODE -ne 0

        $powerShellRefused | Should -BeTrue
        $bashRefused | Should -Be $powerShellRefused
        $bashOutput | Should -Match 'source resolves through a link'
        Get-TargetFile -Fixture $script:parityFixture | Should -BeNullOrEmpty
    }

    It 'Reaches the same refusal verdict in both shells for a linked single-file source' {
        if (-not $script:parityFixture.SymlinkAvailable) {
            Set-ItResult -Skipped -Because 'symbolic links are unavailable'
            return
        }

        $agentPath = Join-Path $script:parityFixture.Source '.github/agents/hve-core/rpi-agent.agent.md'
        Remove-Item -LiteralPath $agentPath -Force
        New-Item -ItemType SymbolicLink -Path $agentPath -Target (Join-Path $script:parityOutside 'bystander.txt') -ErrorAction Stop | Out-Null

        $powerShellRefused = $false
        try { Invoke-ComponentCopy -Fixture $script:parityFixture -Component @('agents/hve-core/rpi-agent.md') | Out-Null }
        catch { $powerShellRefused = $true }

        $bashOutput = Invoke-BashComponentCopy -Fixture $script:parityFixture -Component @('agents/hve-core/rpi-agent.md')
        $bashRefused = $LASTEXITCODE -ne 0

        $powerShellRefused | Should -BeTrue
        $bashRefused | Should -Be $powerShellRefused
        $bashOutput | Should -Match 'source resolves through a link'
        Get-TargetFile -Fixture $script:parityFixture | Should -BeNullOrEmpty
    }

    It 'Reaches the same refusal verdict in both shells for a linked tracking manifest' {
        if (-not $script:parityFixture.SymlinkAvailable) {
            Set-ItResult -Skipped -Because 'symbolic links are unavailable'
            return
        }

        $victim = Join-Path $script:parityOutside 'victim.json'
        Set-Content -LiteralPath $victim -Value '{"schemaVersion":2,"files":{}}' -NoNewline
        $before = [System.IO.File]::ReadAllBytes($victim)
        New-Item -ItemType SymbolicLink -Path (Join-Path $script:parityFixture.Target '.hve-tracking.json') -Target $victim -ErrorAction Stop | Out-Null

        $powerShellRefused = $false
        try { Invoke-ComponentCopy -Fixture $script:parityFixture -Component @('agents/hve-core/rpi-agent.md') | Out-Null }
        catch { $powerShellRefused = $true }

        $bashOutput = Invoke-BashComponentCopy -Fixture $script:parityFixture -Component @('agents/hve-core/rpi-agent.md')
        $bashRefused = $LASTEXITCODE -ne 0

        $powerShellRefused | Should -BeTrue
        $bashRefused | Should -Be $powerShellRefused
        $bashOutput | Should -Match 'is a link'
        [System.IO.File]::ReadAllBytes($victim) | Should -Be $before

        # Both shells must also refuse a dangling manifest link.
        $manifestLink = Join-Path $script:parityFixture.Target '.hve-tracking.json'
        Remove-Item -LiteralPath $manifestLink -Force
        $danglingTarget = Join-Path $script:parityOutside 'dangling-manifest.json'
        New-Item -ItemType SymbolicLink -Path $manifestLink -Target $danglingTarget -ErrorAction Stop | Out-Null

        $powerShellRefusedDangling = $false
        try { Invoke-ComponentCopy -Fixture $script:parityFixture -Component @('agents/hve-core/rpi-agent.md') | Out-Null }
        catch { $powerShellRefusedDangling = $true }

        $bashDanglingOutput = Invoke-BashComponentCopy -Fixture $script:parityFixture -Component @('agents/hve-core/rpi-agent.md')
        $bashRefusedDangling = $LASTEXITCODE -ne 0

        $powerShellRefusedDangling | Should -BeTrue
        $bashRefusedDangling | Should -Be $powerShellRefusedDangling
        $bashDanglingOutput | Should -Match 'is a link'
        [System.IO.File]::Exists($danglingTarget) | Should -BeFalse
    }
}

Requirements and compatibility

Written for: GitHub Copilot

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta

“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.

Needs: Needs an MCP server, Needs Python

The author says: Requires VS Code or VS Code Insiders. Clone-based methods require git on PATH and network access. The Bash component scripts require jq.

Security

What our scanner noticed· Low risk

  • Scripts that your agent may run

This skill includes scripts that your agent may run. Read them before you use it.

A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.

Attribution and licence

Published by
Microsoft
Publisher
Microsoft (Official)
Source
microsoft/hve-core · .github/skills/installer/hve-core-installer · commit f425e94
Licence
MIT License (redistribution allowed)
  • LICENSE (added by AutoGineer from LICENSE)
  • ATTRIBUTION.md (written by AutoGineer)
Synced

AutoGineer is not affiliated with or endorsed by Microsoft or Microsoft.

Download ZIP

The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download hve-core-installer.zip