Initial shared Copilot resources scaffold

This commit is contained in:
2026-04-23 15:46:34 -04:00
commit adfcb83ab6
44 changed files with 2249 additions and 0 deletions

182
install/bootstrap.ps1 Normal file
View File

@@ -0,0 +1,182 @@
$ErrorActionPreference = 'Stop'
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$RepoRoot = Split-Path -Parent $ScriptDir
$CanonicalHome = if ($env:COPILOT_RESOURCES_HOME) { $env:COPILOT_RESOURCES_HOME } else { Join-Path $HOME '.copilot-resources' }
$StateDir = if ($env:COPILOT_RESOURCES_STATE_DIR) { $env:COPILOT_RESOURCES_STATE_DIR } else { Join-Path $HOME '.copilot-resources-state' }
$CopilotHome = if ($env:COPILOT_HOME) { $env:COPILOT_HOME } else { Join-Path $HOME '.copilot' }
$VscodeUserDir = if ($env:VSCODE_USER_DIR) { $env:VSCODE_USER_DIR } else { Join-Path $env:APPDATA 'Code\User' }
$ManagedShellEnv = $null
$ProfilePath = $PROFILE.CurrentUserAllHosts
$VscodeSettingsFile = Join-Path $VscodeUserDir 'settings.json'
function Resolve-Directory {
param([string]$Path)
(Resolve-Path -LiteralPath $Path).Path
}
function Ensure-Directory {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) {
New-Item -ItemType Directory -Path $Path -Force | Out-Null
}
}
function Find-NodeExecutable {
foreach ($Candidate in @('node', 'node.exe', 'nodejs', 'nodejs.exe')) {
$Command = Get-Command $Candidate -ErrorAction SilentlyContinue | Select-Object -First 1
if ($Command) {
return $Command.Source
}
}
return $null
}
function Quote-SingleQuoted {
param([string]$Value)
"'" + $Value.Replace("'", "''") + "'"
}
function Ensure-Junction {
param(
[string]$Target,
[string]$Path
)
$Parent = Split-Path -Parent $Path
Ensure-Directory $Parent
if (Test-Path -LiteralPath $Path) {
$Existing = Get-Item -LiteralPath $Path -Force
if ($Existing.LinkType -eq 'Junction' -or $Existing.LinkType -eq 'SymbolicLink') {
$ResolvedTarget = Resolve-Directory $Existing.Target
if ($ResolvedTarget -eq (Resolve-Directory $Target)) {
return
}
}
if ($Existing.PSIsContainer -and -not (Get-ChildItem -LiteralPath $Path -Force | Select-Object -First 1)) {
Remove-Item -LiteralPath $Path -Force
} else {
throw "Refusing to replace existing path: $Path"
}
}
New-Item -ItemType Junction -Path $Path -Target $Target | Out-Null
}
function Upsert-ManagedBlock {
param(
[string]$Path,
[string]$BeginMarker,
[string]$EndMarker,
[string]$Body
)
Ensure-Directory (Split-Path -Parent $Path)
$Lines = if (Test-Path -LiteralPath $Path) { Get-Content -LiteralPath $Path } else { @() }
$Output = New-Object System.Collections.Generic.List[string]
$Skip = $false
foreach ($Line in $Lines) {
if ($Line -eq $BeginMarker) {
$Skip = $true
continue
}
if ($Line -eq $EndMarker) {
$Skip = $false
continue
}
if (-not $Skip) {
$Output.Add($Line)
}
}
if ($Output.Count -gt 0) {
$Output.Add('')
}
$Output.Add($BeginMarker)
foreach ($BodyLine in ($Body -split "`r?`n")) {
$Output.Add($BodyLine)
}
$Output.Add($EndMarker)
Set-Content -LiteralPath $Path -Value $Output
}
function Write-ManagedPowerShellEnv {
$script:ManagedShellEnv = Join-Path $StateDir 'copilot-cli-env.ps1'
Ensure-Directory (Split-Path -Parent $script:ManagedShellEnv)
@(
'$env:COPILOT_RESOURCES_HOME = ' + (Quote-SingleQuoted $CanonicalHome),
'$env:COPILOT_HOME = ' + (Quote-SingleQuoted $CopilotHome),
'$env:COPILOT_CUSTOM_INSTRUCTIONS_DIRS = ' + (Quote-SingleQuoted (Join-Path $CanonicalHome 'resources\instructions'))
) | Set-Content -LiteralPath $script:ManagedShellEnv
$QuotedEnvPath = Quote-SingleQuoted $script:ManagedShellEnv
Upsert-ManagedBlock \
-Path $ProfilePath \
-BeginMarker '# >>> copilot-resources bootstrap >>>' \
-EndMarker '# <<< copilot-resources bootstrap <<<' \
-Body "if (Test-Path -LiteralPath $QuotedEnvPath) {`n . $QuotedEnvPath`n}"
}
function Merge-VscodeSettings {
$NodeExecutable = Find-NodeExecutable
if (-not $NodeExecutable) {
Write-Warning 'Skipping VS Code settings merge because Node.js is not available.'
return
}
Ensure-Directory (Split-Path -Parent $VscodeSettingsFile)
& $NodeExecutable (Join-Path $ScriptDir 'merge-vscode-settings.mjs') --target $VscodeSettingsFile --template (Join-Path $CanonicalHome 'config\vscode\settings.template.jsonc') --set "COPILOT_RESOURCES_HOME=$CanonicalHome"
}
Ensure-Directory (Split-Path -Parent $CanonicalHome)
if (Test-Path -LiteralPath $CanonicalHome) {
if ((Resolve-Directory $CanonicalHome) -ne (Resolve-Directory $RepoRoot)) {
throw "Canonical path already exists and points elsewhere: $CanonicalHome"
}
} else {
New-Item -ItemType Junction -Path $CanonicalHome -Target $RepoRoot | Out-Null
}
Ensure-Directory $CopilotHome
Ensure-Directory $VscodeUserDir
Ensure-Junction -Target (Join-Path $CanonicalHome 'resources\skills') -Path (Join-Path $CopilotHome 'skills')
Ensure-Junction -Target (Join-Path $CanonicalHome 'resources\agents') -Path (Join-Path $CopilotHome 'agents')
Ensure-Junction -Target (Join-Path $CanonicalHome 'resources\instructions') -Path (Join-Path $CopilotHome 'instructions')
Ensure-Junction -Target (Join-Path $CanonicalHome 'resources\hooks') -Path (Join-Path $CopilotHome 'hooks')
Ensure-Junction -Target (Join-Path $CanonicalHome 'resources\prompts') -Path (Join-Path $VscodeUserDir 'prompts')
Write-ManagedPowerShellEnv
Merge-VscodeSettings
Ensure-Directory $StateDir
@{
canonicalHome = $CanonicalHome
repoRoot = $RepoRoot
copilotHome = $CopilotHome
vscodeUserDir = $VscodeUserDir
vscodeSettingsFile = $VscodeSettingsFile
shellRcFile = $ProfilePath
managedShellEnv = $ManagedShellEnv
bootstrapScript = (Join-Path $ScriptDir 'bootstrap.ps1')
} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $StateDir 'install-state.json')
Write-Host "Bootstrap complete."
Write-Host "Canonical home: $CanonicalHome"
Write-Host "Repository root: $RepoRoot"
Write-Host "Copilot home: $CopilotHome"
Write-Host "VS Code user dir: $VscodeUserDir"
Write-Host "Merged managed VS Code settings into: $VscodeSettingsFile"
Write-Host "Installed managed Copilot CLI PowerShell environment into: $ManagedShellEnv"
Write-Host "Linked PowerShell profile: $ProfilePath"
Write-Host "Optional VS Code template: $(Join-Path $CanonicalHome 'config\vscode\settings.template.jsonc')"

