Initial shared Copilot resources scaffold
This commit is contained in:
182
install/bootstrap.ps1
Normal file
182
install/bootstrap.ps1
Normal 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
264
install/bootstrap.sh
Normal 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 "$@"
|
||||
248
install/merge-vscode-settings.mjs
Normal file
248
install/merge-vscode-settings.mjs
Normal 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
372
install/publish.ps1
Normal 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
501
install/publish.sh
Normal 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
12
install/update.ps1
Normal 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
18
install/update.sh
Normal 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
37
install/verify.ps1
Normal 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
64
install/verify.sh
Normal 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 "$@"
|
||||
Reference in New Issue
Block a user