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

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.DS_Store
Thumbs.db

9
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,9 @@
# Contributing
Shared resources can be added in two ways:
- Create them directly in this repository.
- Publish them into this repository from a local workspace or profile.
Contribution standards, naming rules, review criteria, and governance docs will
be added under `docs/` as the handbook is implemented.

13
PURPOSE.md Normal file
View File

@@ -0,0 +1,13 @@
# Purpose
This project exists to make reusable Copilot customizations portable,
maintainable, and easy to evolve.
Without a shared repository, prompts, skills, agents, and instructions tend to
fragment across user profiles, workspace folders, machines, and shell sessions.
That makes it difficult to keep behavior consistent or to benefit from what was
learned in another session.
This repository solves that by keeping shared resources under version control,
making the repository authoritative after creation, and providing tooling to
bootstrap, publish, update, and verify the shared environment.

58
README.md Normal file
View File

@@ -0,0 +1,58 @@
# Copilot Resources
Copilot Resources is a shared, Git-backed home for prompts, skills, agents,
instructions, hooks, scripts, and templates that should follow you across
machines and across VS Code and Copilot CLI sessions.
## Goals
- Keep one canonical source of truth for shared Copilot assets.
- Make new resources easy to create, publish, and propagate.
- Minimize per-machine setup after the first bootstrap.
- Support macOS, Linux, and Windows.
## Current Foundation
This initial implementation provides:
- A stable install model based on `~/.copilot-resources`
- Bootstrap scripts for macOS/Linux and Windows
- Publish, update, and verify scripts
- Scripted VS Code settings merge for managed Copilot-related keys
- Scripted Copilot CLI environment wiring through a managed sourced fragment
## Operating Model
There are two supported ways to add shared assets:
- Repo-first: create the resource directly in this repository.
- Local-first: create the resource in a normal workspace or user profile, then
publish it into this repository with the publish script.
Once a resource lands in this repository, the repository becomes authoritative.
Git push and pull plus scheduled sync spread the change to other systems.
## Bootstrap
Run one of these from the repository root:
```bash
install/bootstrap.sh
```
```powershell
install/bootstrap.ps1
```
The bootstrap scripts create a stable canonical path and connect default
discovery locations back to this repository. For most surfaces, that means
symlinks or junctions into default VS Code and Copilot paths rather than
per-machine copies. They also merge the managed VS Code settings keys and add a
small managed shell or PowerShell profile block for Copilot CLI environment
variables without replacing the rest of the user config.
## Next Docs
The rest of the handbook will live in `docs/` and will cover architecture,
authoring, publishing, governance, versioning, review standards, and
troubleshooting.

View File

@@ -0,0 +1,3 @@
$env:COPILOT_RESOURCES_HOME = if ($env:COPILOT_RESOURCES_HOME) { $env:COPILOT_RESOURCES_HOME } else { Join-Path $HOME '.copilot-resources' }
$env:COPILOT_HOME = if ($env:COPILOT_HOME) { $env:COPILOT_HOME } else { Join-Path $HOME '.copilot' }
$env:COPILOT_CUSTOM_INSTRUCTIONS_DIRS = Join-Path $env:COPILOT_RESOURCES_HOME 'resources\instructions'

View File

@@ -0,0 +1,3 @@
export COPILOT_RESOURCES_HOME="${COPILOT_RESOURCES_HOME:-$HOME/.copilot-resources}"
export COPILOT_HOME="${COPILOT_HOME:-$HOME/.copilot}"
export COPILOT_CUSTOM_INSTRUCTIONS_DIRS="${COPILOT_RESOURCES_HOME}/resources/instructions"

View File