264
install/bootstrap.sh Normal file
View File

@@ -0,0 +1,264 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
repo_root="$(cd -- "$script_dir/.." && pwd -P)"
canonical_home="${COPILOT_RESOURCES_HOME:-$HOME/.copilot-resources}"
state_dir="${COPILOT_RESOURCES_STATE_DIR:-$HOME/.copilot-resources-state}"
copilot_home="${COPILOT_HOME:-$HOME/.copilot}"
managed_shell_env=""
shell_rc_file=""
vscode_settings_file=""
usage() {
cat <<'EOF'
Usage: install/bootstrap.sh [--print-home]
Creates a stable canonical home for the repository and links default VS Code and
Copilot discovery paths back to the repository.
EOF
}
resolve_dir() {
cd -- "$1" && pwd -P
}
ensure_parent_dir() {
mkdir -p -- "$(dirname -- "$1")"
}
find_node_bin() {
if command -v node >/dev/null 2>&1; then
command -v node
return 0
fi
if command -v nodejs >/dev/null 2>&1; then
command -v nodejs
return 0
fi
return 1
}
link_path() {
local target="$1"
local link_path="$2"
ensure_parent_dir "$link_path"
if [[ -L "$link_path" ]]; then
local existing_target
existing_target="$(cd -- "$(dirname -- "$link_path")" && resolve_dir "$(readlink "$link_path")")"
if [[ "$existing_target" == "$target" ]]; then
return 0
fi
printf 'Refusing to replace existing symlink %s -> %s\n' "$link_path" "$existing_target" >&2
return 1
fi
if [[ -e "$link_path" ]]; then
if [[ -d "$link_path" ]] && [[ -z "$(find "$link_path" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]]; then
rmdir "$link_path"
else
printf 'Refusing to replace existing path: %s\n' "$link_path" >&2
return 1
fi
fi
ln -s -- "$target" "$link_path"
}
detect_vscode_user_dir() {
if [[ -n "${VSCODE_USER_DIR:-}" ]]; then
printf '%s\n' "$VSCODE_USER_DIR"
return 0
fi
case "$(uname -s)" in
Darwin)
printf '%s\n' "$HOME/Library/Application Support/Code/User"
;;
Linux)
printf '%s\n' "$HOME/.config/Code/User"
;;
*)
printf '%s\n' "$HOME/.config/Code/User"
;;
esac
}
detect_shell_rc_file() {
case "$(basename -- "${SHELL:-}")" in
zsh)
printf '%s\n' "$HOME/.zshrc"
;;
bash)
printf '%s\n' "$HOME/.bashrc"
;;
*)
printf '%s\n' "$HOME/.profile"
;;
esac
}
upsert_managed_block() {
local file_path="$1"
local begin_marker="$2"
local end_marker="$3"
local body="$4"
local temp_file
temp_file="$(mktemp)"
ensure_parent_dir "$file_path"
if [[ -f "$file_path" ]]; then
awk -v begin_marker="$begin_marker" -v end_marker="$end_marker" '
$0 == begin_marker { skip = 1; next }
$0 == end_marker { skip = 0; next }
!skip { print }
' "$file_path" > "$temp_file"
fi
if [[ -s "$temp_file" ]]; then
printf '\n' >> "$temp_file"
fi
cat >> "$temp_file" <<EOF
$begin_marker
$body
$end_marker
EOF
mv -- "$temp_file" "$file_path"
}
write_managed_shell_env() {
local shell_block
managed_shell_env="$state_dir/copilot-cli-env.sh"
ensure_parent_dir "$managed_shell_env"
cat > "$managed_shell_env" <<EOF
export COPILOT_RESOURCES_HOME="${canonical_home}"
export COPILOT_HOME="${copilot_home}"
export COPILOT_CUSTOM_INSTRUCTIONS_DIRS="${canonical_home}/resources/instructions"
EOF
shell_rc_file="$(detect_shell_rc_file)"
shell_block="$(cat <<EOF
if [ -f "$managed_shell_env" ]; then
. "$managed_shell_env"
fi
EOF
)"
upsert_managed_block \
"$shell_rc_file" \
"# >>> copilot-resources bootstrap >>>" \
"# <<< copilot-resources bootstrap <<<" \
"$shell_block"
}
merge_vscode_settings() {
local node_bin
vscode_settings_file="$vscode_user_dir/settings.json"
if ! node_bin="$(find_node_bin)"; then
printf 'Skipping VS Code settings merge because Node.js is not available.\n' >&2
return 0
fi
ensure_parent_dir "$vscode_settings_file"
"$node_bin" "$script_dir/merge-vscode-settings.mjs" \
--target "$vscode_settings_file" \
--template "$canonical_home/config/vscode/settings.template.jsonc" \
--set "COPILOT_RESOURCES_HOME=$canonical_home"
}
write_state() {
mkdir -p -- "$state_dir"
cat > "$state_dir/install-state.json" <<EOF
{
"canonicalHome": "${canonical_home}",
"repoRoot": "${repo_root}",
"copilotHome": "${copilot_home}",
"vscodeUserDir": "${vscode_user_dir}",
"vscodeSettingsFile": "${vscode_settings_file}",
"shellRcFile": "${shell_rc_file}",
"managedShellEnv": "${managed_shell_env}",
"bootstrapScript": "${script_dir}/bootstrap.sh"
}
EOF
}
main() {
if [[ "${1:-}" == "--help" ]]; then
usage
exit 0
fi
if [[ "${1:-}" == "--print-home" ]]; then
printf '%s\n' "$canonical_home"
exit 0
fi
local canonical_parent
canonical_parent="$(dirname -- "$canonical_home")"
mkdir -p -- "$canonical_parent"
if [[ -e "$canonical_home" ]]; then
if [[ "$(resolve_dir "$canonical_home")" != "$repo_root" ]]; then
printf 'Canonical path already exists and points elsewhere: %s\n' "$canonical_home" >&2
exit 1
fi
else
ln -s -- "$repo_root" "$canonical_home"
fi
mkdir -p -- "$copilot_home"
vscode_user_dir="$(detect_vscode_user_dir)"
mkdir -p -- "$vscode_user_dir"
link_path "$canonical_home/resources/skills" "$copilot_home/skills"
link_path "$canonical_home/resources/agents" "$copilot_home/agents"
link_path "$canonical_home/resources/instructions" "$copilot_home/instructions"
link_path "$canonical_home/resources/hooks" "$copilot_home/hooks"
link_path "$canonical_home/resources/prompts" "$vscode_user_dir/prompts"
write_managed_shell_env
merge_vscode_settings
write_state
cat <<EOF
Bootstrap complete.
Canonical home: $canonical_home
Repository root: $repo_root
Copilot home: $copilot_home
VS Code user dir: $vscode_user_dir
Linked default discovery paths back to the repository for skills, agents,
instructions, hooks, and prompts.
Merged managed VS Code settings into:
$vscode_settings_file
Installed managed Copilot CLI shell environment into:
$managed_shell_env
Linked shell startup file:
$shell_rc_file
Optional VS Code feature flags are available in:
$canonical_home/config/vscode/settings.template.jsonc
Optional Copilot CLI environment template is available in:
$canonical_home/config/copilot-cli/env.example.sh
EOF
}
main "$@"

