commit adfcb83ab60853337aa00067ba9963dd45634ab0 Author: FragginWagon Date: Thu Apr 23 15:46:34 2026 -0400 Initial shared Copilot resources scaffold diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fafff2e --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +Thumbs.db diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6adbe48 --- /dev/null +++ b/CONTRIBUTING.md @@ -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. diff --git a/PURPOSE.md b/PURPOSE.md new file mode 100644 index 0000000..c31d9fb --- /dev/null +++ b/PURPOSE.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7bd2538 --- /dev/null +++ b/README.md @@ -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. diff --git a/config/copilot-cli/env.example.ps1 b/config/copilot-cli/env.example.ps1 new file mode 100644 index 0000000..2c77036 --- /dev/null +++ b/config/copilot-cli/env.example.ps1 @@ -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' \ No newline at end of file diff --git a/config/copilot-cli/env.example.sh b/config/copilot-cli/env.example.sh new file mode 100644 index 0000000..4cb4430 --- /dev/null +++ b/config/copilot-cli/env.example.sh @@ -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" diff --git a/config/vscode/settings.template.jsonc b/config/vscode/settings.template.jsonc new file mode 100644 index 0000000..cb0abc6 --- /dev/null +++ b/config/vscode/settings.template.jsonc @@ -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 + } +} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..b977fee --- /dev/null +++ b/docs/architecture.md @@ -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. diff --git a/docs/authoring.md b/docs/authoring.md new file mode 100644 index 0000000..df9d9f0 --- /dev/null +++ b/docs/authoring.md @@ -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. diff --git a/docs/deprecation.md b/docs/deprecation.md new file mode 100644 index 0000000..fa6bd55 --- /dev/null +++ b/docs/deprecation.md @@ -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. diff --git a/docs/governance.md b/docs/governance.md new file mode 100644 index 0000000..178793e --- /dev/null +++ b/docs/governance.md @@ -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. diff --git a/docs/naming.md b/docs/naming.md new file mode 100644 index 0000000..763322f --- /dev/null +++ b/docs/naming.md @@ -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. diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..1ac3d96 --- /dev/null +++ b/docs/operations.md @@ -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. diff --git a/docs/ownership.md b/docs/ownership.md new file mode 100644 index 0000000..eaa6758 --- /dev/null +++ b/docs/ownership.md @@ -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. diff --git a/docs/publishing.md b/docs/publishing.md new file mode 100644 index 0000000..800d1df --- /dev/null +++ b/docs/publishing.md @@ -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. diff --git a/docs/release-process.md b/docs/release-process.md new file mode 100644 index 0000000..c415a4c --- /dev/null +++ b/docs/release-process.md @@ -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. diff --git a/docs/review-standards.md b/docs/review-standards.md new file mode 100644 index 0000000..83578e0 --- /dev/null +++ b/docs/review-standards.md @@ -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 diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 0000000..a032049 --- /dev/null +++ b/docs/setup.md @@ -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. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..8845c54 --- /dev/null +++ b/docs/troubleshooting.md @@ -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. diff --git a/docs/versioning.md b/docs/versioning.md new file mode 100644 index 0000000..955fcd2 --- /dev/null +++ b/docs/versioning.md @@ -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. diff --git a/install/bootstrap.ps1 b/install/bootstrap.ps1 new file mode 100644 index 0000000..f4917a5 --- /dev/null +++ b/install/bootstrap.ps1 @@ -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')" diff --git a/install/bootstrap.sh b/install/bootstrap.sh new file mode 100644 index 0000000..d0ae75a --- /dev/null +++ b/install/bootstrap.sh @@ -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" < "$managed_shell_env" <>> 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" <&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 < --template [--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(); \ No newline at end of file diff --git a/install/publish.ps1 b/install/publish.ps1 new file mode 100644 index 0000000..fddd9e0 --- /dev/null +++ b/install/publish.ps1 @@ -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 + } +} diff --git a/install/publish.sh b/install/publish.sh new file mode 100644 index 0000000..dc6c604 --- /dev/null +++ b/install/publish.sh @@ -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 <&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 "$@" diff --git a/resources/agents/resource-maintainer.agent.md b/resources/agents/resource-maintainer.agent.md new file mode 100644 index 0000000..b185120 --- /dev/null +++ b/resources/agents/resource-maintainer.agent.md @@ -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. diff --git a/resources/hooks/session-audit.json b/resources/hooks/session-audit.json new file mode 100644 index 0000000..6c2193d --- /dev/null +++ b/resources/hooks/session-audit.json @@ -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\"" + } + ] + } +} diff --git a/resources/instructions/copilot-customization.instructions.md b/resources/instructions/copilot-customization.instructions.md new file mode 100644 index 0000000..bec67ab --- /dev/null +++ b/resources/instructions/copilot-customization.instructions.md @@ -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. diff --git a/resources/mcp/README.md b/resources/mcp/README.md new file mode 100644 index 0000000..663bcff --- /dev/null +++ b/resources/mcp/README.md @@ -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. diff --git a/resources/prompts/publish-resource.prompt.md b/resources/prompts/publish-resource.prompt.md new file mode 100644 index 0000000..0a363dc --- /dev/null +++ b/resources/prompts/publish-resource.prompt.md @@ -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= kind= name=" +--- + +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. diff --git a/resources/scripts/report-hook-event.ps1 b/resources/scripts/report-hook-event.ps1 new file mode 100644 index 0000000..c7de948 --- /dev/null +++ b/resources/scripts/report-hook-event.ps1 @@ -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}' diff --git a/resources/scripts/report-hook-event.sh b/resources/scripts/report-hook-event.sh new file mode 100644 index 0000000..c9554b0 --- /dev/null +++ b/resources/scripts/report-hook-event.sh @@ -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' diff --git a/resources/skills/resource-authoring/SKILL.md b/resources/skills/resource-authoring/SKILL.md new file mode 100644 index 0000000..bb39b41 --- /dev/null +++ b/resources/skills/resource-authoring/SKILL.md @@ -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) diff --git a/resources/skills/resource-authoring/references/publishing-checklist.md b/resources/skills/resource-authoring/references/publishing-checklist.md new file mode 100644 index 0000000..c66b5c0 --- /dev/null +++ b/resources/skills/resource-authoring/references/publishing-checklist.md @@ -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. diff --git a/resources/templates/new-resource-checklist.md b/resources/templates/new-resource-checklist.md new file mode 100644 index 0000000..3a852ca --- /dev/null +++ b/resources/templates/new-resource-checklist.md @@ -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. diff --git a/templates/repo-overlay/.claude/CLAUDE.md b/templates/repo-overlay/.claude/CLAUDE.md new file mode 100644 index 0000000..865ac38 --- /dev/null +++ b/templates/repo-overlay/.claude/CLAUDE.md @@ -0,0 +1,7 @@ +# CLAUDE + +This repository imports a shared AI overlay. + +- Keep changes minimal. +- Validate what you change. +- Prefer existing patterns over novelty. \ No newline at end of file diff --git a/templates/repo-overlay/.github/agents/resource-maintainer.agent.md b/templates/repo-overlay/.github/agents/resource-maintainer.agent.md new file mode 100644 index 0000000..4a0fe38 --- /dev/null +++ b/templates/repo-overlay/.github/agents/resource-maintainer.agent.md @@ -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. diff --git a/templates/repo-overlay/.github/copilot-instructions.md b/templates/repo-overlay/.github/copilot-instructions.md new file mode 100644 index 0000000..9ee8457 --- /dev/null +++ b/templates/repo-overlay/.github/copilot-instructions.md @@ -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. diff --git a/templates/repo-overlay/.github/instructions/shared.instructions.md b/templates/repo-overlay/.github/instructions/shared.instructions.md new file mode 100644 index 0000000..754e125 --- /dev/null +++ b/templates/repo-overlay/.github/instructions/shared.instructions.md @@ -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. diff --git a/templates/repo-overlay/AGENTS.md b/templates/repo-overlay/AGENTS.md new file mode 100644 index 0000000..3fe93df --- /dev/null +++ b/templates/repo-overlay/AGENTS.md @@ -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.