@@ -0,0 +1,29 @@
{
// Optional feature flags and direct-path discovery fallback.
// Most discovery is intended to work through the default paths linked by bootstrap.
"github.copilot.chat.cli.customAgents.enabled": true,
"chat.useCustomAgentHooks": true,
"chat.useAgentsMdFile": true,
"chat.useClaudeMdFile": true,
"chat.useCustomizationsInParentRepositories": true,
// Direct-path fallback configuration in case symlinked defaults are not preferred.
"chat.instructionsFilesLocations": {
"{{COPILOT_RESOURCES_HOME}}/resources/instructions": true,
"~/.claude/rules": true
},
"chat.agentFilesLocations": {
"{{COPILOT_RESOURCES_HOME}}/resources/agents": true,
"~/.copilot/agents": true
},
"chat.agentSkillsLocations": {
"{{COPILOT_RESOURCES_HOME}}/resources/skills": true
},
"chat.promptFilesLocations": {
"{{COPILOT_RESOURCES_HOME}}/resources/prompts": true
},
"chat.hookFilesLocations": {
"{{COPILOT_RESOURCES_HOME}}/resources/hooks": true,
"~/.claude/settings.json": true
}
}

40
docs/architecture.md Normal file
View File

@@ -0,0 +1,40 @@
# Architecture
## Overview
This repository is the canonical source of truth for shared Copilot resources.
It separates reusable workflows from tool-specific adapters and keeps propagation
Git-based after a resource is published.
## Layers
- `resources/skills/`: portable workflows for VS Code, Copilot CLI, and cloud agents
- `resources/prompts/`: VS Code slash-command adapters
- `resources/instructions/`: shared instruction packs
- `resources/agents/`: shared custom agents for local chat and overlays
- `resources/hooks/`: shared hook definitions
- `templates/repo-overlay/`: files that can be copied into another repository for
repository-scoped behavior
## Discovery Model
Bootstrap prefers linking default discovery paths back to this repository:
- `~/.copilot/skills` -> `resources/skills`
- `~/.copilot/agents` -> `resources/agents`
- `~/.copilot/instructions` -> `resources/instructions`
- `~/.copilot/hooks` -> `resources/hooks`
- VS Code user prompts directory -> `resources/prompts`
This keeps the repository authoritative while still using default discovery
locations whenever possible.
## Propagation Model
There are only two supported creation paths:
- repo-first
- local-first followed by publish-to-repo
Once a resource lands in the repository, commit and push it. Other systems pick
it up through `install/update.*` or future scheduled sync.

29
docs/authoring.md Normal file
View File

@@ -0,0 +1,29 @@
# Authoring Guide
## Choose The Right Primitive
- Use a skill for reusable multi-step workflows, especially if the workflow
should work in Copilot CLI as well as VS Code.
- Use a prompt for a lightweight VS Code slash command.
- Use an instruction file for conventions or rules that should influence model
behavior.
- Use an agent for a reusable persona with tool restrictions or handoffs.
- Use a hook only when the behavior must be deterministic and enforced in code.
## Supported Creation Paths
### Repo-first
Open this repository in VS Code and create the resource directly in the matching
folder.
### Local-first
Create the resource from a normal VS Code workspace or user-profile flow, then
publish it into this repository with `install/publish.sh` or
`install/publish.ps1`.
## Rule Of Record
If a resource is meant to be shared, the version in this repository is the only
version that counts.

9
docs/deprecation.md Normal file
View File

@@ -0,0 +1,9 @@
# Deprecation Policy
## Rules
- Do not remove a shared resource without documenting the replacement or the
reason for removal.
- Prefer marking a resource deprecated in its description or body before
deleting it.
- Keep migration guidance in the same change that introduces the replacement.

18
docs/governance.md Normal file
View File

@@ -0,0 +1,18 @@
# Governance
## Scope
This repository stores shared Copilot resources, the tooling needed to install
them, and the documentation required to maintain them.
## Decision Rules
- Prefer portable workflows over tool-specific duplication.
- Prefer additive changes over breaking changes.
- Prefer repo-first authoring when the work is intentionally shared.
- Require publish-to-repo before considering a local resource shared.
## Stewardship
Project stewards review structural changes, naming conventions, lifecycle rules,
and anything that changes propagation behavior.