View File

@@ -0,0 +1,248 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
function usage() {
console.error(
'Usage: node install/merge-vscode-settings.mjs --target <settings.json> --template <settings.template.jsonc> [--set NAME=value]'
);
}
function parseArgs(argv) {
const options = {
replacements: {},
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--target') {
options.target = argv[index + 1];
index += 1;
continue;
}
if (arg === '--template') {
options.template = argv[index + 1];
index += 1;
continue;
}
if (arg === '--set') {
const assignment = argv[index + 1] ?? '';
const equalsIndex = assignment.indexOf('=');
if (equalsIndex <= 0) {
throw new Error(`Invalid --set assignment: ${assignment}`);
}
const key = assignment.slice(0, equalsIndex);
const value = assignment.slice(equalsIndex + 1);
options.replacements[key] = value;
index += 1;
continue;
}
throw new Error(`Unknown argument: ${arg}`);
}
if (!options.target || !options.template) {
usage();
throw new Error('Both --target and --template are required.');
}
return options;
}
function stripJsonComments(input) {
let output = '';
let inString = false;
let escaping = false;
let inLineComment = false;
let inBlockComment = false;
for (let index = 0; index < input.length; index += 1) {
const char = input[index];
const next = input[index + 1];
if (inLineComment) {
if (char === '\n') {
inLineComment = false;
output += char;
}
continue;
}
if (inBlockComment) {
if (char === '*' && next === '/') {
inBlockComment = false;
index += 1;
} else if (char === '\n' || char === '\r') {
output += char;
}
continue;
}
if (inString) {
output += char;
if (escaping) {
escaping = false;
} else if (char === '\\') {
escaping = true;
} else if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
inString = true;
output += char;
continue;
}
if (char === '/' && next === '/') {
inLineComment = true;
index += 1;
continue;
}
if (char === '/' && next === '*') {
inBlockComment = true;
index += 1;
continue;
}
output += char;
}
return output;
}
function stripTrailingCommas(input) {
let output = '';
let inString = false;
let escaping = false;
for (let index = 0; index < input.length; index += 1) {
const char = input[index];
if (inString) {
output += char;
if (escaping) {
escaping = false;
} else if (char === '\\') {
escaping = true;
} else if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
inString = true;
output += char;
continue;
}
if (char === ',') {
let lookahead = index + 1;
while (lookahead < input.length && /\s/.test(input[lookahead])) {
lookahead += 1;
}
if (input[lookahead] === '}' || input[lookahead] === ']') {
continue;
}
}
output += char;
}
return output;
}
function renderTemplate(input, replacements) {
return input.replace(/\{\{([A-Z0-9_]+)\}\}/g, (match, key) => {
if (!(key in replacements)) {
return match;
}
return replacements[key]
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"');
});
}
function parseJsonc(input, label) {
const sanitized = stripTrailingCommas(stripJsonComments(input)).trim();
if (!sanitized) {
return {};
}
let parsed;
try {
parsed = JSON.parse(sanitized);
} catch (error) {
throw new Error(`Failed to parse ${label}: ${error.message}`);
}
if (!isPlainObject(parsed)) {
throw new Error(`${label} must contain a JSON object at the root.`);
}
return parsed;
}
function isPlainObject(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function cloneValue(value) {
if (Array.isArray(value)) {
return value.map((item) => cloneValue(item));
}
if (isPlainObject(value)) {
return Object.fromEntries(
Object.entries(value).map(([key, nested]) => [key, cloneValue(nested)])
);
}
return value;
}
function deepMerge(target, source) {
const merged = { ...target };
for (const [key, value] of Object.entries(source)) {
if (isPlainObject(value) && isPlainObject(merged[key])) {
merged[key] = deepMerge(merged[key], value);
continue;
}
merged[key] = cloneValue(value);
}
return merged;
}
function main() {
const options = parseArgs(process.argv.slice(2));
const templateText = fs.readFileSync(options.template, 'utf8');
const renderedTemplate = renderTemplate(templateText, options.replacements);
const managedSettings = parseJsonc(renderedTemplate, options.template);
const targetText = fs.existsSync(options.target)
? fs.readFileSync(options.target, 'utf8')
: '{}\n';
const currentSettings = parseJsonc(targetText, options.target);
const mergedSettings = deepMerge(currentSettings, managedSettings);
const output = `${JSON.stringify(mergedSettings, null, 2)}\n`;
fs.mkdirSync(path.dirname(options.target), { recursive: true });
if (output === targetText) {
console.log(`VS Code settings already up to date: ${options.target}`);
return;
}
fs.writeFileSync(options.target, output, 'utf8');
console.log(`Merged managed VS Code settings into: ${options.target}`);
}
main();

372
install/publish.ps1 Normal file
View File

@@ -0,0 +1,372 @@
param(
[Parameter(Mandatory = $true)]
[string]$Source,
[string]$Kind,
[string]$Name,
[switch]$Force
)
$ErrorActionPreference = 'Stop'
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$RepoRoot = Split-Path -Parent $ScriptDir
$StateDir = if ($env:COPILOT_RESOURCES_STATE_DIR) { $env:COPILOT_RESOURCES_STATE_DIR } else { Join-Path $HOME '.copilot-resources-state' }
function Normalize-Stem {
param([string]$Value)
$Normalized = $Value.ToLowerInvariant()
$Normalized = [regex]::Replace($Normalized, '[\s_]+', '-')
$Normalized = [regex]::Replace($Normalized, '[^a-z0-9-]+', '-')
$Normalized = [regex]::Replace($Normalized, '-+', '-')
$Normalized = $Normalized.Trim('-')
if ([string]::IsNullOrWhiteSpace($Normalized)) {
return 'resource'
}
return $Normalized
}
function Strip-KindSuffix {
param(
[string]$Kind,
[string]$Value
)
switch ($Kind) {
'prompt' { return ($Value -replace '\.prompt\.md$', '') }
'instruction' { return ($Value -replace '\.instructions\.md$', '') }
'agent' { return ($Value -replace '\.agent\.md$', '') }
'hook' { return ($Value -replace '\.json$', '') }
default { return $Value }
}
}
function Get-KindSuffix {
param([string]$Kind)
switch ($Kind) {
'prompt' { return '.prompt.md' }
'instruction' { return '.instructions.md' }
'agent' { return '.agent.md' }
'hook' { return '.json' }
'skill' { return '' }
default { throw "Unsupported kind: $Kind" }
}
}
function Get-KindRoot {
param([string]$Kind)
switch ($Kind) {
'skill' { return (Join-Path $RepoRoot 'resources\skills') }
'prompt' { return (Join-Path $RepoRoot 'resources\prompts') }
'instruction' { return (Join-Path $RepoRoot 'resources\instructions') }
'agent' { return (Join-Path $RepoRoot 'resources\agents') }
'hook' { return (Join-Path $RepoRoot 'resources\hooks') }
default { throw "Unsupported kind: $Kind" }
}
}
function Get-FrontmatterField {
param(
[string]$Path,
[string]$Field
)
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
return $null
}
$Lines = Get-Content -LiteralPath $Path
if ($Lines.Count -eq 0 -or $Lines[0].Trim() -ne '---') {
return $null
}
$Pattern = '^[\s]*' + [regex]::Escape($Field) + ':[\s]*(.+?)\s*$'
for ($Index = 1; $Index -lt $Lines.Count; $Index++) {
if ($Lines[$Index].Trim() -eq '---') {
break
}
if ($Lines[$Index] -match $Pattern) {
$Value = $Matches[1] -replace '\s+#.*$', ''
return $Value.Trim('"', "'")
}
}
return $null
}
function Set-SkillNameField {
param(
[string]$Path,
[string]$Name
)
$Lines = [System.Collections.Generic.List[string]]::new()
$Lines.AddRange([string[]](Get-Content -LiteralPath $Path))
if ($Lines.Count -eq 0 -or $Lines[0].Trim() -ne '---') {
throw "Skill frontmatter must start with ---: $Path"
}
$FrontmatterEnd = -1
for ($Index = 1; $Index -lt $Lines.Count; $Index++) {
if ($Lines[$Index].Trim() -eq '---') {
$FrontmatterEnd = $Index
break
}
}
if ($FrontmatterEnd -lt 0) {
throw "Skill frontmatter is incomplete: $Path"
}
$Replaced = $false
for ($Index = 1; $Index -lt $FrontmatterEnd; $Index++) {
if ($Lines[$Index] -match '^\s*name\s*:') {
$Lines[$Index] = "name: $Name"
$Replaced = $true
break
}
}
if (-not $Replaced) {
$Lines.Insert(1, "name: $Name")
}
Set-Content -LiteralPath $Path -Value $Lines
}
function Get-StringHash {
param([string]$Value)
$Bytes = [System.Text.Encoding]::UTF8.GetBytes($Value)
$Sha = [System.Security.Cryptography.SHA256]::Create()
try {
return ([System.Convert]::ToHexString($Sha.ComputeHash($Bytes))).ToLowerInvariant()
}
finally {
$Sha.Dispose()
}
}
function Get-ArtifactFingerprint {
param([string]$Path)
if (Test-Path -LiteralPath $Path -PathType Container) {
$Manifest = foreach ($File in (Get-ChildItem -LiteralPath $Path -Recurse -File | Sort-Object FullName)) {
$Relative = [System.IO.Path]::GetRelativePath($Path, $File.FullName).Replace('\', '/')
$Hash = (Get-FileHash -LiteralPath $File.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
"$Hash $Relative"
}
return Get-StringHash -Value ($Manifest -join "`n")
}
return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
}
function Get-ArtifactDisplayName {
param(
[string]$Kind,
[string]$Path
)
switch ($Kind) {
'skill' {
$Name = Get-FrontmatterField -Path (Join-Path $Path 'SKILL.md') -Field 'name'
if ($Name) { return $Name }
return (Split-Path -Leaf $Path)
}
'prompt' {
$Name = Get-FrontmatterField -Path $Path -Field 'name'
if ($Name) { return $Name }
return Strip-KindSuffix -Kind $Kind -Value (Split-Path -Leaf $Path)
}
'instruction' {
$Name = Get-FrontmatterField -Path $Path -Field 'name'
if ($Name) { return $Name }
return Strip-KindSuffix -Kind $Kind -Value (Split-Path -Leaf $Path)
}
'agent' {
$Name = Get-FrontmatterField -Path $Path -Field 'name'
if ($Name) { return $Name }
return Strip-KindSuffix -Kind $Kind -Value (Split-Path -Leaf $Path)
}
'hook' {
return Strip-KindSuffix -Kind $Kind -Value (Split-Path -Leaf $Path)
}
default {
throw "Unsupported kind: $Kind"
}
}
}
function Get-KindArtifacts {
param([string]$Kind)
$Root = Get-KindRoot -Kind $Kind
if (-not (Test-Path -LiteralPath $Root)) {
return @()
}
switch ($Kind) {
'skill' { return (Get-ChildItem -LiteralPath $Root -Directory | Sort-Object FullName | Select-Object -ExpandProperty FullName) }
'prompt' { return (Get-ChildItem -LiteralPath $Root -File -Filter '*.prompt.md' | Sort-Object FullName | Select-Object -ExpandProperty FullName) }
'instruction' { return (Get-ChildItem -LiteralPath $Root -File -Filter '*.instructions.md' | Sort-Object FullName | Select-Object -ExpandProperty FullName) }
'agent' { return (Get-ChildItem -LiteralPath $Root -File -Filter '*.agent.md' | Sort-Object FullName | Select-Object -ExpandProperty FullName) }
'hook' { return (Get-ChildItem -LiteralPath $Root -File -Filter '*.json' | Sort-Object FullName | Select-Object -ExpandProperty FullName) }
default { throw "Unsupported kind: $Kind" }
}
}
function Write-PublishLog {
param(
[string]$Kind,
[string]$Source,
[string]$Target,
[string]$Origin,
[string]$Fingerprint,
[string]$Outcome
)
New-Item -ItemType Directory -Path $StateDir -Force | Out-Null
$Timestamp = [DateTime]::UtcNow.ToString('o')
"$Timestamp`t$Kind`t$Source`t$Target`t$Origin`t$Fingerprint`t$Outcome" | Add-Content -LiteralPath (Join-Path $StateDir 'publish-log.tsv')
}
function Detect-Kind {
param([string]$Path)
if ((Test-Path -LiteralPath $Path -PathType Container) -and (Test-Path -LiteralPath (Join-Path $Path 'SKILL.md'))) {
return 'skill'
}
switch -Regex ((Split-Path -Leaf $Path)) {
'\.prompt\.md$' { return 'prompt' }
'\.instructions\.md$' { return 'instruction' }
'\.agent\.md$' { return 'agent' }
'\.json$' { return 'hook' }
'^SKILL\.md$' { return 'skill' }
default { throw "Could not detect resource kind for: $Path" }
}
}
if (-not (Test-Path -LiteralPath $Source)) {
throw "Source does not exist: $Source"
}
if (-not $Kind) {
$Kind = Detect-Kind -Path $Source
}
$TempDir = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $TempDir -Force | Out-Null
try {
switch ($Kind) {
'skill' {
if (Test-Path -LiteralPath $Source -PathType Leaf) {
$Source = Split-Path -Parent $Source
}
if (-not (Test-Path -LiteralPath (Join-Path $Source 'SKILL.md'))) {
throw "Skill publish requires a directory containing SKILL.md"
}
if (-not $Name) { $Name = Get-FrontmatterField -Path (Join-Path $Source 'SKILL.md') -Field 'name' }
if (-not $Name) { $Name = Split-Path -Leaf $Source }
$NormalizedName = Normalize-Stem -Value $Name
$Target = Join-Path $RepoRoot (Join-Path 'resources\skills' $NormalizedName)
$Candidate = Join-Path $TempDir $NormalizedName
Copy-Item -LiteralPath $Source -Destination $Candidate -Recurse -Force
Set-SkillNameField -Path (Join-Path $Candidate 'SKILL.md') -Name $NormalizedName
}
'prompt' {
if (-not $Name) { $Name = Split-Path -Leaf $Source }
$NormalizedName = Normalize-Stem -Value (Strip-KindSuffix -Kind $Kind -Value $Name)
$Target = Join-Path $RepoRoot (Join-Path 'resources\prompts' ($NormalizedName + (Get-KindSuffix -Kind $Kind)))
$Candidate = Join-Path $TempDir ($NormalizedName + (Get-KindSuffix -Kind $Kind))
Copy-Item -LiteralPath $Source -Destination $Candidate -Force
}
'instruction' {
if (-not $Name) { $Name = Split-Path -Leaf $Source }
$NormalizedName = Normalize-Stem -Value (Strip-KindSuffix -Kind $Kind -Value $Name)
$Target = Join-Path $RepoRoot (Join-Path 'resources\instructions' ($NormalizedName + (Get-KindSuffix -Kind $Kind)))
$Candidate = Join-Path $TempDir ($NormalizedName + (Get-KindSuffix -Kind $Kind))
Copy-Item -LiteralPath $Source -Destination $Candidate -Force
}
'agent' {
if (-not $Name) { $Name = Split-Path -Leaf $Source }
$NormalizedName = Normalize-Stem -Value (Strip-KindSuffix -Kind $Kind -Value $Name)
$Target = Join-Path $RepoRoot (Join-Path 'resources\agents' ($NormalizedName + (Get-KindSuffix -Kind $Kind)))
$Candidate = Join-Path $TempDir ($NormalizedName + (Get-KindSuffix -Kind $Kind))
Copy-Item -LiteralPath $Source -Destination $Candidate -Force
}
'hook' {
if (-not $Name) { $Name = Split-Path -Leaf $Source }
$NormalizedName = Normalize-Stem -Value (Strip-KindSuffix -Kind $Kind -Value $Name)
$Target = Join-Path $RepoRoot (Join-Path 'resources\hooks' ($NormalizedName + (Get-KindSuffix -Kind $Kind)))
$Candidate = Join-Path $TempDir ($NormalizedName + (Get-KindSuffix -Kind $Kind))
Copy-Item -LiteralPath $Source -Destination $Candidate -Force
}
default {
throw "Unsupported kind: $Kind"
}
}
$CandidateFingerprint = Get-ArtifactFingerprint -Path $Candidate
$CandidateDisplayName = Get-ArtifactDisplayName -Kind $Kind -Path $Candidate
if (Test-Path -LiteralPath $Target) {
$ExistingTargetFingerprint = Get-ArtifactFingerprint -Path $Target
if ($ExistingTargetFingerprint -eq $CandidateFingerprint) {
Write-PublishLog -Kind $Kind -Source $Source -Target $Target -Origin 'local-first' -Fingerprint $CandidateFingerprint -Outcome 'noop'
Write-Host "No publish needed. Shared artifact already exists at: $Target"
Write-Host "Fingerprint: $CandidateFingerprint"
Write-Host "Display name: $CandidateDisplayName"
return
}
if (-not $Force) {
throw "Target already exists with different content: $Target"
}
}
foreach ($ExistingPath in (Get-KindArtifacts -Kind $Kind)) {
if ($ExistingPath -eq $Target) {
continue
}
$ExistingFingerprint = Get-ArtifactFingerprint -Path $ExistingPath
if ($ExistingFingerprint -eq $CandidateFingerprint) {
throw "Duplicate $Kind content already exists at: $ExistingPath"
}
$ExistingDisplayName = Get-ArtifactDisplayName -Kind $Kind -Path $ExistingPath
if ($ExistingDisplayName -eq $CandidateDisplayName) {
throw "Duplicate $Kind display name '$CandidateDisplayName' already exists at: $ExistingPath"
}
}
New-Item -ItemType Directory -Path (Split-Path -Parent $Target) -Force | Out-Null
if (Test-Path -LiteralPath $Target) {
Remove-Item -LiteralPath $Target -Recurse -Force
}
if (Test-Path -LiteralPath $Candidate -PathType Container) {
Copy-Item -LiteralPath $Candidate -Destination $Target -Recurse -Force
} else {
Copy-Item -LiteralPath $Candidate -Destination $Target -Force
}
Write-PublishLog -Kind $Kind -Source $Source -Target $Target -Origin 'local-first' -Fingerprint $CandidateFingerprint -Outcome 'published'
Write-Host "Published $Kind into shared repo: $Target"
Write-Host "Fingerprint: $CandidateFingerprint"
Write-Host "Display name: $CandidateDisplayName"
}
finally {
if (Test-Path -LiteralPath $TempDir) {
Remove-Item -LiteralPath $TempDir -Recurse -Force
}
}

501
install/publish.sh Normal file
View File

@@ -0,0 +1,501 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
repo_root="$(cd -- "$script_dir/.." && pwd -P)"
state_dir="${COPILOT_RESOURCES_STATE_DIR:-$HOME/.copilot-resources-state}"
temp_dir=""
usage() {
cat <<'EOF'
Usage: install/publish.sh --source PATH [--kind KIND] [--name NAME] [--force]
Supported kinds:
skill, prompt, instruction, agent, hook
The publish workflow normalizes target names, aligns skill metadata with the
published directory name, and blocks duplicate shared resources by content and
effective display name.
EOF
}
fail() {
printf '%s\n' "$1" >&2
exit 1
}
append_log() {
mkdir -p -- "$state_dir"
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
"$1" \
"$2" \
"$3" \
"$4" \
"$5" \
"$6" >> "$state_dir/publish-log.tsv"
}
normalize_stem() {
local raw="$1"
raw="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')"
raw="$(printf '%s' "$raw" | sed -E 's/[[:space:]_]+/-/g; s/[^a-z0-9-]+/-/g; s/-+/-/g; s/^-+//; s/-+$//')"
if [[ -z "$raw" ]]; then
raw="resource"
fi
printf '%s\n' "$raw"
}
strip_kind_suffix() {
local kind="$1"
local raw="$2"
case "$kind" in
prompt)
raw="${raw%.prompt.md}"
;;
instruction)
raw="${raw%.instructions.md}"
;;
agent)
raw="${raw%.agent.md}"
;;
hook)
raw="${raw%.json}"
;;
esac
printf '%s\n' "$raw"
}
kind_root_dir() {
local kind="$1"
case "$kind" in
skill)
printf '%s\n' "$repo_root/resources/skills"
;;
prompt)
printf '%s\n' "$repo_root/resources/prompts"
;;
instruction)
printf '%s\n' "$repo_root/resources/instructions"
;;
agent)
printf '%s\n' "$repo_root/resources/agents"
;;
hook)
printf '%s\n' "$repo_root/resources/hooks"
;;
*)
fail "Unsupported kind: $kind"
;;
esac
}
kind_suffix() {
local kind="$1"
case "$kind" in
prompt)
printf '.prompt.md\n'
;;
instruction)
printf '.instructions.md\n'
;;
agent)
printf '.agent.md\n'
;;
hook)
printf '.json\n'
;;
skill)
printf '\n'
;;
*)
fail "Unsupported kind: $kind"
;;
esac
}
extract_frontmatter_field() {
local file="$1"
local field="$2"
[[ -f "$file" ]] || return 0
awk -v field="$field" '
BEGIN {
in_yaml = 0
delimiter_count = 0
}
/^[[:space:]]*---[[:space:]]*$/ {
delimiter_count++
if (delimiter_count == 1) {
in_yaml = 1
next
}
if (in_yaml) {
exit
}
}
in_yaml {
pattern = "^[[:space:]]*" field ":[[:space:]]*"
if ($0 ~ pattern) {
sub(pattern, "", $0)
sub(/[[:space:]]+#.*$/, "", $0)
gsub(/^["\047]|["\047]$/, "", $0)
print $0
exit
}
}
' "$file"
}
ensure_skill_name_field() {
local file="$1"
local normalized_name="$2"
local temp_file
[[ -f "$file" ]] || fail "Skill publish requires SKILL.md: $file"
[[ "$(head -n 1 "$file")" =~ ^---[[:space:]]*$ ]] || fail "Skill frontmatter must start with ---: $file"
temp_file="$(mktemp)"
if ! awk -v normalized_name="$normalized_name" '
BEGIN {
in_yaml = 0
delimiter_count = 0
wrote_name = 0
frontmatter_found = 0
}
/^[[:space:]]*---[[:space:]]*$/ {
delimiter_count++
print
if (delimiter_count == 1) {
in_yaml = 1
frontmatter_found = 1
next
}
if (in_yaml && !wrote_name) {
print "name: " normalized_name
wrote_name = 1
}
in_yaml = 0
next
}
in_yaml {
if ($0 ~ /^[[:space:]]*name:[[:space:]]*/) {
print "name: " normalized_name
wrote_name = 1
next
}
}
{ print }
END {
if (!frontmatter_found || delimiter_count < 2) {
exit 42
}
}
' "$file" > "$temp_file"; then
rm -f -- "$temp_file"
fail "Skill frontmatter is invalid or incomplete: $file"
fi
mv -- "$temp_file" "$file"
}
artifact_fingerprint() {
local path="$1"
if [[ -d "$path" ]]; then
(
cd -- "$path"
find . -type f | LC_ALL=C sort | while IFS= read -r rel_path; do
file_hash="$(shasum -a 256 "$rel_path" | awk '{print $1}')"
printf '%s %s\n' "$file_hash" "$rel_path"
done
) | shasum -a 256 | awk '{print $1}'
return 0
fi
shasum -a 256 "$path" | awk '{print $1}'
}
artifact_display_name() {
local kind="$1"
local path="$2"
local display_name=""
local base_name=""
case "$kind" in
skill)
display_name="$(extract_frontmatter_field "$path/SKILL.md" name)"
if [[ -z "$display_name" ]]; then
display_name="$(basename -- "$path")"
fi
;;
prompt|instruction|agent)
display_name="$(extract_frontmatter_field "$path" name)"
if [[ -z "$display_name" ]]; then
base_name="$(basename -- "$path")"
display_name="$(strip_kind_suffix "$kind" "$base_name")"
fi
;;
hook)
base_name="$(basename -- "$path")"
display_name="${base_name%.json}"
;;
*)
fail "Unsupported kind: $kind"
;;
esac
printf '%s\n' "$display_name"
}
list_kind_artifacts() {
local kind="$1"
local root_dir
root_dir="$(kind_root_dir "$kind")"
[[ -d "$root_dir" ]] || return 0
case "$kind" in
skill)
find "$root_dir" -mindepth 1 -maxdepth 1 -type d | LC_ALL=C sort
;;
prompt)
find "$root_dir" -mindepth 1 -maxdepth 1 -type f -name '*.prompt.md' | LC_ALL=C sort
;;
instruction)
find "$root_dir" -mindepth 1 -maxdepth 1 -type f -name '*.instructions.md' | LC_ALL=C sort
;;
agent)
find "$root_dir" -mindepth 1 -maxdepth 1 -type f -name '*.agent.md' | LC_ALL=C sort
;;
hook)
find "$root_dir" -mindepth 1 -maxdepth 1 -type f -name '*.json' | LC_ALL=C sort
;;
esac
}
detect_kind() {
local source="$1"
local base
base="$(basename -- "$source")"
if [[ -d "$source" && -f "$source/SKILL.md" ]]; then
printf 'skill\n'
return 0
fi
case "$base" in
*.prompt.md)
printf 'prompt\n'
;;
*.instructions.md)
printf 'instruction\n'
;;
*.agent.md)
printf 'agent\n'
;;
*.json)
printf 'hook\n'
;;
SKILL.md)
printf 'skill\n'
;;
*)
return 1
;;
esac
}
cleanup() {
if [[ -n "${temp_dir:-}" && -d "${temp_dir:-}" ]]; then
rm -rf -- "${temp_dir:-}"
fi
}
main() {
local source=""
local kind=""
local name=""
local force="false"
local candidate_path=""
local normalized_name=""
local target=""
local origin_label="local-first"
local source_base=""
local candidate_fingerprint=""
local candidate_display_name=""
local existing_fingerprint=""
local existing_display_name=""
trap cleanup EXIT
while [[ $# -gt 0 ]]; do
case "$1" in
--source)
source="$2"
shift 2
;;
--kind)
kind="$2"
shift 2
;;
--name)
name="$2"
shift 2
;;
--force)
force="true"
shift
;;
--help)
usage
exit 0
;;
*)
fail "Unknown argument: $1"
;;
esac
done
[[ -n "$source" ]] || fail "--source is required"
[[ -e "$source" ]] || fail "Source does not exist: $source"
if [[ -z "$kind" ]]; then
kind="$(detect_kind "$source")" || fail "Could not detect resource kind from source path"
fi
temp_dir="$(mktemp -d)"
case "$kind" in
skill)
if [[ -f "$source" ]]; then
source="$(dirname -- "$source")"
fi
[[ -f "$source/SKILL.md" ]] || fail "Skill publish requires a directory containing SKILL.md"
name="${name:-$(extract_frontmatter_field "$source/SKILL.md" name)}"
source_base="$(basename -- "$source")"
normalized_name="$(normalize_stem "${name:-$source_base}")"
target="$repo_root/resources/skills/$normalized_name"
candidate_path="$temp_dir/$normalized_name"
cp -R -- "$source" "$candidate_path"
ensure_skill_name_field "$candidate_path/SKILL.md" "$normalized_name"
;;
prompt)
source_base="$(basename -- "$source")"
normalized_name="$(normalize_stem "$(strip_kind_suffix "$kind" "${name:-$source_base}")")"
target="$repo_root/resources/prompts/$normalized_name$(kind_suffix "$kind")"
candidate_path="$temp_dir/$normalized_name$(kind_suffix "$kind")"
cp -- "$source" "$candidate_path"
;;
instruction)
source_base="$(basename -- "$source")"
normalized_name="$(normalize_stem "$(strip_kind_suffix "$kind" "${name:-$source_base}")")"
target="$repo_root/resources/instructions/$normalized_name$(kind_suffix "$kind")"
candidate_path="$temp_dir/$normalized_name$(kind_suffix "$kind")"
cp -- "$source" "$candidate_path"
;;
agent)
source_base="$(basename -- "$source")"
normalized_name="$(normalize_stem "$(strip_kind_suffix "$kind" "${name:-$source_base}")")"
target="$repo_root/resources/agents/$normalized_name$(kind_suffix "$kind")"
candidate_path="$temp_dir/$normalized_name$(kind_suffix "$kind")"
cp -- "$source" "$candidate_path"
;;
hook)
source_base="$(basename -- "$source")"
normalized_name="$(normalize_stem "$(strip_kind_suffix "$kind" "${name:-$source_base}")")"
target="$repo_root/resources/hooks/$normalized_name$(kind_suffix "$kind")"
candidate_path="$temp_dir/$normalized_name$(kind_suffix "$kind")"
cp -- "$source" "$candidate_path"
;;
*)
fail "Unsupported kind: $kind"
;;
esac
candidate_fingerprint="$(artifact_fingerprint "$candidate_path")"
candidate_display_name="$(artifact_display_name "$kind" "$candidate_path")"
if [[ -e "$target" ]]; then
existing_fingerprint="$(artifact_fingerprint "$target")"
if [[ "$existing_fingerprint" == "$candidate_fingerprint" ]]; then
append_log "$kind" "$source" "$target" "$origin_label" "$candidate_fingerprint" "noop"
cat <<EOF
No publish needed.
The normalized shared artifact already exists at:
$target
Fingerprint: $candidate_fingerprint
Display name: $candidate_display_name
EOF
return 0
fi
if [[ "$force" != "true" ]]; then
fail "Target already exists with different content: $target"
fi
fi
while IFS= read -r existing_path; do
[[ -n "$existing_path" ]] || continue
if [[ "$existing_path" == "$target" ]]; then
continue
fi
existing_fingerprint="$(artifact_fingerprint "$existing_path")"
if [[ "$existing_fingerprint" == "$candidate_fingerprint" ]]; then
fail "Duplicate $kind content already exists at: $existing_path"
fi
existing_display_name="$(artifact_display_name "$kind" "$existing_path")"
if [[ "$existing_display_name" == "$candidate_display_name" ]]; then
fail "Duplicate $kind display name '$candidate_display_name' already exists at: $existing_path"
fi
done < <(list_kind_artifacts "$kind")
mkdir -p -- "$(dirname -- "$target")"
rm -rf -- "$target"
if [[ -d "$candidate_path" ]]; then
cp -R -- "$candidate_path" "$target"
else
cp -- "$candidate_path" "$target"
fi
append_log "$kind" "$source" "$target" "$origin_label" "$candidate_fingerprint" "published"
cat <<EOF
Published $kind into shared repo:
Source: $source
Target: $target
Fingerprint: $candidate_fingerprint
Display name: $candidate_display_name
Next steps:
1. Review the published artifact.
2. Commit and push the change.
3. Run install/update.sh on other systems or let scheduled sync pick it up.
EOF
}
main "$@"

12
install/update.ps1 Normal file
View File

@@ -0,0 +1,12 @@
$ErrorActionPreference = 'Stop'
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$RepoRoot = Split-Path -Parent $ScriptDir
if (Test-Path -LiteralPath (Join-Path $RepoRoot '.git')) {
git -C $RepoRoot pull --ff-only
} else {
Write-Host 'Skipping git pull because this repository is not initialized as a git repository yet.'
}
& (Join-Path $ScriptDir 'verify.ps1') -Quick

18
install/update.sh Normal file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
repo_root="$(cd -- "$script_dir/.." && pwd -P)"
main() {
if [[ -d "$repo_root/.git" ]]; then
git -C "$repo_root" pull --ff-only
else
printf 'Skipping git pull because this repository is not initialized as a git repository yet.\n'
fi
"$script_dir/verify.sh" --quick
}
main "$@"

37
install/verify.ps1 Normal file
View File

@@ -0,0 +1,37 @@
$ErrorActionPreference = 'Stop'
param(
[switch]$Quick
)
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$RepoRoot = Split-Path -Parent $ScriptDir
$CanonicalHome = if ($env:COPILOT_RESOURCES_HOME) { $env:COPILOT_RESOURCES_HOME } else { Join-Path $HOME '.copilot-resources' }
$CopilotHome = if ($env:COPILOT_HOME) { $env:COPILOT_HOME } else { Join-Path $HOME '.copilot' }
$VscodeUserDir = if ($env:VSCODE_USER_DIR) { $env:VSCODE_USER_DIR } else { Join-Path $env:APPDATA 'Code\User' }
function Assert-Path {
param(
[string]$Label,
[string]$Path
)
if (Test-Path -LiteralPath $Path) {
Write-Host "[ok] $Label: $Path"
} else {
throw "Missing $Label: $Path"
}
}
Assert-Path -Label 'repo root' -Path $RepoRoot
Assert-Path -Label 'canonical home' -Path $CanonicalHome
Assert-Path -Label 'skills link' -Path (Join-Path $CopilotHome 'skills')
Assert-Path -Label 'agents link' -Path (Join-Path $CopilotHome 'agents')
Assert-Path -Label 'instructions link' -Path (Join-Path $CopilotHome 'instructions')
Assert-Path -Label 'hooks link' -Path (Join-Path $CopilotHome 'hooks')
Assert-Path -Label 'prompts link' -Path (Join-Path $VscodeUserDir 'prompts')
if (-not $Quick) {
Assert-Path -Label 'VS Code settings template' -Path (Join-Path $RepoRoot 'config\vscode\settings.template.jsonc')
Assert-Path -Label 'CLI env template' -Path (Join-Path $RepoRoot 'config\copilot-cli\env.example.ps1')
}