20
docs/naming.md Normal file
View File

@@ -0,0 +1,20 @@
# Naming Standards
## General
- Use lowercase kebab-case for directory names and skill names.
- Keep filenames descriptive and stable.
- Match skill directory names to the `name` field in `SKILL.md`.
## File Conventions
- Prompts end in `.prompt.md`
- Instructions end in `.instructions.md`
- Agents end in `.agent.md`
- Hooks end in `.json`
## Frontmatter
- Always include a meaningful `description` for skills, prompts, and agents.
- Keep descriptions discovery-oriented: include what the resource does and when
to use it.

23
docs/operations.md Normal file
View File

@@ -0,0 +1,23 @@
# Operations
## Update
```bash
install/update.sh
```
```powershell
install/update.ps1
```
## Verify
```bash
install/verify.sh
```
```powershell
install/verify.ps1
```
Scheduled sync will be added on top of the same update and verify entrypoints.

14
docs/ownership.md Normal file
View File

@@ -0,0 +1,14 @@
# Ownership
## Resource Ownership
- Skills and scripts should have a clear maintainer because they affect multiple
surfaces.
- Hooks require extra scrutiny because they execute code.
- Repository overlay templates should be maintained with GitHub-side behavior in
mind.
## Change Responsibility
Whoever introduces a shared resource is responsible for documenting it,
verifying it, and proposing deprecation guidance if it later becomes obsolete.

37
docs/publishing.md Normal file
View File

@@ -0,0 +1,37 @@
# Publishing Guide
## Why Publishing Exists
VS Code creation flows can write to a workspace or user profile, but those
locations are not the authoritative shared source. Publishing moves the resource
into this repository so other sessions and machines can consume the same asset.
## Examples
```bash
install/publish.sh --source /path/to/example.prompt.md
```
```powershell
install/publish.ps1 -Source C:\path\to\example.agent.md
```
## What Publish Normalizes
- target filenames and skill directory names are normalized into lowercase
kebab-case
- expected suffixes are enforced for prompts, instructions, agents, and hooks
- skill `name` metadata is aligned to the published directory name
## What Publish Rejects
- exact content duplicates that already exist elsewhere in the shared repo
- effective display-name duplicates, even if the filenames differ
- conflicting targets unless you explicitly replace them with `--force` or
`-Force`
## After Publishing
1. Review the normalized target in this repository.
2. Commit and push.
3. Update other systems.

10
docs/release-process.md Normal file
View File

@@ -0,0 +1,10 @@
# Release Process
## Flow
1. Add or publish the resource into this repository.
2. Review the change against naming, review, and portability rules.
3. Commit and push.
4. Run `install/update.sh` or `install/update.ps1` on consuming systems, or let
scheduled sync pick up the change.
5. Verify the resource appears in the expected VS Code and CLI surfaces.

10
docs/review-standards.md Normal file
View File

@@ -0,0 +1,10 @@
# Review Standards
A new shared resource should be reviewed for:
- Correct location and naming
- Valid frontmatter and discovery text
- Portability expectations
- Security implications, especially for hooks and scripts
- Clear purpose and expected usage
- Duplication with existing resources

31
docs/setup.md Normal file
View File

@@ -0,0 +1,31 @@
# Setup
## macOS Or Linux
```bash
install/bootstrap.sh
```
## Windows
```powershell
install/bootstrap.ps1
```
## What Bootstrap Does
- Creates a canonical path at `~/.copilot-resources`
- Links default discovery locations back to this repository
- Merges only the managed Copilot-related VS Code settings into the user settings file
- Writes a managed Copilot CLI environment fragment and sources it from the shell or PowerShell profile
- Writes a local install-state file outside the repository
## Optional Settings
Bootstrap renders and merges the managed keys from
`config/vscode/settings.template.jsonc` into the user settings file. Existing
unmanaged VS Code settings are preserved.
Bootstrap also writes a managed Copilot CLI environment file into the local
state directory and adds a small managed source block to the active shell or
PowerShell profile instead of replacing the whole profile.

21
docs/troubleshooting.md Normal file
View File

@@ -0,0 +1,21 @@
# Troubleshooting
## Resource Does Not Appear
- Run the verify script.
- Confirm the resource exists in the repository.
- Confirm the default discovery link exists.
- Use VS Code Chat Diagnostics to confirm the resource is being loaded.
## Changed Resource Does Not Reload
Treat hot reload as likely but not guaranteed. If needed:
- start a new chat session
- restart the Copilot CLI session
- reopen VS Code
## Publish Refused To Overwrite
The publish scripts stop on collisions by default. Use a new name or rerun with
`--force` only after confirming the replacement is intentional.

10
docs/versioning.md Normal file
View File

@@ -0,0 +1,10 @@
# Versioning
This repository versions the shared resource set as a whole through Git.
## Compatibility
- Additive resources are preferred and should not require coordinated rollout.
- Renames and removals should be treated as breaking changes.
- If a resource changes behavior in a non-obvious way, document it in the
change description and deprecation notes.

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 "$@"

View File

@@ -0,0 +1,20 @@
---
name: "Resource Maintainer"
description: "Use when maintaining the shared Copilot resource repository, publishing local resources into the repo, or reviewing naming, portability, and propagation concerns."
tools: [read, search, edit, execute, todo]
model: "GPT-5 (copilot)"
---
You maintain the shared Copilot resource repository.
## Constraints
- Keep the repository authoritative for shared assets.
- Prefer small, reviewable changes.
- Validate scripts and config before finishing.
- Treat prompts as adapters and skills as the canonical portable workflow layer.
## Output
Summarize what changed, how it propagates, and any follow-up validation or
commit steps.

View File

@@ -0,0 +1,12 @@
{
"hooks": {
"SessionStart": [
{
"type": "command",
"osx": "~/.copilot-resources/resources/scripts/report-hook-event.sh",
"linux": "~/.copilot-resources/resources/scripts/report-hook-event.sh",
"windows": "powershell -NoProfile -File \"$HOME/.copilot-resources/resources/scripts/report-hook-event.ps1\""
}
]
}
}

View File

@@ -0,0 +1,12 @@
---
name: "Copilot Customization Standards"
description: "Use when authoring or editing shared Copilot prompts, instructions, agents, skills, hooks, or overlay templates. Covers naming, portability, and frontmatter expectations."
applyTo: "resources/prompts/**/*.prompt.md,resources/instructions/**/*.instructions.md,resources/agents/**/*.agent.md,resources/skills/**/SKILL.md,templates/repo-overlay/.github/instructions/**/*.instructions.md,templates/repo-overlay/.github/agents/**/*.agent.md"
---
- Prefer skills for portable workflows and prompts for VS Code-only adapters.
- Keep descriptions discovery-oriented: say what the resource does and when to
use it.
- Do not duplicate workflow logic across prompts and skills without a reason.
- Keep shared resources neutral and reusable rather than tied to one machine.
- Treat repository overlays as templates, not as the authoritative shared copy.

5
resources/mcp/README.md Normal file
View File

@@ -0,0 +1,5 @@
# MCP Notes
This folder is reserved for reusable MCP references and safe shared
configuration snippets. Machine-specific secrets and authenticated local server
definitions should stay out of the repository.

View File

@@ -0,0 +1,17 @@
---
name: "publish-resource"
description: "Publish a prompt, instruction, agent, hook, or skill into the shared Copilot resource repository."
agent: "agent"
tools: [read, search, edit, execute]
argument-hint: "source=<path> kind=<prompt|instruction|agent|hook|skill> name=<optional>"
---
Publish the resource into the shared repository by using the repository's
publish script.
Requirements:
- Detect or confirm the resource kind.
- Use the repository publish script instead of copying files manually.
- Review the normalized result.
- Summarize what changed and what should be committed.