64
install/verify.sh Normal file
View File

@@ -0,0 +1,64 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
repo_root="$(cd -- "$script_dir/.." && pwd -P)"
canonical_home="${COPILOT_RESOURCES_HOME:-$HOME/.copilot-resources}"
copilot_home="${COPILOT_HOME:-$HOME/.copilot}"
quick="false"
detect_vscode_user_dir() {
if [[ -n "${VSCODE_USER_DIR:-}" ]]; then
printf '%s\n' "$VSCODE_USER_DIR"
return 0
fi
case "$(uname -s)" in
Darwin)
printf '%s\n' "$HOME/Library/Application Support/Code/User"
;;
Linux)
printf '%s\n' "$HOME/.config/Code/User"
;;
*)
printf '%s\n' "$HOME/.config/Code/User"
;;
esac
}
check_path() {
local label="$1"
local path="$2"
if [[ -e "$path" ]]; then
printf '[ok] %s: %s\n' "$label" "$path"
else
printf '[missing] %s: %s\n' "$label" "$path" >&2
return 1
fi
}
main() {
if [[ "${1:-}" == "--quick" ]]; then
quick="true"
fi
local vscode_user_dir
vscode_user_dir="$(detect_vscode_user_dir)"
check_path "repo root" "$repo_root"
check_path "canonical home" "$canonical_home"
check_path "skills link" "$copilot_home/skills"
check_path "agents link" "$copilot_home/agents"
check_path "instructions link" "$copilot_home/instructions"
check_path "hooks link" "$copilot_home/hooks"
check_path "prompts link" "$vscode_user_dir/prompts"
if [[ "$quick" != "true" ]]; then
check_path "VS Code settings template" "$repo_root/config/vscode/settings.template.jsonc"
check_path "CLI env template" "$repo_root/config/copilot-cli/env.example.sh"
fi
}
main "$@"