View File

@@ -0,0 +1,8 @@
$ErrorActionPreference = 'Stop'
$StateDir = if ($env:COPILOT_RESOURCES_STATE_DIR) { $env:COPILOT_RESOURCES_STATE_DIR } else { Join-Path $HOME '.copilot-resources-state' }
New-Item -ItemType Directory -Path $StateDir -Force | Out-Null
$Payload = [Console]::In.ReadToEnd()
$Payload | Add-Content -LiteralPath (Join-Path $StateDir 'hook-events.log')
'{"continue": true}'

View File

@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
state_dir="${COPILOT_RESOURCES_STATE_DIR:-$HOME/.copilot-resources-state}"
mkdir -p -- "$state_dir"
event_payload="$(cat)"
printf '%s\n' "$event_payload" >> "$state_dir/hook-events.log"
printf '{"continue": true}\n'

View File

@@ -0,0 +1,23 @@
---
name: resource-authoring
description: "Use when adding, publishing, or reviewing shared Copilot resources such as skills, prompts, instructions, agents, hooks, or supporting scripts."
argument-hint: "[resource type] [goal]"
---
# Resource Authoring
Use this skill when the task is to create or evolve a shared Copilot resource in
the repository.
## Procedure
1. Decide whether the resource should be a skill, prompt, agent, instruction,
or hook.
2. Prefer repo-first authoring when the work is intentionally shared.
3. If the resource originated elsewhere, publish it into the repository.
4. Review naming, portability, and lifecycle impact.
5. Validate any scripts or automation referenced by the resource.
## References
- [Publishing checklist](./references/publishing-checklist.md)

View File

@@ -0,0 +1,7 @@
# Publishing Checklist
- Choose the correct primitive.
- Confirm the target folder and filename.
- Confirm frontmatter is valid and descriptive.
- Avoid duplicating a workflow that already exists.
- If scripts are referenced, validate them before merging.

View File

@@ -0,0 +1,8 @@
# New Shared Resource Checklist
- Pick the correct primitive.
- Confirm the target location.
- Add clear frontmatter.
- Check naming and portability rules.
- Validate any referenced scripts.
- Commit and push after publishing.

View File

@@ -0,0 +1,7 @@
# CLAUDE
This repository imports a shared AI overlay.
- Keep changes minimal.
- Validate what you change.
- Prefer existing patterns over novelty.

View File

@@ -0,0 +1,11 @@
---
name: "Repository Maintainer"
description: "Use when maintaining a repository that imports the shared Copilot overlay template and needs focused, validation-driven changes."
tools: [read, search, edit, execute, todo]
---
You maintain a repository that uses the shared Copilot overlay template.
- Prefer narrow validation.
- Keep changes small.
- Summarize risks, validation, and next steps.

View File

@@ -0,0 +1,6 @@
# Repository Copilot Instructions
- Follow the repository's documented build, test, and validation steps before
finishing a change.
- Prefer existing patterns and project conventions over introducing new ones.
- Keep changes focused and minimal.

View File

@@ -0,0 +1,9 @@
---
name: "Shared Repository Standards"
description: "Use when editing files in a repository that imports the shared Copilot overlay template. Covers validation, minimal changes, and reuse of existing patterns."
applyTo: "**"
---
- Reuse existing project patterns before introducing new abstractions.
- Validate changes with the narrowest relevant check available.
- Keep edits focused on the requested outcome.

View File

@@ -0,0 +1,7 @@
# AGENTS
This repository uses shared Copilot overlay instructions.
- Prefer focused changes.
- Validate the touched slice before expanding scope.
- Reuse existing conventions and tooling.