diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..7ac931b --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,97 @@ +# Copilot Instructions + +This repository contains PowerShell scripts that sync Copilot resources from [github/awesome-copilot](https://github.com/github/awesome-copilot) to a local machine and distribute them to the right VS Code/Copilot locations. + +## Script Workflow + +Scripts are designed to be run in this order: + +``` +configure.ps1 # Main entry point (chains all steps) +scripts/sync-awesome-copilot.ps1 # 1. Clone/pull github/awesome-copilot → ~/.awesome-copilot/ +scripts/publish-global.ps1 # 2. Publish agents + skills globally +scripts/init-repo.ps1 # 3. Interactive per-repo setup → .github/ +scripts/install-scheduled-task.ps1 # 4. Automate steps 1+2 on a schedule +``` + +**Resource scopes:** +- **Global** (machine-wide): Agents → `%APPDATA%\Code\User\prompts\`; Skills → `~/.copilot/skills/` +- **Per-repo** (committed to `.github/`): Instructions, Hooks, Workflows + +## Key Conventions + +### Error Handling +All scripts use `$ErrorActionPreference = 'Stop'` so errors terminate rather than prompt. Use `try/catch` blocks for recoverable errors — do not rely on error preference for expected failure paths. + +### Logging +Use the `Log` / `Write-Log` function (not `Write-Host` directly): +```powershell +Log "Message here" # INFO (Cyan) +Log "Something wrong" 'WARN' # Yellow +Log "Done!" 'SUCCESS' # Green +Log "Failed" 'ERROR' # Red +``` + +### Dry-Run Pattern +Every destructive operation must be guarded by `$DryRun`: +```powershell +if ($DryRun) { Log "[DryRun] Would do X"; return 'would-copy' } +# actual operation here +``` + +### Change Detection +Always use SHA256 hash comparison before copying — never overwrite blindly: +```powershell +$srcHash = (Get-FileHash $Src -Algorithm SHA256).Hash +$dstHash = if (Test-Path $dest) { (Get-FileHash $dest -Algorithm SHA256).Hash } else { $null } +if ($srcHash -eq $dstHash) { return 'unchanged' } +``` + +### Portable Paths +Always use `$HOME`, `$env:APPDATA`, and `Join-Path` — never hardcode user paths: +```powershell +# ✅ +$cacheDir = Join-Path $HOME '.awesome-copilot' +# ❌ +$cacheDir = 'C:\Users\Someone\.awesome-copilot' +``` + +### Parameter Patterns +- `-DryRun` / `-Plan` — preview without writing +- `-Skip*` switches (e.g. `-SkipAgents`, `-SkipHooks`) — granular opt-out +- Comma-separated strings for lists: `[string]$Categories = 'agents,instructions,workflows,hooks,skills'` +- Default paths always use `$HOME` or `$env:APPDATA` + +## External Dependencies + +- **`gh` (GitHub CLI)**: preferred tool for cloning/pulling `github/awesome-copilot`; handles authentication automatically via `gh auth login`. Falls back to `git` if `gh` is not available. +- **`Out-GridView`**: used in `init-repo.ps1` for interactive picking; automatically falls back to a numbered console menu if unavailable. + +## Local Cache Structure + +`sync-awesome-copilot.ps1` writes to `~/.awesome-copilot/` (a sparse git clone): +``` +~/.awesome-copilot/ + .git/ git metadata (managed automatically) + agents/ *.agent.md + instructions/ *.instructions.md + workflows/ *.md + hooks/ / (directories) + skills/ / (directories) + manifest.json file inventory with hashes (written after each sync) + status.txt human-readable summary of last sync run +``` + +Sync logs are written to a `logs/` folder in the working directory where the script was invoked (typically the repo root when run via `configure.ps1`). + +## Scheduled Task + +`configure.ps1 -InstallTask` chains `sync-awesome-copilot.ps1 → publish-global.ps1` and registers a Windows Scheduled Task named `AwesomeCopilotSync` (delegating to `scripts/install-scheduled-task.ps1`). The task runs under the current user context — the user must be logged in for it to execute. + +## Contributing + +- Update `CHANGELOG.md` with every change under the appropriate version +- Test with `-DryRun` / `-Plan` before running live +- Run `sync-awesome-copilot.ps1 -Plan` to verify without writing files +- New parameters must follow the existing `[switch]$Skip*` / `[string]$Target` naming conventions +- See `CONTRIBUTING.md` for the full PR checklist diff --git a/.gitignore b/.gitignore index 635fccf..6b42699 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,6 @@ # Logs directory (contains sync operation logs) logs/ -# Local awesome-copilot cache (user-specific) -.awesome-copilot/ - # PowerShell history .history @@ -14,4 +11,24 @@ logs/ Thumbs.db # macOS -.DS_Store \ No newline at end of file +.DS_Store + +# Local cache (managed by sync-awesome-copilot.ps1 - not committed) +.awesome-copilot/ + +# Temp files from PowerShell / editors +*.tmp +*.bak +~$* + +# Per-repo Copilot resources installed from github/awesome-copilot +# These belong to the awesome-copilot community — not ours to redistribute. +# Commit these in YOUR projects; exclude them here (demo/tooling repo only). +.github/agents/ +.github/hooks/ +.github/instructions/ +.github/skills/ +# Only ignore copilot agentic workflow .md files — not GitHub Actions .yml files +.github/workflows/*.md +# Subscription tracking manifest (local state, not redistributed) +.github/.copilot-subscriptions.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 9362609..b9054c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,110 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.2.2] - 2026-02-27 + +### Changed + +- `configure.ps1`: use `$PSScriptRoot` to locate the `scripts/` folder (replaces `$MyInvocation.MyCommand.Path` which behaves differently when dot-sourced) +- `scripts/sync-awesome-copilot.ps1`: replace manual SHA256 with built-in `Get-FileHash` — cleaner and avoids loading entire file into memory +- `scripts/publish-global.ps1`: emit a `WARN` log when VS Code `settings.json` is not found (was a silent no-op); user is directed to open VS Code once to generate the file + +## [1.2.1] - 2026-02-27 + +### Fixed + +- `configure.ps1`: `-InstallTask` / `-UninstallTask` now automatically skip the `init-repo` prompt (validation moved before Step 1 so the flag takes effect) +- `configure.ps1`: prompts to overwrite when the scheduled task already exists, instead of throwing a hard error +- `scripts/install-scheduled-task.ps1`: added `-WorkingDirectory` to both scheduled task actions (was defaulting to `C:\Windows\System32`, causing a permissions error creating the `logs/` directory) +- `scripts/sync-awesome-copilot.ps1`: replaced relative `logs/` path with `$PSScriptRoot/logs/` so logs always land in `scripts/logs/` regardless of working directory +- `scripts/install-scheduled-task.ps1`: updated task description (removed stale "combine" wording) +- `scripts/publish-global.ps1`: fixed named-profile example path (`agents\` → `prompts\`) +- `README.md`: corrected default interval (6h → 4h), log paths, authentication section, and custom-repo instructions +- `.github/copilot-instructions.md`: removed stale GitHub API / `GITHUB_TOKEN` references; updated cache structure + +## [1.2.0] - 2026-02-27 + +### Changed + +- `scripts/sync-awesome-copilot.ps1`: **Rewritten** — replaced GitHub API + per-file HTTP download approach with `git sparse-checkout`. First run clones `github/awesome-copilot` shallowly with only the requested categories; subsequent runs run `git pull` for near-instant delta updates. Dramatically faster (single bulk transfer vs 700+ individual HTTP requests) and removes GitHub API rate-limit concerns entirely. + - Prefers `gh` (GitHub CLI) for automatic auth; falls back to `git` + - New `-GitTool auto|gh|git` parameter to override tool selection + - Removed parameters: `-NoDelete`, `-DiffOnly`, `-SkipBackup`, `-BackupRetention` (git handles all of these natively) + - Migrates automatically from the old API-based cache (renames non-git `~/.awesome-copilot/` to `~/.awesome-copilot-backup-` before cloning) + - `manifest.json` still written (from local file scan) for backward compatibility with `publish-global.ps1` and `configure.ps1` + +### Added + +- `README.md`: document `gh`/`git` requirement; update sync section to reflect git-based approach + +## [1.1.2] - 2026-02-27 + +### Added + +- `configure.ps1` — interactive orchestrator that chains sync → publish-global → init-repo; each step independently skippable via `-SkipSync`, `-SkipPublish`, `-SkipInit`; `-DryRun` passes through to all child scripts; shows last sync timestamp from cache manifest before running + +### Added + +- `init-repo.ps1`: added Agents as a fourth interactive category (installs to `.github/agents/`) +- `init-repo.ps1`: `Detect-RepoStack` — auto-detects language/framework from file signals and marks relevant items with ★ in the picker +- `init-repo.ps1`: `Prompt-RepoIntent` — interactive fallback for new/empty repos; asks language, project type, and concerns +- `init-repo.ps1`: `-- none / skip --` sentinel row in every OGV picker so clicking OK with no intentional selection installs nothing +- `publish-global.ps1`: auto-configures `chat.useAgentSkills` and `chat.agentSkillsLocations` in VS Code `settings.json` when skills are published +- `.github/copilot-instructions.md`: Copilot instructions for this repository covering script workflow, conventions, and contributing guidelines + +### Fixed + +- `normalize-copilot-folders.ps1`: `Split-Path -LeafParent` → `Split-Path -Parent` (`-LeafParent` is not a valid parameter and would throw at runtime) +- `install-scheduled-task.ps1`: removed `-Quiet` from `publish-global.ps1` invocation (`publish-global.ps1` has no `-Quiet` parameter; would throw on scheduled runs) +- `init-repo.ps1`: `$Items.IndexOf($_)` → `[Array]::IndexOf($Items, $_)` (`System.Object[]` has no instance `IndexOf` method; affected console-menu fallback path) +- `init-repo.ps1`: fixed OGV column name `[*] Installed` → `Installed` (special characters caused WPF binding errors at runtime) +- `init-repo.ps1`: fixed `return if (...)` runtime error in `Install-File` — replaced with explicit `if/else` branches +- `publish-global.ps1`: corrected agents target path to `%APPDATA%\Code\User\prompts\` (was incorrectly set to `agents\`) + +### Changed + +- `publish-global.ps1`: updated inline comment from "CCA" to "VS Code Agent mode / Copilot CLI" +- `README.md`: corrected all `-Interval` references to `-Every`; fixed `-ProfileName` → `-ProfileRoot`/`-AllProfiles`; updated agents path to `%APPDATA%\Code\User\prompts\`; updated `init-repo.ps1` section to reflect agents category and smart detection; fixed custom `-AgentsTarget` example path +- `.github/copilot-instructions.md`: corrected agents path from `%APPDATA%\Code\User\agents\` to `%APPDATA%\Code\User\prompts\` + +### Fixed + +- `sync-awesome-copilot.ps1`: changed `$ErrorActionPreference` from `Inquire` to `Stop` — `Inquire` caused the script to hang waiting for interactive input when run as a scheduled task + +### Changed + +- `init-repo.ps1`: removed skills from per-repo initialisation; skills are globally available via `publish-global.ps1` (`~/.copilot/skills/`) and users should reference the source directly at [github/awesome-copilot](https://github.com/github/awesome-copilot) rather than committing point-in-time copies to repos + +## [1.1.0] - 2026-02-26 + +### Added + +- `publish-global.ps1` — publishes agents to the VS Code user agents folder (via junction so sync updates are reflected immediately) and skills to `~/.copilot/skills/`; supports `-DryRun`, `-SkipAgents`, `-SkipSkills`, `-AgentsTarget`, `-SkillsTarget` +- `init-repo.ps1` — interactive script to initialise a repo with per-repo resources (instructions, hooks, workflows); uses Out-GridView on Windows with a numbered console-menu fallback; supports `-RepoPath`, `-DryRun`, `-SkipInstructions`, `-SkipHooks`, `-SkipWorkflows` + +### Changed + +- Updated default sync categories to match current awesome-copilot repository structure: + - **Added**: `agents`, `workflows`, `hooks`, `skills` + - **Removed**: `chatmodes`, `prompts` (no longer exist in awesome-copilot) +- Added recursive directory traversal in `sync-awesome-copilot.ps1` to support subdirectory-based categories (`skills/`, `hooks/`, `plugins/`) +- Extended file extension filter to include `.sh` files (required for hooks to function — each hook ships shell scripts alongside its `hooks.json`) +- Updated `combine-and-publish-prompts.ps1` categories from `chatmodes/instructions/prompts` to `agents/instructions/workflows`; added deprecation notice at top (superseded by `publish-global.ps1` + `init-repo.ps1`); kept for backwards compatibility +- Updated `normalize-copilot-folders.ps1` to classify `*.agent.md` → `agents/` and ensure `agents/` directory is created on normalize runs +- Updated `install-scheduled-task.ps1`: default categories now `agents,instructions,workflows,hooks,skills`; `-IncludeCollections` replaced by `-IncludePlugins`; `-SkipCombine` replaced by `-SkipPublishGlobal`; scheduled actions now run `publish-global.ps1` after sync + +### Removed + +- `normalize-copilot-folders.ps1` — removed (legacy, superseded by junction-based agent publishing and `init-repo.ps1`) + +- `plugins/` and `cookbook/` are available but opt-in via `-IncludePlugins` due to their size +- Hooks are synced as complete packages (README.md + hooks.json + .sh scripts) preserving their directory structure +- **Design rationale**: agents and skills are global (agents available in all VS Code workspaces; skills loaded on-demand); instructions/hooks/workflows are per-repo opt-in via `init-repo.ps1` to avoid conflicts between contradicting instruction files + ## [1.0.0] - 2025-10-20 ### Added + - Initial release of VS Code Copilot Resource Sync Scripts - `sync-awesome-copilot.ps1` - Sync resources from GitHub awesome-copilot repository - `combine-and-publish-prompts.ps1` - Combine and publish resources to VS Code @@ -23,6 +124,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Support for multiple VS Code profiles ### Features + - Portable paths using environment variables ($HOME, $env:APPDATA) - Preserves user-created custom files in combined directory - Incremental updates (only downloads changed files) @@ -31,6 +133,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Detailed sync logs with timestamps ### Security + - No hardcoded credentials or personal information - Optional environment variable for GitHub token - All sensitive data handled via environment variables @@ -40,6 +143,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Future Plans ### Planned Features + - [ ] Configuration file support (YAML/JSON) - [ ] Backup and restore functionality - [ ] Conflict resolution UI for duplicate resources @@ -49,12 +153,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [ ] Cross-platform support (macOS, Linux) ### Known Issues + - Junction creation requires appropriate permissions on some systems - Scheduled task runs under user context (requires user to be logged in) --- **Note:** Version numbers follow [Semantic Versioning](https://semver.org/): + - MAJOR version for incompatible API changes - MINOR version for new functionality in a backwards compatible manner - PATCH version for backwards compatible bug fixes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c7fd7a4..44061eb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,16 +7,18 @@ Thank you for considering contributing to the VS Code Copilot Resource Sync Scri ### Reporting Bugs If you find a bug, please create an issue with: + - Clear description of the problem - Steps to reproduce - Expected vs actual behavior - PowerShell version (`$PSVersionTable.PSVersion`) - Windows version -- Relevant log files from `$HOME\.awesome-copilot\logs\` +- Relevant log files from `scripts\logs\` ### Suggesting Enhancements Feature requests are welcome! Please include: + - Clear use case description - Why this feature would be useful - Proposed implementation (if you have ideas) @@ -25,6 +27,7 @@ Feature requests are welcome! Please include: 1. **Fork the repository** 2. **Create a feature branch** from `main`: + ```powershell git checkout -b feature/your-feature-name ``` @@ -41,20 +44,25 @@ Feature requests are welcome! Please include: - Add inline comments for complex code 5. **Test your changes**: + ```powershell + # Full dry run (no files written) + .\configure.ps1 -DryRun + # Test individual scripts - .\sync-awesome-copilot.ps1 - .\combine-and-publish-prompts.ps1 - + .\scripts\sync-awesome-copilot.ps1 -Plan + .\scripts\publish-global.ps1 -DryRun + # Test scheduled task installation - .\install-scheduled-task.ps1 -Interval "1h" + .\configure.ps1 -InstallTask -Every "1h" Start-ScheduledTask -TaskName "AwesomeCopilotSync" - + # Verify logs - Get-Content "$HOME\.awesome-copilot\logs\sync-*.log" -Tail 20 + Get-ChildItem .\scripts\logs\sync-*.log | Sort-Object LastWriteTime -Descending | Select-Object -First 1 | Get-Content -Tail 20 ``` 6. **Commit with clear messages**: + ```powershell git commit -m "Add feature: description of what you added" ``` @@ -82,21 +90,21 @@ function Get-ResourceFiles { <# .SYNOPSIS Retrieves resource files from a directory. - + .PARAMETER Path The directory path to search. - + .PARAMETER Type The type of resource to filter (chatmode, instruction, prompt). #> param( [Parameter(Mandatory)] [string]$Path, - + [ValidateSet('chatmode', 'instruction', 'prompt')] [string]$Type ) - + try { $pattern = "*.$Type.md" Get-ChildItem -Path $Path -Filter $pattern -File @@ -145,6 +153,7 @@ Before submitting a PR, verify: ## Questions? Feel free to: + - Open an issue for discussion - Ask questions in pull request comments - Reach out to maintainers diff --git a/README.md b/README.md index 3bb1ced..72860b8 100644 --- a/README.md +++ b/README.md @@ -1,296 +1,220 @@ -# VS Code Copilot Resource Sync Scripts +# VS Code Copilot Resource Sync -A collection of PowerShell scripts to automatically sync, combine, and publish [GitHub Copilot](https://github.com/features/copilot) resources from the [awesome-copilot](https://github.com/github/awesome-copilot) community repository to your local VS Code profiles. +A collection of PowerShell scripts to sync and manage [GitHub Copilot](https://github.com/features/copilot) resources from the [awesome-copilot](https://github.com/github/awesome-copilot) community repository — cherry-picking exactly what each repo needs into `.github/`. ## 🎯 What This Does -These scripts automate the management of VS Code Copilot custom instructions, chat modes, prompts, and collections by: +1. **Syncs** the latest agents, instructions, hooks, workflows, and skills from [awesome-copilot](https://github.com/github/awesome-copilot) into a local cache (`~/.awesome-copilot/`) +2. **Initialises repos** — intelligently recommends and installs resources into a repo's `.github/` folder based on detected language/framework, with full install/update/remove lifecycle management -1. **Syncing** resources from the awesome-copilot GitHub repository -2. **Combining** multiple resource categories into a unified structure -3. **Publishing** to your VS Code profile(s) via symbolic links or file copies -4. **Normalizing** file organization to prevent duplicates -5. **Automating** the entire process via Windows Task Scheduler +### What goes where + +| Resource | Location | +| ---------------- | ----------------------- | +| **Agents** | `.github/agents/` | +| **Instructions** | `.github/instructions/` | +| **Hooks** | `.github/hooks//` | +| **Workflows** | `.github/workflows/` | +| **Skills** | `.github/skills/` | ## 📋 Prerequisites -- **Windows** with PowerShell 7+ ([Download here](https://github.com/PowerShell/PowerShell/releases)) -- **VS Code** with GitHub Copilot extension installed -- **Internet connection** for GitHub API access -- **Administrator privileges** (for creating scheduled tasks) +- **Windows** with PowerShell 7+ ([Download](https://github.com/PowerShell/PowerShell/releases)) +- **VS Code** with GitHub Copilot extension +- **`gh` (GitHub CLI) or `git`** — `gh` preferred ([Download](https://cli.github.com/)); handles auth automatically +- **Internet connection** for initial sync ## 🚀 Quick Start ### 1. Clone or Download ```powershell -# Clone this repository git clone -cd scripts +cd vscode-copilot-sync ``` -### 2. Run Initial Sync +### 2. Run the Configurator ```powershell -# Sync resources from GitHub -.\sync-awesome-copilot.ps1 - -# Combine resources into unified folder -.\combine-and-publish-prompts.ps1 -``` +# Sync from GitHub and optionally configure your current repo +.\configure.ps1 -### 3. Install Automated Sync (Optional) +# Sync + go straight to install pickers (no Y/N prompt) +.\configure.ps1 -Install -```powershell -# Install scheduled task (runs every 4 hours by default) -.\install-scheduled-task.ps1 +# Sync only (no repo setup) +.\configure.ps1 -SkipInit -# Or customize the interval -.\install-scheduled-task.ps1 -Interval "2h" # Every 2 hours -.\install-scheduled-task.ps1 -Interval "1d" # Once daily +# Preview everything without writing any files +.\configure.ps1 -DryRun ``` -## 📁 What Gets Created - -``` -$HOME\.awesome-copilot\ # Local cache -├── chatmodes\ # Chat mode definitions -├── instructions\ # Custom instructions -├── prompts\ # Prompt templates -├── collections\ # Resource collections -├── combined\ # Unified resources (all categories) -└── manifest.json # Sync state tracking - -%APPDATA%\Code\User\ # VS Code global config -└── prompts\ # Junction/symlink to combined folder - -%APPDATA%\Code\User\profiles\ # VS Code profiles -└── \ - ├── chatmodes\ # Linked/copied resources - ├── instructions\ - └── prompts\ -``` +### 3. Configure a Repo -## 📜 Scripts Overview +Run from inside any repo to add agents, instructions, hooks, workflows and skills to `.github/`: -### `sync-awesome-copilot.ps1` -Syncs resources from the awesome-copilot GitHub repository. +```powershell +cd C:\Projects\my-app +.\configure.ps1 -**Features:** -- Downloads latest resources via GitHub API -- SHA256 hash-based change detection -- Incremental updates (only downloads changed files) -- Manifest tracking for sync state -- Optional GITHUB_TOKEN support for higher rate limits +# Or target a repo without cd-ing first +.\configure.ps1 -SkipSync -RepoPath "C:\Projects\my-app" -**Usage:** -```powershell -.\sync-awesome-copilot.ps1 +# Or call the script directly +.\scripts\init-repo.ps1 +.\scripts\init-repo.ps1 -RepoPath "C:\Projects\my-app" ``` -**Environment Variables:** -- `GITHUB_TOKEN` (optional) - Personal access token for higher API rate limits +The picker auto-detects your language/framework and marks relevant items with ★. Items already installed show their status: ---- - -### `combine-and-publish-prompts.ps1` -Combines resources from all categories into a unified folder and publishes to VS Code. +| Symbol | Meaning | +| ------ | ------------------------------ | +| ★ | Recommended for this repo | +| `[*]` | Already installed | +| `[↑]` | Update available from upstream | +| `[~]` | Locally modified since install | +| `[!]` | Requires additional setup (MCP server, API key, etc.) | -**Features:** -- Merges chatmodes, instructions, and prompts into single directory -- Creates junction/symlink to VS Code prompts directory -- Automatic fallback to file copy if linking fails -- Preserves user-created custom files +### 4. Remove Installed Resources -**Usage:** ```powershell -.\combine-and-publish-prompts.ps1 - -# Publish to specific profile -.\combine-and-publish-prompts.ps1 -ProfileName "MyProfile" +# Interactive removal picker (only shows script-managed files — never user-created ones) +.\configure.ps1 -Uninstall -# Publish to global VS Code config only -.\combine-and-publish-prompts.ps1 -GlobalOnly +# Or directly +.\scripts\init-repo.ps1 -Uninstall ``` ---- +Locally modified files are flagged with `[~]` before removal so you don't accidentally discard work. + +## 📁 Local Cache Structure -### `publish-to-vscode-profile.ps1` -Publishes resources to VS Code profile(s) via symbolic links or copies. +``` +~/.awesome-copilot/ +├── .git/ # Git metadata (managed automatically) +├── agents/ # *.agent.md +├── instructions/ # *.instructions.md +├── workflows/ # *.md +├── hooks/ # / directories +├── skills/ # / directories +└── manifest.json # Sync state (hashes, timestamps, counts) +``` -**Features:** -- Creates symbolic links (junctions) for efficient syncing -- Automatic fallback to file copy -- Supports multiple profiles or global config +## 📜 Scripts -**Usage:** -```powershell -.\publish-to-vscode-profile.ps1 +### `configure.ps1` — Main entry point -# Publish to specific profile -.\publish-to-vscode-profile.ps1 -ProfileName "Work" +Chains sync → repo init in one command. -# Publish to global config -.\publish-to-vscode-profile.ps1 -GlobalOnly +```powershell +.\configure.ps1 # Full run +.\configure.ps1 -Install # Sync + go straight to pickers +.\configure.ps1 -SkipInit # Sync only +.\configure.ps1 -SkipSync # Repo init only +.\configure.ps1 -SkipSync -Uninstall # Remove resources +.\configure.ps1 -RepoPath "C:\Projects\my-app" # Target specific repo +.\configure.ps1 -DryRun # Preview all changes ``` --- -### `normalize-copilot-folders.ps1` -Cleans up misplaced or duplicated files in VS Code directories. +### `scripts/sync-awesome-copilot.ps1` — Sync cache -**Features:** -- Moves files to correct category folders based on suffix -- Removes duplicate files (keeps newest version) -- Handles renamed copies (file.1.md, chatmodes__file.md) +Clones (first run) or pulls (subsequent runs) `github/awesome-copilot` as a sparse git checkout. -**Usage:** -```powershell -.\normalize-copilot-folders.ps1 +- Only downloads the categories you need (`agents`, `instructions`, `workflows`, `hooks`, `skills` by default) +- SHA256 hash manifest tracks added/updated/removed counts across runs +- Auto-recovers from merge conflicts in the local cache +- Prefers `gh` CLI for auth; falls back to `git` -# Normalize specific profile -.\normalize-copilot-folders.ps1 -ProfileName "MyProfile" +```powershell +.\scripts\sync-awesome-copilot.ps1 # Sync all categories +.\scripts\sync-awesome-copilot.ps1 -Plan # Dry run +.\scripts\sync-awesome-copilot.ps1 -Categories "agents,instructions" +.\scripts\sync-awesome-copilot.ps1 -GitTool git # Force git ``` --- -### `install-scheduled-task.ps1` -Creates a Windows scheduled task for automatic syncing. +### `scripts/init-repo.ps1` — Configure a repo -**Features:** -- Runs sync and combine scripts on a schedule -- Default: every 4 hours -- Customizable interval -- Runs as current user (no SYSTEM account needed) +Interactively selects and installs Copilot resources into `.github/`. -**Usage:** -```powershell -# Install with default 4-hour interval -.\install-scheduled-task.ps1 +**Smart recommendations** — scans repo files for language/framework signals (`.cs`, `package.json`, `go.mod`, `Dockerfile`, `.github/workflows/*.yml`, etc.) and pre-marks relevant items with ★. Falls back to an intent questionnaire for empty repos. -# Custom intervals -.\install-scheduled-task.ps1 -Interval "2h" # Every 2 hours -.\install-scheduled-task.ps1 -Interval "30m" # Every 30 minutes -.\install-scheduled-task.ps1 -Interval "1d" # Once daily +**Full lifecycle** — install, update, and remove, all tracked in `.github/.copilot-subscriptions.json`. -# Check task status -Get-ScheduledTask -TaskName "AwesomeCopilotSync" +```powershell +.\scripts\init-repo.ps1 # Interactive +.\scripts\init-repo.ps1 -RepoPath "C:\Projects\my-app" +.\scripts\init-repo.ps1 -DryRun # Preview +.\scripts\init-repo.ps1 -Uninstall # Remove resources +.\scripts\init-repo.ps1 -SkipHooks -SkipWorkflows # Skip categories +.\scripts\init-repo.ps1 -Agents "devops-expert,se-security-reviewer" -Instructions "powershell" ``` -**Interval Format:** -- `30m` - Minutes -- `2h` - Hours -- `1d` - Days - --- -### `uninstall-scheduled-task.ps1` -Removes the scheduled task. +### `scripts/update-repo.ps1` — Apply upstream updates + +Reads `.github/.copilot-subscriptions.json` and applies any upstream changes from the local cache to installed resources. -**Usage:** ```powershell -.\uninstall-scheduled-task.ps1 +.\scripts\update-repo.ps1 # Interactive — prompts per item +.\scripts\update-repo.ps1 -DryRun # Show what would change +.\scripts\update-repo.ps1 -Force # Apply all without prompting +.\scripts\update-repo.ps1 -RepoPath "C:\Projects\my-app" ``` -## 🔧 Configuration - -### GitHub Rate Limits +> **Tip:** The `[↑]` column in `init-repo.ps1` shows update availability inline — run `update-repo.ps1` when you want to apply them all at once. -Without authentication, GitHub API allows 60 requests/hour. For heavy usage: - -1. Create a [Personal Access Token](https://github.com/settings/tokens) (no scopes needed for public repos) -2. Set environment variable: +## 🔧 Configuration -```powershell -# Temporary (current session) -$env:GITHUB_TOKEN = "ghp_your_token_here" +### Authentication -# Permanent (user environment) -[Environment]::SetEnvironmentVariable("GITHUB_TOKEN", "ghp_your_token_here", "User") -``` +`gh` CLI is preferred — inherits from `gh auth login`, no extra setup. If only `git` is available, the public `github/awesome-copilot` repo requires no credentials. For private forks, configure git credentials as usual. ### Custom Source Repository -By default, scripts sync from `github/awesome-copilot`. To use a different source: +Edit two variables near the top of `scripts/sync-awesome-copilot.ps1`: -Edit `sync-awesome-copilot.ps1` line 57: ```powershell -$Repo = "your-username/your-repo" +$RepoSlug = 'your-username/your-fork' +$RepoUrl = 'https://github.com/your-username/your-fork.git' ``` -## 🗂️ File Naming Conventions - -Resources follow naming patterns for automatic categorization: - -- `*.chatmode.md` - Chat mode definitions -- `*.instructions.md` - Custom instructions -- `*.prompt.md` - Prompt templates -- `*.collection.yml` - Resource collections - -Files without these suffixes in the combined folder are preserved (assumed to be user-created). - ## 🛠️ Troubleshooting -### Scripts Not Running -```powershell -# Check PowerShell version (must be 7+) -$PSVersionTable.PSVersion +### Execution policy error -# Set execution policy +```powershell Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser ``` -### Junction/Symlink Fails -Scripts automatically fall back to copying files. Check logs for details. - -### Scheduled Task Not Running -```powershell -# Check task status -Get-ScheduledTask -TaskName "AwesomeCopilotSync" | Get-ScheduledTaskInfo - -# View logs -Get-Content "$HOME\.awesome-copilot\logs\sync-*.log" -Tail 50 - -# Manually run task -Start-ScheduledTask -TaskName "AwesomeCopilotSync" -``` +### Sync fails with merge conflict -### Files Not Appearing in VS Code -1. Restart VS Code -2. Check VS Code profile is correct: `Ctrl+Shift+P` → "Preferences: Show Profiles" -3. Verify files exist in `%APPDATA%\Code\User\prompts\` +The script auto-recovers (`git reset --hard origin/HEAD`). If it persists, delete `~/.awesome-copilot` and re-run — it will re-clone fresh. ## 📊 Logs -Sync logs are stored in `$HOME\.awesome-copilot\logs\`: +Sync logs are written to `scripts/logs/sync-YYYYMMDD-HHMMSS.log`: + ```powershell -# View latest sync log -Get-Content "$HOME\.awesome-copilot\logs\sync-*.log" -Tail 20 +Get-ChildItem .\scripts\logs\sync-*.log | Sort-Object LastWriteTime -Descending | Select-Object -First 1 | Get-Content ``` -Log format: `sync-YYYYMMDD-HHMMSS.log` - ## 🤝 Contributing -Contributions welcome! Please: - -1. Fork the repository -2. Create a feature branch -3. Test your changes thoroughly -4. Submit a pull request +Contributions welcome! Fork, branch, test, PR. ## 📄 License -MIT License - See [LICENSE](LICENSE) file for details +MIT — see [LICENSE](LICENSE) ## 🙏 Acknowledgments -- [awesome-copilot](https://github.com/github/awesome-copilot) - Community resource repository -- [GitHub Copilot](https://github.com/features/copilot) - AI pair programmer - -## ⚠️ Disclaimer - -These scripts are community-maintained and not officially supported by GitHub or Microsoft. Use at your own risk. Always review synced content before using in production environments. +- [awesome-copilot](https://github.com/github/awesome-copilot) — Community resource repository +- [GitHub Copilot](https://github.com/features/copilot) — AI pair programmer --- diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..813b4f4 --- /dev/null +++ b/TODO.md @@ -0,0 +1,17 @@ +# TODO + +## Open Issues + +### DataVerse false ★ recommendations for repos with `.py` files + +- `.py` files trigger `python` keyword → `dataverse-python-*` scores 2 (contains `python` as a dash-segment) +- Reproduced with: `.\configure.ps1 -Install -RepoPath ..\Scripts\` +- Root cause: generic language keywords match vendor-prefixed item names +- **Proposed fix:** maintain a known vendor-prefix blocklist (`dataverse`, `salesforce`, `shopify`, `atlassian`, `pimcore`, `amplitude`, etc.). Items whose first segment is in the blocklist should not receive a name-match score from generic language keywords — only from an explicit vendor keyword detected in the repo. + +### OGV opens behind other windows + +- Windows UIPI prevents focus-stealing from background processes by design +- Three approaches tried and failed: `SetForegroundWindow`, `keybd_event(Alt)`, `WScript.Shell.AppActivate` from runspace +- Currently mitigated with a yellow hint message in the terminal +- Possible alternative: investigate WinForms-based topmost picker as a drop-in replacement for `Out-GridView` diff --git a/combine-and-publish-prompts.ps1 b/combine-and-publish-prompts.ps1 deleted file mode 100644 index 47ade10..0000000 --- a/combine-and-publish-prompts.ps1 +++ /dev/null @@ -1,201 +0,0 @@ -<# -Combine Copilot Resources Into Unified 'combined' Folder And Publish To Prompts - -CHANGE (2025-09-18): Default publish target is now the global prompts directory: - $env:APPDATA/Code/User/prompts -Previously the script auto-selected the latest profile under profiles/. That -behavior is still available via -UseLatestProfile. Supplying -AllProfiles keeps -publishing to every discovered profile prompts directory (unchanged). - -Steps Implemented: - 1. (Assumes sync already ran) Validate presence of source category folders under root (default $HOME/.awesome-copilot) - 2. Create / refresh a 'combined' folder containing all *.md files from: - chatmodes/, instructions/, prompts/ (collections optional via -IncludeCollections) - - Name collisions resolved by prefixing category (unless identical content) - - Preserves original filenames when unique - 3. Publish the combined folder: - a) By default to global prompts directory (profile-agnostic) - b) Or if -UseLatestProfile, to the latest profile's prompts - c) Or if -AllProfiles, to each profile's prompts - d) Attempt symbolic link (prompts -> combined) then junction then copy (or copy directly with -ForceCopy) - -Result: All items visible under the chosen prompts directory, enabling unified browsing. - -Usage Examples: - Dry run only: - pwsh -File .\scripts\combine-and-publish-prompts.ps1 -DryRun - Publish to global prompts (default): - pwsh -File .\scripts\combine-and-publish-prompts.ps1 - Publish to latest profile prompts (legacy behavior): - pwsh -File .\scripts\combine-and-publish-prompts.ps1 -UseLatestProfile - Publish to all profiles forcing copy: - pwsh -File .\scripts\combine-and-publish-prompts.ps1 -AllProfiles -ForceCopy - Rebuild combined only (no publish): - pwsh -File .\scripts\combine-and-publish-prompts.ps1 -NoPublish - -Flags: - -SourceRoot - -ProfilesBase - -ProfileRoot (explicit profile root; overrides -UseLatestProfile) - -AllProfiles - -UseLatestProfile (restore previous default targeting most recent profile) - -ForceCopy (skip link attempts) - -DryRun (show plan, do not modify) - -NoPublish (build combined set only) - -Prune (remove stale files from existing combined folder before rebuild) - -IncludeCollections (opt-in: also merge markdown files from collections/) - -License: Adapt freely. -#> -[CmdletBinding()] param( - [string]$SourceRoot = "$HOME/.awesome-copilot", - [string]$ProfilesBase = (Join-Path $env:APPDATA 'Code/User/profiles'), - [string]$ProfileRoot, - [switch]$UseLatestProfile, - [switch]$AllProfiles, - [switch]$ForceCopy, - [switch]$DryRun, - [switch]$NoPublish, - [switch]$Prune, - [switch]$IncludeCollections -) - -$ErrorActionPreference = 'Stop' - -function Log($m, [string]$level = 'INFO') { $ts = (Get-Date).ToString('s'); Write-Host "[$ts][$level] $m" -ForegroundColor $(if ($level -eq 'ERROR') { 'Red' } elseif ($level -eq 'WARN') { 'Yellow' } else { 'Cyan' }) } - -if (-not (Test-Path $SourceRoot)) { Log "Source root missing: $SourceRoot" 'ERROR'; exit 1 } -if (-not (Test-Path $ProfilesBase)) { Log "Profiles base missing: $ProfilesBase" 'WARN' } - -$categories = @('chatmodes', 'instructions', 'prompts') -if ($IncludeCollections) { $categories += 'collections' } -$missing = @() -foreach ($c in $categories) { if (-not (Test-Path (Join-Path $SourceRoot $c))) { $missing += $c } } -if ($missing) { Log "Missing category folders: $($missing -join ', ')" 'WARN' } - -# Determine publish targets. We now default to global prompts unless an explicit -# profile strategy is selected. -$globalPrompts = Join-Path (Join-Path $env:APPDATA 'Code/User') 'prompts' -$targetMode = 'Global' -$targets = @() - -if ($AllProfiles) { - $targetMode = 'AllProfiles' - $targets = Get-ChildItem $ProfilesBase -Directory | ForEach-Object { Join-Path $_.FullName 'prompts' } - if (-not $targets) { Log 'No profiles discovered for -AllProfiles.' 'ERROR'; exit 1 } - Log "Will publish to $($targets.Count) profile prompt directories" 'INFO' -} -elseif ($ProfileRoot) { - $targetMode = 'ExplicitProfile' - $targets = @(Join-Path $ProfileRoot 'prompts') - Log "Explicit profile root provided: $ProfileRoot" 'INFO' -} -elseif ($UseLatestProfile) { - $targetMode = 'LatestProfile' - $latest = if (Test-Path $ProfilesBase) { Get-ChildItem $ProfilesBase -Directory | Sort-Object LastWriteTime -Descending | Select-Object -First 1 } else { $null } - if (-not $latest) { Log 'No profiles found for -UseLatestProfile.' 'ERROR'; exit 1 } - $targets = @(Join-Path $latest.FullName 'prompts') - Log "Using latest profile: $($latest.FullName)" 'INFO' -} -else { - $targetMode = 'Global' - $targets = @($globalPrompts) - Log "Defaulting to global prompts directory: $globalPrompts" 'INFO' -} - -Log "Publish mode: $targetMode" 'INFO' - -$combinedRoot = Join-Path $SourceRoot 'combined' - -# Ensure combined directory exists -if (-not (Test-Path $combinedRoot)) { - if ($DryRun) { Log "[DryRun] Would create combined folder: $combinedRoot" 'INFO' } else { New-Item -ItemType Directory -Path $combinedRoot | Out-Null } -} - -# Map of dest relative name -> full path already added (for duplicate detection) -$index = @{} -$added = 0 -$skippedSame = 0 -$renamed = 0 - -foreach ($cat in $categories) { - $srcDir = Join-Path $SourceRoot $cat - if (-not (Test-Path $srcDir)) { continue } - $files = Get-ChildItem $srcDir -File -Filter '*.md' - foreach ($f in $files) { - $destName = $f.Name - $destPath = Join-Path $combinedRoot $destName - if (Test-Path $destPath) { - # Compare content hash; if identical skip, else replace with latest version - $existingHash = (Get-FileHash -Algorithm SHA256 $destPath).Hash - $newHash = (Get-FileHash -Algorithm SHA256 $f.FullName).Hash - if ($existingHash -eq $newHash) { - $skippedSame++ - continue - } - # Different content - latest version wins, replace the file - Log "Name collision: replacing $destName with latest from $cat" 'INFO' - $renamed++ - } - if ($DryRun) { - Log "[DryRun] Add $cat -> $destName" 'INFO' - } - else { - Copy-Item $f.FullName $destPath -Force - } - $added++ - } -} - -Log "Combined summary: added=$added identicalSkipped=$skippedSame renamed=$renamed" 'INFO' - -if ($NoPublish) { Log "NoPublish set; skipping linking/copy phase." 'INFO'; exit 0 } - -function Publish-ToPrompts($promptsDir) { - if (-not (Test-Path $promptsDir)) { - if ($DryRun) { Log "[DryRun] Would create prompts dir $promptsDir" } - else { New-Item -ItemType Directory -Path $promptsDir | Out-Null } - } - $canLink = $true - if (Test-Path $promptsDir) { - $item = Get-Item $promptsDir -Force - $isLink = ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) - if ($isLink) { - Log "prompts already linked: $promptsDir" 'INFO' - return - } - # If prompts directory exists as normal folder, we'll update it in place - # User-created files will be preserved, synced files will be updated - $nonHidden = Get-ChildItem $promptsDir -Force | Where-Object { -not $_.Attributes.ToString().Contains('Hidden') } - if ($nonHidden) { - Log "prompts exists as normal directory; will update synced files in place" 'INFO' - } - } - if ($ForceCopy -or -not $canLink) { - Log "Copying combined contents into prompts ($promptsDir)" 'INFO' - if (-not $DryRun) { Copy-Item (Join-Path $combinedRoot '*') $promptsDir -Recurse -Force } - return - } - if ($DryRun) { Log "[DryRun] Would replace prompts with link/junction to combined" 'INFO'; return } - try { - Remove-Item $promptsDir -Recurse -Force -ErrorAction Stop - New-Item -ItemType SymbolicLink -Path $promptsDir -Target $combinedRoot -Force | Out-Null - Log "Created symlink prompts -> combined" 'INFO' - } - catch { - Log "Symlink failed: $($_.Exception.Message) (attempt junction)" 'WARN' - try { - cmd /c mklink /J "$promptsDir" "$combinedRoot" | Out-Null - Log "Created junction prompts -> combined" 'INFO' - } - catch { - Log "Junction failed; copying fallback" 'WARN' - New-Item -ItemType Directory -Path $promptsDir -Force | Out-Null - Copy-Item (Join-Path $combinedRoot '*') $promptsDir -Recurse -Force - } - } -} - -foreach ($t in $targets) { Publish-ToPrompts -promptsDir $t } - -Log "Combine & publish complete." 'INFO' diff --git a/configure.ps1 b/configure.ps1 new file mode 100644 index 0000000..2ed5161 --- /dev/null +++ b/configure.ps1 @@ -0,0 +1,125 @@ +<# +Configure Copilot Resources + +Main entry point for all Copilot resource management operations. +Chains the scripts in the correct order: + + 1. sync-awesome-copilot.ps1 -- fetch latest from github/awesome-copilot + 2. init-repo.ps1 -- (prompted) per-repo .github/ setup + +Usage: + # Full interactive run: sync + prompt for init-repo + .\configure.ps1 + + # Sync + go straight to install pickers (skip the Y/N prompt) + .\configure.ps1 -Install + + # Sync only (skip repo setup) + .\configure.ps1 -SkipInit + + # Preview without writing any files + .\configure.ps1 -DryRun + + # Init a specific repo (not the current working directory) + .\configure.ps1 -SkipSync -RepoPath "C:\Projects\my-app" + + # Remove installed .github/ resources from the current repo + .\configure.ps1 -Uninstall +#> +[CmdletBinding()] param( + [switch]$SkipSync, + [switch]$SkipInit, + [switch]$Install, # Skip the Y/N prompt and go straight to init-repo pickers + [switch]$Uninstall, # Remove installed .github/ resources via init-repo -Uninstall + [string]$RepoPath = (Get-Location).Path, + [switch]$DryRun +) + +#region Initialisation +$ErrorActionPreference = 'Stop' +$ScriptDir = Join-Path $PSScriptRoot 'scripts' + +function Log($m, [string]$level = 'INFO') { + $ts = (Get-Date).ToString('s') + $color = switch ($level) { 'ERROR' { 'Red' } 'WARN' { 'Yellow' } 'SUCCESS' { 'Green' } default { 'Cyan' } } + Write-Host "[$ts][$level] $m" -ForegroundColor $color +} + +function Step($label) { + Write-Host "" + Write-Host " ── $label ──" -ForegroundColor Magenta + Write-Host "" +} + +# Show cache state +$manifest = "$HOME\.awesome-copilot\manifest.json" +if (Test-Path $manifest) { + try { + $m = Get-Content $manifest -Raw | ConvertFrom-Json + Log "Cache last synced: $($m.fetchedAt) Items: $($m.items.Count)" + } catch {} +} else { + Log "No local cache found — sync will download everything fresh." 'WARN' +} + +if ($Uninstall) { $SkipSync = $true } +if ($Install) { $SkipInit = $false } # ensure -Install always runs init-repo + +#endregion # Initialisation + +#region Step 1 — Sync +if (-not $SkipSync) { + Step "Sync from github/awesome-copilot" + $syncArgs = @{} + if ($DryRun) { $syncArgs['Plan'] = $true } + & (Join-Path $ScriptDir 'sync-awesome-copilot.ps1') @syncArgs + if ($LASTEXITCODE -and $LASTEXITCODE -ne 0) { Log "Sync failed (exit $LASTEXITCODE)" 'ERROR'; exit $LASTEXITCODE } +} + +#endregion # Step 1 + +#region Step 2 — Init repo +if (-not $SkipInit) { + # If a subscriptions manifest exists with entries, check for updates first + $subscriptionsFile = Join-Path $RepoPath '.github\.copilot-subscriptions.json' + $subCount = 0 + if (Test-Path $subscriptionsFile) { + $subCount = try { (@((Get-Content $subscriptionsFile -Raw | ConvertFrom-Json).subscriptions)).Count } catch { 0 } + if ($subCount -gt 0) { + Step "Check for updates to subscribed repo resources" + $updateArgs = @{} + if ($DryRun) { $updateArgs['DryRun'] = $true } + if ($RepoPath) { $updateArgs['RepoPath'] = $RepoPath } + & (Join-Path $ScriptDir 'update-repo.ps1') @updateArgs + } + } + + Step "Init repo" + if ($Uninstall -and $subCount -eq 0) { + Log "Nothing to uninstall — no subscriptions recorded for this repo." + } elseif ($Install -or $Uninstall) { + # -Install or -Uninstall: skip the Y/N prompt, run directly + $initArgs = @{} + if ($DryRun) { $initArgs['DryRun'] = $true } + if ($RepoPath) { $initArgs['RepoPath'] = $RepoPath } + if ($Uninstall) { $initArgs['Uninstall'] = $true } + & (Join-Path $ScriptDir 'init-repo.ps1') @initArgs + } else { + Write-Host " Add agents/instructions/hooks/workflows/skills to .github/ in the current repo?" -ForegroundColor Yellow + Write-Host " [Y] Yes [N] No (default): " -NoNewline -ForegroundColor Yellow + $answer = (Read-Host).Trim() + if ($answer -match '^[Yy]') { + $initArgs = @{} + if ($DryRun) { $initArgs['DryRun'] = $true } + if ($RepoPath) { $initArgs['RepoPath'] = $RepoPath } + & (Join-Path $ScriptDir 'init-repo.ps1') @initArgs + } else { + Log "init-repo skipped." + } + } +} + +#endregion # Step 2 + +Write-Host "" +Log "Done." 'SUCCESS' diff --git a/install-scheduled-task.ps1 b/install-scheduled-task.ps1 deleted file mode 100644 index d17fc65..0000000 --- a/install-scheduled-task.ps1 +++ /dev/null @@ -1,58 +0,0 @@ -[CmdletBinding()] param( - [string]$TaskName = 'AwesomeCopilotSync', - [string]$Every = '4h', - [string]$Dest = "$HOME/.awesome-copilot", - # Default categories now exclude 'collections' (can be re-enabled with -IncludeCollections) - [string]$Categories = 'chatmodes,instructions,prompts', - [switch]$IncludeCollections, - # Allow skipping the combine/publish step if user only wants raw sync - [switch]$SkipCombine, - [string]$PwshPath = (Get-Command pwsh -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source), - [string]$ScriptPath = (Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path) 'sync-awesome-copilot.ps1'), - [string]$CombineScriptPath = (Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path) 'combine-and-publish-prompts.ps1'), - [switch]$Force -) - -if (-not $PwshPath) { $PwshPath = (Get-Command powershell | Select-Object -ExpandProperty Source) } -if (-not (Test-Path $ScriptPath)) { throw "Sync script not found at $ScriptPath" } -if (-not $SkipCombine -and -not (Test-Path $CombineScriptPath)) { throw "Combine script not found at $CombineScriptPath (use -SkipCombine to suppress)" } - -function Parse-Interval($spec) { - if ($spec -match '^(\d+)([hm])$') { - $val = [int]$Matches[1]; $unit = $Matches[2] - switch ($unit) { - 'h' { return @{ Type = 'HOURLY'; Modifier = $val } } - 'm' { return @{ Type = 'MINUTE'; Modifier = $val } } - } - } - throw "Unsupported interval spec: $spec (use like 4h or 30m)" -} - -if ($IncludeCollections -and ($Categories -notmatch 'collections')) { - $Categories = ($Categories.TrimEnd(',') + ',collections') -} - -$int = Parse-Interval $Every - -# Primary sync action -$syncArgs = "-NoProfile -ExecutionPolicy Bypass -File `"$ScriptPath`" -Dest `"$Dest`" -Categories `"$Categories`" -Quiet" -$actions = @() -$actions += New-ScheduledTaskAction -Execute $PwshPath -Argument $syncArgs - -if (-not $SkipCombine) { - # Combine script runs after sync; no need for -DryRun. Include collections only if requested. - $combineArgs = "-NoProfile -ExecutionPolicy Bypass -File `"$CombineScriptPath`" -SourceRoot `"$Dest`"" + $(if ($IncludeCollections) { ' -IncludeCollections' } else { '' }) - $actions += New-ScheduledTaskAction -Execute $PwshPath -Argument $combineArgs -} -$Trigger = if ($int.Type -eq 'HOURLY') { New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) -RepetitionInterval ([TimeSpan]::FromHours($int.Modifier)) -RepetitionDuration ([TimeSpan]::FromDays(3650)) } else { New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) -RepetitionInterval ([TimeSpan]::FromMinutes($int.Modifier)) -RepetitionDuration ([TimeSpan]::FromDays(3650)) } -$Settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Minutes 20) - -if (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue) { - if (-not $Force) { Write-Error "Task $TaskName already exists. Use -Force to overwrite."; exit 1 } - Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -} - -Register-ScheduledTask -TaskName $TaskName -Action $actions -Trigger $Trigger -Settings $Settings -Description "Sync (and combine) Awesome Copilot resources" | Out-Null - -$post = if ($SkipCombine) { 'sync only' } else { 'sync + combine/publish' } -Write-Host "Scheduled task '$TaskName' created ($post). First run in ~1 minute, then every $Every." -ForegroundColor Green diff --git a/normalize-copilot-folders.ps1 b/normalize-copilot-folders.ps1 deleted file mode 100644 index 195154b..0000000 --- a/normalize-copilot-folders.ps1 +++ /dev/null @@ -1,127 +0,0 @@ -<# -Normalize Copilot Resource Folder Placement - -Purpose: - Ensures all markdown resources are stored in the directory that matches their - semantic suffix: *.chatmode.md -> chatmodes/, *.instructions.md -> instructions/, - *.prompt.md -> prompts/, *.collection.md|*.collections.md -> collections/. - -Why: - A mismatch (e.g. a .chatmode.md inside prompts/) can lead to inconsistent - discovery or confusion about canonical location. This tool re-homes files. - -Features: - - Works on a single detected (most recent) profile or all profiles (-AllProfiles) - - Dry-run mode (default) shows planned moves - - Skips already-correct placements - - Avoids overwriting: if destination filename exists, appends numeric suffix - - Reports summary counts - -Usage Examples: - Dry-run across all profiles: - pwsh -File .\scripts\normalize-copilot-folders.ps1 -AllProfiles - - Execute (no dry-run) for a specific profile root: - pwsh -File .\scripts\normalize-copilot-folders.ps1 -ProfileRoot "C:\Users\me\AppData\Roaming\Code\User\profiles\abc123" -NoDryRun - - Execute across all profiles: - pwsh -File .\scripts\normalize-copilot-folders.ps1 -AllProfiles -NoDryRun - -Limitations: - - Only processes markdown files (*.md) - - Does not attempt content validation; classification is by filename suffix - -License: MIT-like; adapt as needed. -#> -[CmdletBinding()] param( - [string]$ProfilesBase = (Join-Path $env:APPDATA 'Code/User/profiles'), - [string]$ProfileRoot, - [switch]$AllProfiles, - [switch]$NoDryRun -) - -$ErrorActionPreference = 'Stop' - -function Log($m, [string]$level = 'INFO') { - $ts = (Get-Date).ToString('s'); Write-Host "[$ts][$level] $m" -ForegroundColor $(if ($level -eq 'ERROR') { 'Red' } elseif ($level -eq 'WARN') { 'Yellow' } else { 'Cyan' }) -} - -if (-not (Test-Path $ProfilesBase)) { Log "Profiles base not found: $ProfilesBase" 'ERROR'; exit 1 } - -$targets = @() -if ($AllProfiles) { - $targets = Get-ChildItem $ProfilesBase -Directory | ForEach-Object { $_.FullName } - if (-not $targets) { Log 'No profiles discovered.' 'ERROR'; exit 1 } - Log "Discovered $($targets.Count) profiles" 'INFO' -} -else { - if (-not $ProfileRoot) { - $latest = Get-ChildItem $ProfilesBase -Directory | Sort-Object LastWriteTime -Descending | Select-Object -First 1 - if (-not $latest) { Log 'No profiles found.' 'ERROR'; exit 1 } - $ProfileRoot = $latest.FullName - Log "Detected profile: $ProfileRoot" 'INFO' - } - $targets = @($ProfileRoot) -} - -$movePlanned = 0 -$moveDone = 0 -$skipped = 0 -$correct = 0 - -function Classify([string]$fileName) { - switch -regex ($fileName) { - '\\.chatmode\.md$' { return 'chatmodes' } - '\\.instructions\.md$' { return 'instructions' } - '\\.prompt\.md$' { return 'prompts' } - '\\.(collection|collections)\.md$' { return 'collections' } - default { return $null } - } -} - -foreach ($p in $targets) { - Log "Scanning profile: $p" 'INFO' - $expected = 'chatmodes', 'instructions', 'prompts', 'collections' - foreach ($dir in $expected) { $full = Join-Path $p $dir; if (-not (Test-Path $full)) { New-Item -ItemType Directory -Path $full | Out-Null } } - - # Consider: any .md file in profile tree at depth 0..2 - $candidates = Get-ChildItem $p -Recurse -File -Include *.md | Where-Object { $_.DirectoryName -notmatch '\\\.git' } - foreach ($f in $candidates) { - $targetFolder = Classify -fileName $f.Name - if (-not $targetFolder) { continue } - $currentFolder = Split-Path $f.FullName -LeafParent - $currentBase = Split-Path $currentFolder -Leaf - if ($currentBase -eq $targetFolder) { $correct++; continue } - - $destDir = Join-Path $p $targetFolder - $destPath = Join-Path $destDir $f.Name - if (Test-Path $destPath) { - # File exists at destination - compare content and replace if different - $existingHash = (Get-FileHash -Algorithm SHA256 $destPath).Hash - $newHash = (Get-FileHash -Algorithm SHA256 $f.FullName).Hash - if ($existingHash -eq $newHash) { - Log "Identical file already exists at destination, skipping: $($f.Name)" 'INFO' - $correct++ - continue - } - Log "Replacing existing file with latest version: $($f.Name)" 'INFO' - if ($NoDryRun) { - Remove-Item $destPath -Force - } - } - Log "Relocate: $($f.FullName) -> $destPath" 'INFO' - $movePlanned++ - if ($NoDryRun) { - try { - Move-Item -LiteralPath $f.FullName -Destination $destPath -Force - $moveDone++ - } - catch { - Log "Failed move: $($_.Exception.Message)" 'ERROR' - } - } - } -} - -if (-not $NoDryRun) { Log "Dry run complete (no files moved). Use -NoDryRun to apply." 'INFO' } -Log "Summary: planned=$movePlanned moved=$moveDone correct=$correct" 'INFO' diff --git a/publish-to-vscode-profile.ps1 b/publish-to-vscode-profile.ps1 deleted file mode 100644 index c84af4e..0000000 --- a/publish-to-vscode-profile.ps1 +++ /dev/null @@ -1,145 +0,0 @@ -[CmdletBinding()] param( - [string]$SourceRoot = "$HOME/.awesome-copilot", - [string]$ProfileRoot, - [switch]$AllProfiles, - [string]$WorkspaceRoot, - [switch]$ForceCopyFallback, - [switch]$Prune, - [switch]$VerboseLinks -) - -$ErrorActionPreference = 'Stop' - -function Log($msg, [string]$level = 'INFO') { - $ts = (Get-Date).ToString('s'); Write-Host "[$ts][$level] $msg" -ForegroundColor $(if ($level -eq 'ERROR') { 'Red' } elseif ($level -eq 'WARN') { 'Yellow' } else { 'Cyan' }) -} - -if (-not (Test-Path $SourceRoot)) { Log "Source root not found: $SourceRoot" 'ERROR'; exit 1 } - -# Collect target profiles -$profilesBase = Join-Path $env:APPDATA 'Code/User/profiles' -if (-not (Test-Path $profilesBase)) { Log "Profiles base not found: $profilesBase (open VS Code & ensure a profile exists)" 'ERROR'; exit 1 } -$TargetProfiles = @() -if ($AllProfiles) { - $TargetProfiles = Get-ChildItem $profilesBase -Directory | ForEach-Object { $_.FullName } - if (-not $TargetProfiles) { Log "No profile directories found under $profilesBase" 'ERROR'; exit 1 } - Log "Publishing to ALL profiles ($($TargetProfiles.Count))" 'INFO' -} -else { - if (-not $ProfileRoot) { - $profileDir = Get-ChildItem $profilesBase -Directory | Sort-Object LastWriteTime -Descending | Select-Object -First 1 - if (-not $profileDir) { Log "No profile directories found under $profilesBase" 'ERROR'; exit 1 } - $ProfileRoot = $profileDir.FullName - Log "Detected profile: $ProfileRoot" - } - $TargetProfiles = @($ProfileRoot) -} - -function Get-MappingsForProfile($pRoot) { - @( - @{ Name = 'chatmodes'; Src = (Join-Path $SourceRoot 'chatmodes'); Dst = (Join-Path $pRoot 'chatmodes') } - @{ Name = 'prompts'; Src = (Join-Path $SourceRoot 'prompts'); Dst = (Join-Path $pRoot 'prompts') } - @{ Name = 'instructions'; Src = (Join-Path $SourceRoot 'instructions'); Dst = (Join-Path $pRoot 'instructions') } - ) -} - -# Helper to link or copy -function Ensure-LinkOrCopy($src, $dst, [string]$label) { - if (-not (Test-Path $src)) { Log "Skipping $label (missing source: $src)" 'WARN'; return } - if (Test-Path $dst) { - $item = Get-Item $dst -Force - $isLink = ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) - if ($isLink -and -not $ForceCopyFallback) { - Log "$label already linked: $dst"; return - } - elseif (-not $isLink -and -not $ForceCopyFallback) { - Log "$label exists as normal directory; will update only synced files" 'INFO' - # Only remove files that exist in source (preserve user-created files) - if (Test-Path $src) { - $srcFiles = Get-ChildItem $src -Filter '*.md' -File -Recurse | ForEach-Object { $_.Name } - Get-ChildItem $dst -Filter '*.md' -File -Recurse -ErrorAction SilentlyContinue | ForEach-Object { - if ($srcFiles -contains $_.Name) { - Log "Removing synced file to update: $($_.Name)" 'INFO' - Remove-Item $_.FullName -Force - } - } - } - # Now copy the source files (will update/add synced files, leave others alone) - if (-not (Test-Path $dst)) { New-Item -ItemType Directory -Path $dst | Out-Null } - Copy-Item (Join-Path $src '*') $dst -Recurse -Force - return - } - else { - Log "Removing existing $label at $dst to replace" 'WARN' - Remove-Item $dst -Recurse -Force - } - } - if ($ForceCopyFallback) { - Log "Copying $label -> $dst" - if (-not (Test-Path $dst)) { New-Item -ItemType Directory -Path $dst | Out-Null } - Copy-Item (Join-Path $src '*') $dst -Recurse -Force - } - else { - try { - Log "Creating symlink $dst -> $src" - New-Item -ItemType SymbolicLink -Path $dst -Target $src -Force | Out-Null - } - catch { - Log "Symlink failed ($label): $_ (attempt junction)" 'WARN' - try { - cmd /c mklink /J "$dst" "$src" | Out-Null - Log "Created junction for ${label}: ${dst}" - } - catch { - Log "Junction failed for ${label}: $_ (fallback copy)" 'WARN' - if (-not (Test-Path $dst)) { New-Item -ItemType Directory -Path $dst | Out-Null } - Copy-Item (Join-Path $src '*') $dst -Recurse -Force - } - } - } - if ($VerboseLinks) { - $preview = Get-ChildItem $dst -File | Select-Object -First 3 | ForEach-Object { $_.Name } | Out-String - $cleanPreview = ($preview -split "`r?`n" | Where-Object { $_ }) -join ', ' - Log "${label} preview: $cleanPreview" - } -} - -foreach ($p in $TargetProfiles) { - Log "Processing profile: $p" 'INFO' - $Mappings = Get-MappingsForProfile -pRoot $p - foreach ($m in $Mappings) { Ensure-LinkOrCopy -src $m.Src -dst $m.Dst -label $m.Name } - - if ($Prune) { - foreach ($m in $Mappings) { - if (-not (Test-Path $m.Dst)) { continue } - $srcRel = Get-ChildItem $m.Src -Recurse -File | ForEach-Object { $_.FullName.Substring($m.Src.Length).TrimStart('\\') } - $srcSet = [System.Collections.Generic.HashSet[string]]::new([string[]]$srcRel) - Get-ChildItem $m.Dst -Recurse -File | ForEach-Object { - $rel = $_.FullName.Substring($m.Dst.Length).TrimStart('\\') - if (-not $srcSet.Contains($rel)) { - Log "Prune stale: $($m.Name)/$rel" 'WARN' - Remove-Item $_.FullName -Force - } - } - } - } -} - -# Mirror into workspace if requested -if ($WorkspaceRoot) { - $gh = Join-Path $WorkspaceRoot '.github' - if (-not (Test-Path $gh)) { New-Item -ItemType Directory -Path $gh | Out-Null } - foreach ($m in $Mappings) { - if (-not (Test-Path $m.Src)) { continue } - $dst = Join-Path $gh $m.Name - if (-not (Test-Path $dst)) { - Log "Seeding workspace $($m.Name) -> $dst" - Copy-Item $m.Src $dst -Recurse - } - else { - Log "Workspace already has $($m.Name); skipping" 'INFO' - } - } -} - -Log "Publish complete. Reload VS Code if new items aren't visible." 'INFO' diff --git a/scripts/init-repo.ps1 b/scripts/init-repo.ps1 new file mode 100644 index 0000000..91c4ed6 --- /dev/null +++ b/scripts/init-repo.ps1 @@ -0,0 +1,1005 @@ +<# +Initialize a Repository with Copilot Resources + +Interactively selects and installs non-global Copilot resources from the local +awesome-copilot cache into a target repository's .github/ folder. + +Resources installed here are project-specific (opt-in) rather than global: + + Agents --> .github/agents/*.agent.md + Instructions --> .github/instructions/*.instructions.md + Hooks --> .github/hooks// (full directory) + Workflows --> .github/workflows/*.md + Skills --> .github/skills// (full directory) + +Usage: + # Interactive - run from within the target repo + .\init-repo.ps1 + + # Specify a repo path explicitly + .\init-repo.ps1 -RepoPath "C:\Projects\my-app" + + # Skip specific categories + .\init-repo.ps1 -SkipAgents -SkipHooks + .\init-repo.ps1 -SkipHooks -SkipWorkflows -SkipSkills + + # Non-interactive: specify items by name (comma-separated) + .\init-repo.ps1 -Instructions "angular,dotnet-framework" -Hooks "session-logger" + .\init-repo.ps1 -Agents "devops-expert,se-security-reviewer" + .\init-repo.ps1 -Skills "my-custom-skill" + # Dry run - show what would be installed + .\init-repo.ps1 -DryRun + + # Remove installed resources (uninstall mode) + .\init-repo.ps1 -Uninstall + .\init-repo.ps1 -Uninstall -SkipInstructions -SkipHooks + +Notes: + - Existing files are only overwritten if the source is newer/different. + - .github/ is created if it doesn't exist. + - Skills are installed to .github/skills/ for version control with the project, + alongside all other per-repo resources. + - A subscription manifest (.github/.copilot-subscriptions.json) is written on + each run. Use update-repo.ps1 to check for and apply upstream changes. + - The selection UI uses Out-GridView where available (Windows GUI, filterable, + multi-select). Falls back to a numbered console menu automatically. + - Auto-detects language/framework from repo file signals and pre-marks + recommended (config-free) resources with ★ in the picker. + Items requiring additional setup (MCP server, API key, etc.) are marked [!]. + - For new/empty repos, prompts for intent one question at a time. +#> +[CmdletBinding()] param( + [string]$RepoPath = (Get-Location).Path, + [string]$SourceRoot = "$HOME/.awesome-copilot", + [string]$Instructions = '', # Comma-separated names to pre-select (non-interactive) + [string]$Agents = '', + [string]$Hooks = '', + [string]$Workflows = '', + [string]$Skills = '', # Comma-separated names to pre-select (non-interactive) + [switch]$SkipInstructions, + [switch]$SkipAgents, + [switch]$SkipHooks, + [switch]$SkipWorkflows, + [switch]$SkipSkills, + [switch]$DryRun, + [switch]$Uninstall # show a picker of installed items to remove instead of installing +) + +#region Initialisation +$ErrorActionPreference = 'Stop' + +function Log($m, [string]$level = 'INFO') { + $ts = (Get-Date).ToString('s') + $color = switch ($level) { 'ERROR' { 'Red' } 'WARN' { 'Yellow' } 'SUCCESS' { 'Green' } default { 'Cyan' } } + Write-Host "[$ts][$level] $m" -ForegroundColor $color +} + +function Show-OGV { + # Wrapper around Out-GridView that flashes the taskbar button and prints + # a console hint — the most reliable way to alert the user since Windows + # prevents focus-stealing from background processes by design. + param([Parameter(ValueFromPipeline)][object[]]$InputObject, [string]$Title, [string]$SearchKey, [switch]$PassThru) + begin { $all = [System.Collections.Generic.List[object]]::new() } + process { foreach ($i in $InputObject) { $all.Add($i) } } + end { + Write-Host " ► Selection window opening — check your taskbar if it appears behind other apps." -ForegroundColor Yellow + + if ($PassThru) { $result = $all | Out-GridView -Title $Title -PassThru } + else { $all | Out-GridView -Title $Title } + + if ($PassThru) { return $result } + } +} + +#endregion # Initialisation + +#region Stack detection +function Detect-RepoStack { + param([string]$RepoPath) + + $recs = [System.Collections.Generic.List[string]]::new() + $files = Get-ChildItem $RepoPath -Recurse -File -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -notmatch '\\(\.git|node_modules|\.venv|bin|obj)\\' } | + Where-Object { $_.Name -notmatch '\.(instructions|agent|prompt|chatmode)\.md$' } # exclude installed awesome-copilot files + + $exts = $files | ForEach-Object { $_.Extension.ToLower() } | Sort-Object -Unique + $names = $files | ForEach-Object { $_.Name } | Sort-Object -Unique + $hasDotnet = $exts -contains '.cs' -or ($names | Where-Object { $_ -match '\.(csproj|sln)$' }) + $hasPy = $exts -contains '.py' -or ($names -contains 'requirements.txt') -or ($names -contains 'pyproject.toml') + $hasTs = $exts -contains '.ts' -or ($names -contains 'tsconfig.json') + $hasGo = $exts -contains '.go' -or ($names -contains 'go.mod') + $hasRs = $exts -contains '.rs' -or ($names -contains 'Cargo.toml') + $hasJava = $exts -contains '.java' -or ($names -contains 'pom.xml') -or ($names | Where-Object { $_ -eq 'build.gradle' }) + $hasKt = $exts -contains '.kt' + $hasTf = $exts -contains '.tf' + $hasBicep = $exts -contains '.bicep' + $hasPs1 = $exts -contains '.ps1' + $hasJs = $exts -contains '.js' -or $exts -contains '.jsx' -or ($names -contains 'package.json') + + # Language/platform keywords — used for content-based scoring across all categories + if ($hasDotnet) { $recs.Add('csharp'); $recs.Add('dotnet') } + if ($hasPy) { $recs.Add('python') } + if ($hasTs) { $recs.Add('typescript') } + if ($hasJs) { $recs.Add('javascript') } + if ($hasGo) { $recs.Add('go') } + if ($hasRs) { $recs.Add('rust') } + if ($hasJava) { $recs.Add('java') } + if ($hasKt) { $recs.Add('kotlin') } + if ($hasTf) { $recs.Add('terraform') } + if ($hasBicep) { $recs.Add('bicep'); $recs.Add('azure') } + if ($hasPs1) { $recs.Add('powershell') } + + # Docker / containers + $hasDocker = ($names -contains 'Dockerfile') -or ($names | Where-Object { $_ -match '^docker-compose\.yml$' }) + if ($hasDocker) { $recs.Add('docker'); $recs.Add('container') } + + # GitHub Actions + $ghWorkflows = $files | Where-Object { $_.FullName -match '\\\.github\\workflows\\' -and $_.Extension -eq '.yml' } + if ($ghWorkflows) { $recs.Add('github-actions') } + + # Playwright + $hasPlaywright = $files | Where-Object { $_.Name -match '^playwright\.config\.' } + if ($hasPlaywright) { $recs.Add('playwright') } + + # package.json framework detection + $pkgJson = $files | Where-Object { $_.Name -eq 'package.json' } | Select-Object -First 1 + if ($pkgJson) { + try { + $pkg = Get-Content $pkgJson.FullName -Raw | ConvertFrom-Json -ErrorAction Stop + $allDeps = @() + if ($pkg.dependencies) { $allDeps += $pkg.dependencies.PSObject.Properties.Name } + if ($pkg.devDependencies) { $allDeps += $pkg.devDependencies.PSObject.Properties.Name } + if ($allDeps -contains 'react') { $recs.Add('react') } + if ($allDeps -contains 'next') { $recs.Add('nextjs') } + if ($allDeps | Where-Object { $_ -match '^@angular/' }) { $recs.Add('angular') } + if ($allDeps -contains 'vue') { $recs.Add('vue') } + if ($allDeps -contains 'svelte') { $recs.Add('svelte') } + if ($allDeps | Where-Object { $_ -match '^@nestjs/' }) { $recs.Add('nestjs') } + } catch {} + } + + # Always recommend security-focused resources for every repo + $recs.Add('owasp') + + return @($recs | Sort-Object -Unique) +} + +#endregion # Stack detection + +#region Intent prompt +function Prompt-RepoIntent { + $recs = [System.Collections.Generic.List[string]]::new() + + Write-Host "" + Write-Host " Q1: What is the primary language or stack?" -ForegroundColor Yellow + Write-Host " 1. C# / .NET" + Write-Host " 2. Python" + Write-Host " 3. TypeScript / JavaScript" + Write-Host " 4. Go" + Write-Host " 5. Java / Kotlin" + Write-Host " 6. Rust" + Write-Host " 7. PowerShell" + Write-Host " 8. Terraform / Bicep (Infrastructure)" + Write-Host " 9. Other" + Write-Host " Enter number: " -NoNewline -ForegroundColor Yellow + $q1 = (Read-Host).Trim() + switch ($q1) { + '1' { $recs.Add('csharp'); $recs.Add('dotnet') } + '2' { $recs.Add('python') } + '3' { $recs.Add('typescript'); $recs.Add('javascript') } + '4' { $recs.Add('go') } + '5' { $recs.Add('java'); $recs.Add('kotlin') } + '6' { $recs.Add('rust') } + '7' { $recs.Add('powershell') } + '8' { $recs.Add('terraform'); $recs.Add('bicep'); $recs.Add('azure') } + } + + Write-Host "" + Write-Host " Q2: What type of project is this?" -ForegroundColor Yellow + Write-Host " 1. Web API / REST service" + Write-Host " 2. Web application (frontend)" + Write-Host " 3. CLI tool" + Write-Host " 4. Library / SDK" + Write-Host " 5. Data pipeline / ML" + Write-Host " 6. Infrastructure / DevOps" + Write-Host " 7. Documentation / Content" + Write-Host " Enter number: " -NoNewline -ForegroundColor Yellow + $q2 = (Read-Host).Trim() + switch ($q2) { + '1' { $recs.Add('api'); $recs.Add('rest') } + '2' { $recs.Add('frontend') } + '3' { $recs.Add('cli') } + '5' { $recs.Add('data') } + '6' { $recs.Add('docker'); $recs.Add('terraform'); $recs.Add('github-actions') } + '7' { $recs.Add('markdown'); $recs.Add('documentation') } + } + + Write-Host "" + Write-Host " Q3: Any specific concerns? (comma-separated, e.g. 1,3)" -ForegroundColor Yellow + Write-Host " 1. Security / OWASP" + Write-Host " 2. Accessibility (a11y)" + Write-Host " 3. Testing / Playwright" + Write-Host " 4. Performance" + Write-Host " 5. Docker / Containers" + Write-Host " 6. CI/CD / GitHub Actions" + Write-Host " 7. None" + Write-Host " Enter numbers: " -NoNewline -ForegroundColor Yellow + $q3 = (Read-Host).Trim() + if ($q3 -and $q3 -ne '7') { + foreach ($part in $q3.Split(',')) { + switch ($part.Trim()) { + '1' { $recs.Add('security'); $recs.Add('owasp') } + '2' { $recs.Add('accessibility'); $recs.Add('a11y') } + '3' { $recs.Add('playwright'); $recs.Add('testing') } + '4' { $recs.Add('performance') } + '5' { $recs.Add('docker'); $recs.Add('container'); $recs.Add('kubernetes') } + '6' { $recs.Add('github-actions') } + } + } + } + + $recs.Add('owasp') + return @($recs | Sort-Object -Unique) +} + +#endregion # Intent prompt + +#region Path validation and stack detection +if (-not (Test-Path $RepoPath)) { + Log "Repo path not found: $RepoPath" 'ERROR'; exit 1 +} +$RepoPath = Resolve-Path $RepoPath | Select-Object -ExpandProperty Path + +if (-not (Test-Path $SourceRoot)) { + Log "Cache not found: $SourceRoot -- run sync-awesome-copilot.ps1 first" 'ERROR'; exit 1 +} + +$GithubDir = Join-Path $RepoPath '.github' +Log "Target repo : $RepoPath" +Log "Copilot cache: $SourceRoot" + +# Auto-detect stack or prompt for intent +$script:Recommendations = @() +if (-not ($SkipInstructions -and $SkipHooks -and $SkipWorkflows -and $SkipAgents -and $SkipSkills)) { + $repoFileCount = (Get-ChildItem $RepoPath -Recurse -File -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -notmatch '\\(\.git|node_modules|\.venv|bin|obj)\\' } | + Measure-Object).Count + + if ($repoFileCount -gt 3) { + Log "Scanning repo for language/framework signals..." + $script:Recommendations = Detect-RepoStack -RepoPath $RepoPath + if ($script:Recommendations.Count -gt 0) { + Log "Detected: $($script:Recommendations -join ', ')" + } else { + Log "No signals detected." 'WARN' + $script:Recommendations = Prompt-RepoIntent + } + } else { + Log "New or empty repo detected — prompting for intent." + $script:Recommendations = Prompt-RepoIntent + } +} + +#endregion # Path validation and stack detection + +#region Helpers +function Select-Items { + param( + [string]$Category, + [object[]]$Items, # objects with Name, Description, AlreadyInstalled + [string]$PreSelected = '', + [string[]]$Tags = @() # tech keywords used for content-based relevance scoring + ) + + if ($Items.Count -eq 0) { + Log "No $Category found in cache." 'WARN' + return @() + } + + # Non-interactive mode: pre-selection provided + if ($PreSelected) { + $names = $PreSelected.Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ } + $selected = $Items | Where-Object { $names -contains $_.Name } + if (-not $selected) { Log "No matching $Category items for: $PreSelected" 'WARN' } + return @($selected) + } + + # Score each item against the detected tech keywords; recommended = score >= 2 AND no setup required. + $Items = $Items | ForEach-Object { + $score = Measure-ItemRelevance -ItemName $_.Name -FilePath $_.FullPath -Tags $Tags + $isRec = ($score -ge 2) -and (-not $_.RequiresSetup) + $_ | Add-Member -NotePropertyName 'IsRecommended' -NotePropertyValue $isRec -PassThru -Force | + Add-Member -NotePropertyName 'Score' -NotePropertyValue $score -PassThru -Force + } | Sort-Object @{ E={ if ($_.IsRecommended) { 0 } else { 1 } } }, @{ E={ if ($_.AlreadyInstalled) { 0 } else { 1 } } }, Name + + Write-Host "" + Write-Host " === $Category ===" -ForegroundColor Yellow + Write-Host " [*]=Installed [↑]=Update available [~]=Locally modified ★=Recommended [!]=Setup required" -ForegroundColor DarkGray + + # Try Out-GridView (Windows GUI - filterable, multi-select) + $ogvAvailable = $false + try { Get-Command Out-GridView -ErrorAction Stop | Out-Null; $ogvAvailable = $true } catch {} + + if ($ogvAvailable) { + $none = [pscustomobject]@{ Rec=''; Status=''; Name='-- none / skip --'; Description='Select this (or nothing) to install nothing' } + $display = @($none) + @($Items | Select-Object ` + @{ N='Rec'; E={ if ($_.IsRecommended) { '★' } else { '' } } }, + @{ N='Status'; E={ + $s = '' + if ($_.AlreadyInstalled) { $s += '[*]' } + if ($_.UpdateAvailable) { $s += '[↑]' } + if ($_.LocallyModified) { $s += '[~]' } + if ($_.RequiresSetup) { $s += '[!]' } + $s + }}, + @{ N='Name'; E={ $_.Name } }, + @{ N='Description'; E={ $_.Description } }) + + $picked = $display | Show-OGV -Title "Select $Category ★=Recommended (config-free) [*]=Installed [↑]=Update [~]=Modified [!]=Setup required" -SearchKey "Select $Category" -PassThru + if (-not $picked) { return @() } + $pickedNames = @($picked | Where-Object { $_.Name -ne '-- none / skip --' } | ForEach-Object { $_.Name }) + return @($Items | Where-Object { $pickedNames -contains $_.Name }) + } + + # Fallback: numbered console menu + Write-Host "" + for ($i = 0; $i -lt $Items.Count; $i++) { + $item = $Items[$i] + $status = '' + if ($item.AlreadyInstalled) { $status += '[*]' } + if ($item.UpdateAvailable) { $status += '[↑]' } + if ($item.LocallyModified) { $status += '[~]' } + $rec = if ($item.IsRecommended) { '[★]' } elseif ($item.RequiresSetup) { '[!]' } else { ' ' } + $color = if ($item.UpdateAvailable) { 'Cyan' } elseif ($item.AlreadyInstalled) { 'DarkCyan' } elseif ($item.IsRecommended) { 'Yellow' } elseif ($item.RequiresSetup) { 'DarkYellow' } else { 'White' } + Write-Host (" {0,3}. {1} {2,-6} {3}" -f ($i+1), $rec, $status, $item.Name) -ForegroundColor $color + if ($item.Description) { + Write-Host (" {0}" -f $item.Description) -ForegroundColor DarkGray + } + } + Write-Host "" + Write-Host " Enter numbers to install (e.g. 1,3,5 or 1-3 or 'all' or blank to skip): " -NoNewline -ForegroundColor Yellow + $input = Read-Host + + if (-not $input -or $input.Trim() -eq '') { return @() } + if ($input.Trim() -eq 'all') { return $Items } + + $indices = @() + foreach ($part in $input.Split(',')) { + $part = $part.Trim() + if ($part -match '^(\d+)-(\d+)$') { + $indices += ([int]$Matches[1])..[int]$Matches[2] + } elseif ($part -match '^\d+$') { + $indices += [int]$part + } + } + return @($Items | Where-Object { $indices -contains ([Array]::IndexOf($Items, $_) + 1) }) +} + +function Select-ToRemove { + <# + .SYNOPSIS + Shows a picker of script-managed installed items and returns those the user wants to remove. + Only items recorded in .copilot-subscriptions.json are offered — never user-created files. + Locally-modified items are flagged with [~] as a removal warning. + #> + param( + [string]$Category, + [object[]]$Items # full catalogue with ManagedByScript, LocallyModified flags + ) + + # Only offer items this script installed — never user-created files + $removable = @($Items | Where-Object { $_.AlreadyInstalled -and $_.ManagedByScript }) + if ($removable.Count -eq 0) { + Log "No script-managed $Category to remove." 'INFO' + return @() + } + + $ogvAvailable = $false + try { Get-Command Out-GridView -ErrorAction Stop | Out-Null; $ogvAvailable = $true } catch {} + + if ($ogvAvailable) { + $none = [pscustomobject]@{ Modified=''; Name='-- none / skip --'; Description='Select this (or nothing) to remove nothing' } + $display = @($none) + @($removable | Select-Object ` + @{ N='Modified'; E={ if ($_.LocallyModified) { '[~] MODIFIED' } else { '' } } }, + @{ N='Name'; E={ $_.Name } }, + @{ N='Description'; E={ $_.Description } }) + $picked = $display | Show-OGV -Title "Select $Category to REMOVE [~]=Locally modified (removal is permanent)" -SearchKey "Select $Category to REMOVE" -PassThru + if (-not $picked) { return @() } + $pickedNames = @($picked | Where-Object { $_.Name -ne '-- none / skip --' } | ForEach-Object { $_.Name }) + return @($removable | Where-Object { $pickedNames -contains $_.Name }) + } + + Write-Host "" + Write-Host " === Remove $Category ===" -ForegroundColor Red + Write-Host " [~] = locally modified — removal is permanent" -ForegroundColor DarkGray + for ($i = 0; $i -lt $removable.Count; $i++) { + $mod = if ($removable[$i].LocallyModified) { '[~]' } else { ' ' } + $color = if ($removable[$i].LocallyModified) { 'Yellow' } else { 'DarkCyan' } + Write-Host (" {0,3}. {1} {2}" -f ($i+1), $mod, $removable[$i].Name) -ForegroundColor $color + if ($removable[$i].Description) { + Write-Host (" {0}" -f $removable[$i].Description) -ForegroundColor DarkGray + } + } + Write-Host "" + Write-Host " Enter numbers to REMOVE (e.g. 1,3 or blank to skip): " -NoNewline -ForegroundColor Red + $input = Read-Host + if (-not $input -or $input.Trim() -eq '') { return @() } + + $indices = @() + foreach ($part in $input.Split(',')) { + $part = $part.Trim() + if ($part -match '^(\d+)-(\d+)$') { + $indices += ([int]$Matches[1])..[int]$Matches[2] + } elseif ($part -match '^\d+$') { + $indices += [int]$part + } + } + return @($removable | Where-Object { $indices -contains ([Array]::IndexOf($removable, $_) + 1) }) +} + +function Remove-File { + param([string]$FilePath) + if ($DryRun) { return 'would-remove' } + if (Test-Path $FilePath) { Remove-Item $FilePath -Force; return 'removed' } + return 'not-found' +} + +function Remove-Directory { + param([string]$DirPath) + if ($DryRun) { return 'would-remove' } + if (Test-Path $DirPath) { Remove-Item $DirPath -Recurse -Force; return 'removed' } + return 'not-found' +} + +function Install-File { + param([string]$Src, [string]$DestDir) + if (-not $DryRun -and -not (Test-Path $DestDir)) { + New-Item -ItemType Directory -Path $DestDir -Force | Out-Null + } + $dest = Join-Path $DestDir (Split-Path $Src -Leaf) + $srcHash = (Get-FileHash $Src -Algorithm SHA256).Hash + $dstHash = if (Test-Path $dest) { (Get-FileHash $dest -Algorithm SHA256).Hash } else { $null } + if ($srcHash -eq $dstHash) { return 'unchanged' } + if ($DryRun) { return 'would-copy' } + Copy-Item $Src $dest -Force + if ($dstHash) { return 'updated' } else { return 'added' } +} + +function Install-Directory { + param([string]$SrcDir, [string]$DestParent) + $name = Split-Path $SrcDir -Leaf + $destDir = Join-Path $DestParent $name + if (-not $DryRun -and -not (Test-Path $destDir)) { + New-Item -ItemType Directory -Path $destDir -Force | Out-Null + } + $added = 0; $updated = 0; $unchanged = 0 + Get-ChildItem $SrcDir -File -Recurse | ForEach-Object { + $rel = $_.FullName.Substring($SrcDir.Length).TrimStart('\','/') + $dest = Join-Path $destDir $rel + $destDir2 = Split-Path $dest -Parent + if (-not $DryRun -and -not (Test-Path $destDir2)) { + New-Item -ItemType Directory -Path $destDir2 -Force | Out-Null + } + $srcHash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash + $dstHash = if (Test-Path $dest) { (Get-FileHash $dest -Algorithm SHA256).Hash } else { $null } + if ($srcHash -ne $dstHash) { + if (-not $DryRun) { Copy-Item $_.FullName $dest -Force } + if ($dstHash) { $updated++ } else { $added++ } + } else { $unchanged++ } + } + return [pscustomobject]@{ Added = $added; Updated = $updated; Unchanged = $unchanged } +} + +function Get-Description([string]$FilePath) { + try { + $lines = Get-Content $FilePath -TotalCount 20 -ErrorAction SilentlyContinue + # YAML frontmatter description field + $inFrontmatter = $false + foreach ($line in $lines) { + if ($line -eq '---') { $inFrontmatter = -not $inFrontmatter; continue } + if ($inFrontmatter -and $line -match '^description:\s*(.+)') { return $Matches[1].Trim('"''') } + } + # First non-heading markdown line + foreach ($line in $lines) { + if ($line -match '^#{1,3}\s+(.+)') { return $Matches[1] } + } + } catch {} + return '' +} + +function Get-DirHash([string]$DirPath) { + $hashes = Get-ChildItem $DirPath -Recurse -File | + Sort-Object FullName | + ForEach-Object { (Get-FileHash $_.FullName -Algorithm SHA256).Hash } + $combined = $hashes -join '|' + $bytes = [System.Text.Encoding]::UTF8.GetBytes($combined) + $stream = [System.IO.MemoryStream]::new($bytes) + return (Get-FileHash -InputStream $stream -Algorithm SHA256).Hash +} + +function Measure-ItemRelevance { + <# + .SYNOPSIS + Scores an item's relevance against detected tech keywords. + .DESCRIPTION + Checks both the item's name (stronger signal, weight 2) and the first 30 lines of + its content or README.md (for directories) against each keyword using word-boundary + matching. Returns a total score; 0 means no relevance detected. + #> + param( + [string] $ItemName, + [string] $FilePath, + [string[]] $Tags + ) + + if (-not $Tags -or $Tags.Count -eq 0) { return 0 } + + $score = 0 + $nameLower = $ItemName.ToLower() + + foreach ($tag in $Tags) { + $t = $tag.ToLower() + # Name match: keyword must be a complete dash-segment in the item name. + # e.g. 'python' matches 'python-mcp-server' AND 'dataverse-python-sdk' + # but NOT 'pythonic-tips' (partial segment match). + $segments = $nameLower -split '-' + if ($segments -contains $t) { $score += 2 } + } + + # For directories, score against README.md / SKILL.md content + $contentFile = $FilePath + if ($FilePath -and (Test-Path $FilePath -PathType Container)) { + $readme = Join-Path $FilePath 'README.md' + if (-not (Test-Path $readme)) { $readme = Join-Path $FilePath 'SKILL.md' } + $contentFile = if (Test-Path $readme) { $readme } else { $null } + } + + if ($contentFile -and (Test-Path $contentFile -PathType Leaf)) { + try { + $text = ((Get-Content $contentFile -TotalCount 30 -ErrorAction SilentlyContinue) -join ' ').ToLower() + foreach ($tag in $Tags) { + $pattern = "(?i)\b$([regex]::Escape($tag.ToLower()))\b" + if ($text -match $pattern) { $score++ } + } + } catch {} + } + + return $score +} + +function Update-Subscriptions { + param([string]$ManifestPath, [object[]]$NewEntries) + if (-not $NewEntries -or $NewEntries.Count -eq 0) { return } + + $subs = $null + if (Test-Path $ManifestPath) { + try { $subs = Get-Content $ManifestPath -Raw | ConvertFrom-Json } catch {} + } + if (-not $subs) { + $subs = [pscustomobject]@{ version = 1; updatedAt = ''; subscriptions = @() } + } + + $existing = [System.Collections.Generic.List[object]]::new() + if ($subs.subscriptions) { $existing.AddRange([object[]]$subs.subscriptions) } + + foreach ($entry in $NewEntries) { + $idx = -1 + for ($i = 0; $i -lt $existing.Count; $i++) { + if ($existing[$i].name -eq $entry.name -and $existing[$i].category -eq $entry.category) { + $idx = $i; break + } + } + if ($idx -ge 0) { $existing[$idx] = $entry } else { $existing.Add($entry) } + } + + $subs | Add-Member -NotePropertyName 'updatedAt' -NotePropertyValue (Get-Date).ToString('o') -Force + $subs | Add-Member -NotePropertyName 'subscriptions' -NotePropertyValue $existing.ToArray() -Force + + if (-not $DryRun) { + $dir = Split-Path $ManifestPath -Parent + if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } + $subs | ConvertTo-Json -Depth 5 | Set-Content $ManifestPath -Encoding UTF8 + Log "Updated subscriptions: $ManifestPath" + } else { + Log "[DryRun] Would update subscriptions: $ManifestPath ($($NewEntries.Count) new/updated entries)" + } +} + +#endregion # Helpers + +#region Catalogue builders +$totalInstalled = 0 + +function Test-RequiresSetup([string]$FilePath) { + # Returns $true if the file/dir requires external setup (MCP server, API key, etc.) + # These items are never starred as recommendations. + $contentFile = $FilePath + if (Test-Path $FilePath -PathType Container) { + $readme = Join-Path $FilePath 'README.md' + if (-not (Test-Path $readme)) { $readme = Join-Path $FilePath 'SKILL.md' } + $contentFile = if (Test-Path $readme) { $readme } else { $null } + } + if (-not $contentFile -or -not (Test-Path $contentFile)) { return $false } + try { + $text = (Get-Content $contentFile -Raw -ErrorAction SilentlyContinue) + return $text -match 'mcp-servers:|_API_KEY\b|COPILOT_MCP_' + } catch { return $false } +} + +function Build-FlatCatalogue([string]$CatDir, [string]$DestDir, [string]$Pattern, [string]$Category) { + if (-not (Test-Path $CatDir)) { return @() } + Get-ChildItem $CatDir -File | Where-Object { $_.Name -match $Pattern } | ForEach-Object { + $destFile = Join-Path $DestDir $_.Name + $itemName = [System.IO.Path]::GetFileNameWithoutExtension($_.Name) -replace '\.(instructions|agent|prompt|chatmode)$','' + $subKey = "$Category|$itemName" + $subEntry = $script:SubIndex[$subKey] + $installed = Test-Path $destFile + + $updateAvailable = $false + $locallyModified = $false + $managedByScript = $null -ne $subEntry + + if ($installed -and $subEntry) { + $srcHash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash + $currentHash = (Get-FileHash $destFile -Algorithm SHA256).Hash + $locallyModified = $currentHash -ne $subEntry.hashAtInstall + $updateAvailable = $srcHash -ne $currentHash + } + + [pscustomobject]@{ + Name = $itemName + FileName = $_.Name + FullPath = $_.FullName + Description = Get-Description $_.FullName + RequiresSetup = Test-RequiresSetup $_.FullName + AlreadyInstalled = $installed + ManagedByScript = $managedByScript + UpdateAvailable = $updateAvailable + LocallyModified = $locallyModified + } + } | Sort-Object Name +} + +function Build-DirCatalogue([string]$CatDir, [string]$DestDir, [string]$Category) { + if (-not (Test-Path $CatDir)) { return @() } + Get-ChildItem $CatDir -Directory | ForEach-Object { + $destSubdir = Join-Path $DestDir $_.Name + $readmePath = Join-Path $_.FullName 'README.md' + if (-not (Test-Path $readmePath)) { $readmePath = Join-Path $_.FullName 'SKILL.md' } + $subKey = "$Category|$($_.Name)" + $subEntry = $script:SubIndex[$subKey] + $installed = Test-Path $destSubdir + + $updateAvailable = $false + $locallyModified = $false + $managedByScript = $null -ne $subEntry + + if ($installed -and $subEntry) { + $srcHash = Get-DirHash $_.FullName + $currentHash = Get-DirHash $destSubdir + $locallyModified = $currentHash -ne $subEntry.hashAtInstall + $updateAvailable = $srcHash -ne $currentHash + } + + [pscustomobject]@{ + Name = $_.Name + FullPath = $_.FullName + Description = if (Test-Path $readmePath) { Get-Description $readmePath } else { '' } + RequiresSetup = Test-RequiresSetup $_.FullName + AlreadyInstalled = $installed + ManagedByScript = $managedByScript + UpdateAvailable = $updateAvailable + LocallyModified = $locallyModified + } + } | Sort-Object Name +} + +#endregion # Catalogue builders + +#region Subscription manifest +$script:SubscriptionEntries = [System.Collections.Generic.List[object]]::new() +$SubscriptionManifestPath = Join-Path $GithubDir '.copilot-subscriptions.json' + +# Load existing manifest into a lookup: "category|name" -> subscription entry +$script:SubIndex = @{} +if (Test-Path $SubscriptionManifestPath) { + try { + $existingSubs = Get-Content $SubscriptionManifestPath -Raw | ConvertFrom-Json + if ($existingSubs.subscriptions) { + foreach ($s in $existingSubs.subscriptions) { + $script:SubIndex["$($s.category)|$($s.name)"] = $s + } + } + } catch { Log "Could not parse subscriptions manifest: $_" 'WARN' } +} + +function Remove-SubscriptionEntries { + param([string]$ManifestPath, [string[]]$Keys) # Keys = "category|name" + if (-not (Test-Path $ManifestPath)) { return } + if ($DryRun) { Log "[DryRun] Would remove $($Keys.Count) subscription(s) from manifest"; return } + try { + $subs = Get-Content $ManifestPath -Raw | ConvertFrom-Json + if (-not $subs.subscriptions) { return } + $kept = @($subs.subscriptions | Where-Object { $Keys -notcontains "$($_.category)|$($_.name)" }) + $subs | Add-Member -NotePropertyName 'subscriptions' -NotePropertyValue $kept -Force + $subs | Add-Member -NotePropertyName 'updatedAt' -NotePropertyValue (Get-Date).ToString('o') -Force + $subs | ConvertTo-Json -Depth 5 | Set-Content $ManifestPath -Encoding UTF8 + } catch { Log "Could not update subscriptions manifest: $_" 'WARN' } +} + +#endregion # Subscription manifest + +#region Pre-load all catalogues +$script:AllCatalogues = [System.Collections.Generic.List[object]]::new() + +# When uninstalling, skip building catalogues for categories with nothing installed +function Should-LoadCatalogue([string]$Category, [string]$DestDir, [switch]$IsSkipped) { + if ($IsSkipped) { return $false } + if ($Uninstall) { return (Test-Path $DestDir) -and ((Get-ChildItem $DestDir -ErrorAction SilentlyContinue | Measure-Object).Count -gt 0) } + return $true +} + +Log "Loading resource catalogues..." +$catAgents = if (Should-LoadCatalogue 'agents' (Join-Path $GithubDir 'agents') -IsSkipped:$SkipAgents) { Build-FlatCatalogue (Join-Path $SourceRoot 'agents') (Join-Path $GithubDir 'agents') '\.agent\.md$' 'agents' } else { @() } +$catInstructions = if (Should-LoadCatalogue 'instructions' (Join-Path $GithubDir 'instructions') -IsSkipped:$SkipInstructions) { Build-FlatCatalogue (Join-Path $SourceRoot 'instructions') (Join-Path $GithubDir 'instructions') '\.instructions\.md$' 'instructions' } else { @() } +$catHooks = if (Should-LoadCatalogue 'hooks' (Join-Path $GithubDir 'hooks') -IsSkipped:$SkipHooks) { Build-DirCatalogue (Join-Path $SourceRoot 'hooks') (Join-Path $GithubDir 'hooks') 'hooks' } else { @() } +$catWorkflows = if (Should-LoadCatalogue 'workflows' (Join-Path $GithubDir 'workflows') -IsSkipped:$SkipWorkflows) { Build-FlatCatalogue (Join-Path $SourceRoot 'workflows') (Join-Path $GithubDir 'workflows') '\.md$' 'workflows' } else { @() } +$catSkills = if (Should-LoadCatalogue 'skills' (Join-Path $GithubDir 'skills') -IsSkipped:$SkipSkills) { Build-DirCatalogue (Join-Path $SourceRoot 'skills') (Join-Path $GithubDir 'skills') 'skills' } else { @() } +Log "Catalogues loaded. Opening pickers..." + +#endregion # Pre-load all catalogues + +#region Agents + +if (-not $SkipAgents) { + $destDir = Join-Path $GithubDir 'agents' + $catalogue = $catAgents + $script:AllCatalogues.Add([pscustomobject]@{ Category='agents'; Type='file'; Items=$catalogue; DestDir=$destDir }) + + if ($Uninstall) { + $toRemove = Select-ToRemove -Category 'Agents' -Items $catalogue + foreach ($item in $toRemove) { + $result = Remove-File -FilePath (Join-Path $destDir $item.FileName) + $verb = if ($result -eq 'would-remove') { '~ DryRun remove' } else { '✗ Removed' } + Log "$verb agent: $($item.FileName)" + } + if ($toRemove.Count -gt 0) { + Remove-SubscriptionEntries -ManifestPath $SubscriptionManifestPath -Keys @($toRemove | ForEach-Object { "agents|$($_.Name)" }) + } + } else { + # Agents: also score on 'security' since security-reviewer agents are universally useful + $agentTags = @($script:Recommendations) + 'security' | Sort-Object -Unique + $selected = Select-Items -Category 'Agents' -Items $catalogue -PreSelected $Agents -Tags $agentTags + + foreach ($item in $selected) { + $result = Install-File -Src $item.FullPath -DestDir $destDir + $verb = switch ($result) { 'added' { '✓ Added' } 'updated' { '↑ Updated' } 'unchanged' { '= Unchanged' } default { '~ DryRun' } } + Log "$verb agent: $($item.FileName)" + if ($result -in 'added','updated','would-copy') { $totalInstalled++ } + $script:SubscriptionEntries.Add([pscustomobject]@{ + name = $item.Name + category = 'agents' + type = 'file' + fileName = $item.FileName + sourceRelPath = "agents/$($item.FileName)" + hashAtInstall = (Get-FileHash $item.FullPath -Algorithm SHA256).Hash + installedAt = (Get-Date).ToString('o') + }) + } + } +} + +#endregion # Agents + +#region Instructions +if (-not $SkipInstructions) { + $destDir = Join-Path $GithubDir 'instructions' + $catalogue = $catInstructions + $script:AllCatalogues.Add([pscustomobject]@{ Category='instructions'; Type='file'; Items=$catalogue; DestDir=$destDir }) + + if ($Uninstall) { + $toRemove = Select-ToRemove -Category 'Instructions' -Items $catalogue + foreach ($item in $toRemove) { + $result = Remove-File -FilePath (Join-Path $destDir $item.FileName) + $verb = if ($result -eq 'would-remove') { '~ DryRun remove' } else { '✗ Removed' } + Log "$verb instructions: $($item.FileName)" + } + if ($toRemove.Count -gt 0) { + Remove-SubscriptionEntries -ManifestPath $SubscriptionManifestPath -Keys @($toRemove | ForEach-Object { "instructions|$($_.Name)" }) + } + } else { + $selected = Select-Items -Category 'Instructions' -Items $catalogue -PreSelected $Instructions -Tags $script:Recommendations + + foreach ($item in $selected) { + $result = Install-File -Src $item.FullPath -DestDir $destDir + $verb = switch ($result) { 'added' { '✓ Added' } 'updated' { '↑ Updated' } 'unchanged' { '= Unchanged' } default { '~ DryRun' } } + Log "$verb instructions: $($item.FileName)" + if ($result -in 'added','updated','would-copy') { $totalInstalled++ } + $script:SubscriptionEntries.Add([pscustomobject]@{ + name = $item.Name + category = 'instructions' + type = 'file' + fileName = $item.FileName + sourceRelPath = "instructions/$($item.FileName)" + hashAtInstall = (Get-FileHash $item.FullPath -Algorithm SHA256).Hash + installedAt = (Get-Date).ToString('o') + }) + } + } +} + +#endregion # Instructions + +#region Hooks +if (-not $SkipHooks) { + $destDir = Join-Path $GithubDir 'hooks' + $catalogue = $catHooks + $script:AllCatalogues.Add([pscustomobject]@{ Category='hooks'; Type='directory'; Items=$catalogue; DestDir=$destDir }) + + if ($Uninstall) { + $toRemove = Select-ToRemove -Category 'Hooks' -Items $catalogue + foreach ($item in $toRemove) { + $result = Remove-Directory -DirPath (Join-Path $destDir $item.Name) + $verb = if ($result -eq 'would-remove') { '~ DryRun remove' } else { '✗ Removed' } + Log "$verb hook: $($item.Name)" + } + if ($toRemove.Count -gt 0) { + Remove-SubscriptionEntries -ManifestPath $SubscriptionManifestPath -Keys @($toRemove | ForEach-Object { "hooks|$($_.Name)" }) + } + } else { + $selected = Select-Items -Category 'Hooks' -Items $catalogue -PreSelected $Hooks -Tags $script:Recommendations + + foreach ($item in $selected) { + $r = Install-Directory -SrcDir $item.FullPath -DestParent $destDir + $verb = if ($DryRun) { '~ DryRun' } else { '✓ Installed' } + Log "$verb hook: $($item.Name) (added=$($r.Added) updated=$($r.Updated) unchanged=$($r.Unchanged))" + if (-not $DryRun) { $totalInstalled++ } + $script:SubscriptionEntries.Add([pscustomobject]@{ + name = $item.Name + category = 'hooks' + type = 'directory' + dirName = $item.Name + sourceRelPath = "hooks/$($item.Name)" + hashAtInstall = Get-DirHash $item.FullPath + installedAt = (Get-Date).ToString('o') + }) + } + } +} + +#endregion # Hooks + +#region Workflows +if (-not $SkipWorkflows) { + $destDir = Join-Path $GithubDir 'workflows' + $catalogue = $catWorkflows + $script:AllCatalogues.Add([pscustomobject]@{ Category='workflows'; Type='file'; Items=$catalogue; DestDir=$destDir }) + + if ($Uninstall) { + $toRemove = Select-ToRemove -Category 'Agentic Workflows' -Items $catalogue + foreach ($item in $toRemove) { + $result = Remove-File -FilePath (Join-Path $destDir $item.FileName) + $verb = if ($result -eq 'would-remove') { '~ DryRun remove' } else { '✗ Removed' } + Log "$verb workflow: $($item.FileName)" + } + if ($toRemove.Count -gt 0) { + Remove-SubscriptionEntries -ManifestPath $SubscriptionManifestPath -Keys @($toRemove | ForEach-Object { "workflows|$($_.Name)" }) + } + } else { + $selected = Select-Items -Category 'Agentic Workflows' -Items $catalogue -PreSelected $Workflows -Tags $script:Recommendations + + foreach ($item in $selected) { + $result = Install-File -Src $item.FullPath -DestDir $destDir + $verb = switch ($result) { 'added' { '✓ Added' } 'updated' { '↑ Updated' } 'unchanged' { '= Unchanged' } default { '~ DryRun' } } + Log "$verb workflow: $($item.FileName)" + if ($result -in 'added','updated','would-copy') { $totalInstalled++ } + $script:SubscriptionEntries.Add([pscustomobject]@{ + name = $item.Name + category = 'workflows' + type = 'file' + fileName = $item.FileName + sourceRelPath = "workflows/$($item.FileName)" + hashAtInstall = (Get-FileHash $item.FullPath -Algorithm SHA256).Hash + installedAt = (Get-Date).ToString('o') + }) + } + } +} + +#endregion # Workflows + +#region Skills +if (-not $SkipSkills) { + $destDir = Join-Path $GithubDir 'skills' + $catalogue = $catSkills + $script:AllCatalogues.Add([pscustomobject]@{ Category='skills'; Type='directory'; Items=$catalogue; DestDir=$destDir }) + + if ($Uninstall) { + $toRemove = Select-ToRemove -Category 'Skills' -Items $catalogue + foreach ($item in $toRemove) { + $result = Remove-Directory -DirPath (Join-Path $destDir $item.Name) + $verb = if ($result -eq 'would-remove') { '~ DryRun remove' } else { '✗ Removed' } + Log "$verb skill: $($item.Name)" + } + if ($toRemove.Count -gt 0) { + Remove-SubscriptionEntries -ManifestPath $SubscriptionManifestPath -Keys @($toRemove | ForEach-Object { "skills|$($_.Name)" }) + } + } else { + $selected = Select-Items -Category 'Skills' -Items $catalogue -PreSelected $Skills -Tags $script:Recommendations + + foreach ($item in $selected) { + $r = Install-Directory -SrcDir $item.FullPath -DestParent $destDir + $verb = if ($DryRun) { '~ DryRun' } else { '✓ Installed' } + Log "$verb skill: $($item.Name) (added=$($r.Added) updated=$($r.Updated) unchanged=$($r.Unchanged))" + if (-not $DryRun) { $totalInstalled++ } + $script:SubscriptionEntries.Add([pscustomobject]@{ + name = $item.Name + category = 'skills' + type = 'directory' + dirName = $item.Name + sourceRelPath = "skills/$($item.Name)" + hashAtInstall = Get-DirHash $item.FullPath + installedAt = (Get-Date).ToString('o') + }) + } + } +} + +#endregion # Skills + +#region Summary + +# Auto-adopt items that are already installed in .github/ but not yet in the manifest +# (e.g. installed by a previous version of this script before tracking was added). +# Uses the current installed file hash as hashAtInstall — they'll show [↑] if upstream has moved on. +if (-not $Uninstall -and -not $DryRun) { + foreach ($cat in $script:AllCatalogues) { + $untracked = @($cat.Items | Where-Object { $_.AlreadyInstalled -and -not $_.ManagedByScript }) + foreach ($item in $untracked) { + $installedPath = if ($cat.Type -eq 'file') { + Join-Path $cat.DestDir $item.FileName + } else { + Join-Path $cat.DestDir $item.Name + } + $adoptedHash = if ($cat.Type -eq 'file') { + (Get-FileHash $installedPath -Algorithm SHA256).Hash + } else { + Get-DirHash $installedPath + } + $entry = [pscustomobject]@{ + name = $item.Name + category = $cat.Category + type = $cat.Type + installedAt = (Get-Item $installedPath).LastWriteTime.ToString('o') + hashAtInstall = $adoptedHash + adopted = $true # flag so we know this was auto-adopted not explicitly chosen + } + if ($cat.Type -eq 'file') { + $entry | Add-Member -NotePropertyName 'fileName' -NotePropertyValue $item.FileName -Force + $entry | Add-Member -NotePropertyName 'sourceRelPath' -NotePropertyValue "$($cat.Category)/$($item.FileName)" -Force + } else { + $entry | Add-Member -NotePropertyName 'dirName' -NotePropertyValue $item.Name -Force + $entry | Add-Member -NotePropertyName 'sourceRelPath' -NotePropertyValue "$($cat.Category)/$($item.Name)" -Force + } + $script:SubscriptionEntries.Add($entry) + Log "Adopted existing install into manifest: $($cat.Category)/$($item.Name)" + } + } +} + +if ($script:SubscriptionEntries.Count -gt 0) { + Update-Subscriptions -ManifestPath $SubscriptionManifestPath -NewEntries $script:SubscriptionEntries.ToArray() +} + +Write-Host "" +if ($Uninstall) { + Log "Uninstall complete." 'SUCCESS' +} elseif ($DryRun) { + Log "Dry run complete. Re-run without -DryRun to apply." 'WARN' +} else { + Log "$totalInstalled resource(s) installed/updated in $GithubDir" 'SUCCESS' + Log "Tip: commit .github/ to share Copilot resources with your team (agents, instructions, hooks, workflows, skills)." + Log "Tip: run update-repo.ps1 to check for and apply upstream changes to your subscribed resources." + Log "Tip: run init-repo.ps1 -Uninstall to remove any installed resources." +} +#endregion # Summary diff --git a/scripts/sync-awesome-copilot.ps1 b/scripts/sync-awesome-copilot.ps1 new file mode 100644 index 0000000..3b80f9b --- /dev/null +++ b/scripts/sync-awesome-copilot.ps1 @@ -0,0 +1,259 @@ +<# +Sync Awesome Copilot Resources + +Clones (first run) or pulls (subsequent runs) the github/awesome-copilot repository +using sparse checkout — only the categories you need are fetched. + +Requires 'gh' (GitHub CLI, preferred) or 'git' to be installed. + +Usage: + # Sync all default categories + .\sync-awesome-copilot.ps1 + + # Dry-run: show what would change without writing files + .\sync-awesome-copilot.ps1 -Plan + + # Sync specific categories only + .\sync-awesome-copilot.ps1 -Categories "agents,instructions" + + # Force a specific git tool + .\sync-awesome-copilot.ps1 -GitTool git +#> +[CmdletBinding()] param( + [string]$Dest = "$HOME/.awesome-copilot", + [string]$Categories = 'agents,instructions,workflows,hooks,skills', + [switch]$Quiet, + [switch]$Plan, # Dry-run: show what would change without writing files + [int]$LogRetentionDays = 14, + [int]$TimeoutSeconds = 600, + [ValidateSet('auto', 'gh', 'git')] + [string]$GitTool = 'auto' +) + +#region Initialisation +$ErrorActionPreference = 'Stop' + +$script:StartTime = Get-Date +$script:Deadline = $script:StartTime.AddSeconds($TimeoutSeconds) + +function Write-Log { + param([string]$Message, [string]$Level = 'INFO') + $ts = (Get-Date).ToString('s') + $line = "[$ts][$Level] $Message" + if (-not $Quiet) { Write-Host $line } + Add-Content -Path $Global:LogFile -Value $line +} + +function Check-Timeout { + if ((Get-Date) -gt $script:Deadline) { + Write-Log "Timeout reached ($TimeoutSeconds s), aborting." 'ERROR' + exit 1 + } +} + +# Prepare log — always relative to this script's directory, regardless of CWD +$RunId = (Get-Date -Format 'yyyyMMdd-HHmmss') +$LogDir = Join-Path $PSScriptRoot 'logs' +if (-not (Test-Path $LogDir)) { New-Item -ItemType Directory -Path $LogDir | Out-Null } +$Global:LogFile = Join-Path $LogDir "sync-$RunId.log" + +Write-Log "Starting Awesome Copilot sync. Dest=$Dest Categories=$Categories" + +#endregion # Initialisation + +#region Tool detection +function Resolve-GitTool { + if ($GitTool -ne 'auto') { + if (-not (Get-Command $GitTool -ErrorAction SilentlyContinue)) { + Write-Log "'$GitTool' not found on PATH." 'ERROR'; exit 1 + } + return $GitTool + } + if (Get-Command gh -ErrorAction SilentlyContinue) { return 'gh' } + if (Get-Command git -ErrorAction SilentlyContinue) { return 'git' } + Write-Log "Neither 'gh' nor 'git' found on PATH. Install one to continue." 'ERROR' + exit 1 +} + +$Tool = Resolve-GitTool +Write-Log "Using tool: $Tool" + +$RepoSlug = 'github/awesome-copilot' +$RepoUrl = 'https://github.com/github/awesome-copilot.git' +$CategoriesList = $Categories.Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ } +$ManifestPath = Join-Path $Dest 'manifest.json' +$StatusPath = Join-Path $Dest 'status.txt' + +# Load previous manifest for change detection +$PrevManifest = $null +$PrevIndex = @{} +if (Test-Path $ManifestPath) { + try { + $PrevManifest = Get-Content $ManifestPath -Raw | ConvertFrom-Json + if ($PrevManifest.items) { + foreach ($it in $PrevManifest.items) { + $PrevIndex["$($it.category)|$($it.path)"] = $it + } + } + } + catch { Write-Log "Failed to parse previous manifest: $_" 'WARN' } +} + +function Get-Sha256 { + param([string]$FilePath) + return (Get-FileHash -LiteralPath $FilePath -Algorithm SHA256).Hash.ToLower() +} + +#endregion # Tool detection + +#region Clone or pull +$IsFirstRun = -not (Test-Path (Join-Path $Dest '.git')) + +if ($Plan) { + if ($IsFirstRun) { + Write-Log "[Plan] Would clone $RepoSlug → $Dest (sparse: $($CategoriesList -join ', '))" 'INFO' + } else { + Write-Log "[Plan] Would pull latest changes from $RepoSlug into $Dest" 'INFO' + } + Write-Log "[Plan] No files written. Exiting." 'INFO' + exit 0 +} + +if ($IsFirstRun) { + Write-Log "First run — cloning $RepoSlug (sparse, shallow)..." + + # Migrate: if a non-git directory already exists (e.g. from the old API-based sync), + # rename it so git can clone into a clean destination. + if ((Test-Path $Dest) -and (Get-ChildItem $Dest -Force | Measure-Object).Count -gt 0) { + $backupPath = "${Dest}-backup-$RunId" + Write-Log "Existing non-git cache found — moving to $backupPath before cloning." 'WARN' + Move-Item $Dest $backupPath + } + + if (-not (Test-Path $Dest)) { New-Item -ItemType Directory -Path $Dest -Force | Out-Null } + + if ($Tool -eq 'gh') { + & gh repo clone $RepoSlug $Dest -- --depth 1 --filter=blob:none --sparse 2>&1 | + ForEach-Object { Write-Log $_ } + } else { + & git clone --depth 1 --filter=blob:none --sparse $RepoUrl $Dest 2>&1 | + ForEach-Object { Write-Log $_ } + } + if ($LASTEXITCODE -and $LASTEXITCODE -ne 0) { Write-Log "Clone failed (exit $LASTEXITCODE)" 'ERROR'; exit $LASTEXITCODE } + + # Set which directories to check out, then materialise them + & git -C $Dest sparse-checkout set @CategoriesList 2>&1 | Out-Null + Write-Log "Repository cloned successfully." 'SUCCESS' +} else { + Write-Log "Pulling latest changes from $RepoSlug..." + + # Re-apply sparse-checkout in case -Categories changed since last run + & git -C $Dest sparse-checkout set @CategoriesList 2>&1 | Out-Null + + $pullOutput = & git -C $Dest pull 2>&1 + $pullOutput | ForEach-Object { Write-Log $_ } + if ($LASTEXITCODE -and $LASTEXITCODE -ne 0) { + $pullText = $pullOutput -join "`n" + if ($pullText -match 'unrelated histories') { + Write-Log "Unrelated histories detected — fetching and resetting to remote HEAD..." 'WARN' + & git -C $Dest fetch origin 2>&1 | ForEach-Object { Write-Log $_ } + & git -C $Dest reset --hard origin/HEAD 2>&1 | ForEach-Object { Write-Log $_ } + if ($LASTEXITCODE -and $LASTEXITCODE -ne 0) { Write-Log "Reset failed (exit $LASTEXITCODE)" 'ERROR'; exit $LASTEXITCODE } + } elseif ($pullText -match 'unmerged files|unresolved conflict|merge conflict') { + # Local cache has conflicts — safe to discard since this directory is read-only managed by this script + Write-Log "Unmerged files detected in local cache — resetting to remote HEAD..." 'WARN' + & git -C $Dest fetch origin 2>&1 | ForEach-Object { Write-Log $_ } + & git -C $Dest reset --hard origin/HEAD 2>&1 | ForEach-Object { Write-Log $_ } + & git -C $Dest clean -fd 2>&1 | ForEach-Object { Write-Log $_ } + if ($LASTEXITCODE -and $LASTEXITCODE -ne 0) { Write-Log "Reset failed (exit $LASTEXITCODE)" 'ERROR'; exit $LASTEXITCODE } + } else { + Write-Log "Pull failed (exit $LASTEXITCODE)" 'ERROR'; exit $LASTEXITCODE + } + } +} + +Check-Timeout + +#endregion # Clone or pull + +#region File scan and change detection +$NewItems = @() +$Added = 0; $Updated = 0; $Unchanged = 0; $Removed = 0 +$DestResolved = (Resolve-Path $Dest).Path + +foreach ($cat in $CategoriesList) { + $catDir = Join-Path $DestResolved $cat + if (-not (Test-Path $catDir)) { Write-Log "Category folder not found after sync: $cat" 'WARN'; continue } + + $files = Get-ChildItem -Path $catDir -Recurse -File | + Where-Object { $_.Name -match '\.(md|markdown|json|sh)$' } + + foreach ($file in $files) { + Check-Timeout + $relativePath = $file.FullName.Substring($DestResolved.Length + 1) -replace '\\', '/' + $hash = Get-Sha256 -FilePath $file.FullName + $key = "$cat|$relativePath" + $prev = $PrevIndex[$key] + + if ($prev -and $prev.hash -eq $hash) { $Unchanged++ } + elseif ($prev) { $Updated++; Write-Log "Updated: $relativePath" } + else { $Added++; Write-Log "Added: $relativePath" } + + $NewItems += [pscustomobject]@{ + category = $cat + path = $relativePath + size = $file.Length + lastFetched = (Get-Date).ToString('o') + hash = $hash + } + } +} + +# Count removals (files present in previous manifest but gone after pull) +$NewKeySet = @{}; foreach ($ni in $NewItems) { $NewKeySet["$($ni.category)|$($ni.path)"] = $true } +if ($PrevManifest -and $PrevManifest.items) { + foreach ($old in $PrevManifest.items) { + if (-not $NewKeySet.ContainsKey("$($old.category)|$($old.path)")) { + $Removed++ + Write-Log "Removed: $($old.path)" + } + } +} + +#endregion # File scan + +#region Write manifest and status +$Manifest = [pscustomobject]@{ + version = 1 + repo = $RepoSlug + fetchedAt = (Get-Date).ToString('o') + categories = $CategoriesList + items = $NewItems + summary = [pscustomobject]@{ added=$Added; updated=$Updated; removed=$Removed; unchanged=$Unchanged } +} +$Manifest | ConvertTo-Json -Depth 6 | Set-Content -Path $ManifestPath -Encoding UTF8 + +@( + "Sync run: $(Get-Date -Format o)" + "Added: $Added" + "Updated: $Updated" + "Removed: $Removed" + "Unchanged:$Unchanged" + "Total: $($NewItems.Count)" + "Manifest: manifest.json" + "Repo: $RepoSlug" + "Duration: $([int]((Get-Date)-$script:StartTime).TotalSeconds)s" +) | Set-Content -Path $StatusPath -Encoding UTF8 + +Write-Log "Summary Added=$Added Updated=$Updated Removed=$Removed Unchanged=$Unchanged" 'SUCCESS' + +#endregion # Write manifest + +#region Log retention +Get-ChildItem $LogDir -Filter 'sync-*.log' | + Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-$LogRetentionDays) } | + ForEach-Object { Remove-Item $_.FullName -Force } + +#endregion # Log retention + +exit 0 diff --git a/scripts/update-repo.ps1 b/scripts/update-repo.ps1 new file mode 100644 index 0000000..77ae79a --- /dev/null +++ b/scripts/update-repo.ps1 @@ -0,0 +1,194 @@ +<# +Update Subscribed Per-Repo Copilot Resources + +Reads the subscription manifest (.github/.copilot-subscriptions.json) written by +init-repo.ps1, compares each subscribed resource against the local awesome-copilot +cache, and applies any upstream updates. + +Resources are updated in place — the same files/directories that were originally +installed to .github/ are refreshed from the cache. + +Usage: + # Check for and apply updates (interactive prompt) + .\update-repo.ps1 + + # Dry run — show what would be updated without writing any files + .\update-repo.ps1 -DryRun + + # Apply all updates without prompting + .\update-repo.ps1 -Force + + # Check a specific repo + .\update-repo.ps1 -RepoPath "C:\Projects\my-app" + +Notes: + - Only resources present in .github/.copilot-subscriptions.json are checked. + Run init-repo.ps1 to add new subscriptions. + - Resources whose destination file/directory has been manually deleted are + skipped (treated as intentionally removed). + - The subscription manifest is updated with new hashes after each successful update. + - Requires the local awesome-copilot cache (~/.awesome-copilot/). Run + sync-awesome-copilot.ps1 first if the cache is stale. +#> +[CmdletBinding()] param( + [string]$RepoPath = (Get-Location).Path, + [string]$SourceRoot = "$HOME/.awesome-copilot", + [switch]$Force, + [switch]$DryRun +) + +#region Initialisation +$ErrorActionPreference = 'Stop' + +function Log($m, [string]$level = 'INFO') { + $ts = (Get-Date).ToString('s') + $color = switch ($level) { 'ERROR' { 'Red' } 'WARN' { 'Yellow' } 'SUCCESS' { 'Green' } default { 'Cyan' } } + Write-Host "[$ts][$level] $m" -ForegroundColor $color +} + +function Get-DirHash([string]$DirPath) { + $hashes = Get-ChildItem $DirPath -Recurse -File | + Sort-Object FullName | + ForEach-Object { (Get-FileHash $_.FullName -Algorithm SHA256).Hash } + $combined = $hashes -join '|' + $bytes = [System.Text.Encoding]::UTF8.GetBytes($combined) + $stream = [System.IO.MemoryStream]::new($bytes) + return (Get-FileHash -InputStream $stream -Algorithm SHA256).Hash +} + +#endregion # Initialisation + +#region Load subscriptions +if (-not (Test-Path $RepoPath)) { + Log "Repo path not found: $RepoPath" 'ERROR'; exit 1 +} +$RepoPath = Resolve-Path $RepoPath | Select-Object -ExpandProperty Path + +$GithubDir = Join-Path $RepoPath '.github' +$ManifestPath = Join-Path $GithubDir '.copilot-subscriptions.json' + +if (-not (Test-Path $ManifestPath)) { + Log "No subscriptions manifest found — run init-repo.ps1 first." + exit 0 +} + +$manifest = Get-Content $ManifestPath -Raw | ConvertFrom-Json +$subs = @($manifest.subscriptions) + +if (-not $subs -or $subs.Count -eq 0) { + exit 0 +} + +if (-not (Test-Path $SourceRoot)) { + Log "Cache not found: $SourceRoot -- run sync-awesome-copilot.ps1 first" 'ERROR'; exit 1 +} + +Log "Checking $($subs.Count) subscribed resource(s) for upstream changes..." +Log "Cache : $SourceRoot" +Log "Repo : $RepoPath" + +#endregion # Load subscriptions + +#region Check for stale resources +$stale = [System.Collections.Generic.List[object]]::new() + +foreach ($sub in $subs) { + $sourcePath = Join-Path $SourceRoot $sub.sourceRelPath + + if (-not (Test-Path $sourcePath)) { + Log "= Skipping $($sub.name) ($($sub.category)) — no longer in cache." 'WARN' + continue + } + + # Destination: .github/ e.g. .github/agents/foo.agent.md + $destPath = Join-Path $GithubDir $sub.sourceRelPath + + if (-not (Test-Path $destPath)) { + Log "= Skipping $($sub.name) ($($sub.category)) — destination removed locally." + continue + } + + $currentHash = if ($sub.type -eq 'file') { + (Get-FileHash $sourcePath -Algorithm SHA256).Hash + } else { + Get-DirHash $sourcePath + } + + if ($currentHash -ne $sub.hashAtInstall) { + $stale.Add([pscustomobject]@{ + Sub = $sub + SourcePath = $sourcePath + DestPath = $destPath + CurrentHash = $currentHash + }) + Log "↑ Stale : $($sub.name) ($($sub.category))" + } else { + Log "= Current: $($sub.name) ($($sub.category))" + } +} + +#endregion # Check for stale resources + +#region Apply updates +if ($stale.Count -eq 0) { + Write-Host "" + Log "All $($subs.Count) subscribed resource(s) are up to date." 'SUCCESS' + exit 0 +} + +Write-Host "" +Log "$($stale.Count) resource(s) have upstream updates available." 'WARN' + +if ($DryRun) { + Log "[DryRun] Re-run without -DryRun to apply updates." 'WARN' + exit 0 +} + +if (-not $Force) { + Write-Host "" + Write-Host " Apply all $($stale.Count) update(s)? [Y] Yes [N] No (default): " -NoNewline -ForegroundColor Yellow + $answer = (Read-Host).Trim() + if ($answer -notmatch '^[Yy]') { + Log "Update skipped." + exit 0 + } +} + +$updated = 0 +foreach ($item in $stale) { + $sub = $item.Sub + try { + if ($sub.type -eq 'file') { + $destDir = Split-Path $item.DestPath -Parent + if (-not (Test-Path $destDir)) { New-Item -ItemType Directory -Path $destDir -Force | Out-Null } + Copy-Item $item.SourcePath $item.DestPath -Force + } else { + # Mirror all files from the source directory into the destination + Get-ChildItem $item.SourcePath -File -Recurse | ForEach-Object { + $rel = $_.FullName.Substring($item.SourcePath.Length).TrimStart('\', '/') + $dest = Join-Path $item.DestPath $rel + $destDir = Split-Path $dest -Parent + if (-not (Test-Path $destDir)) { New-Item -ItemType Directory -Path $destDir -Force | Out-Null } + Copy-Item $_.FullName $dest -Force + } + } + + $sub | Add-Member -NotePropertyName 'hashAtInstall' -NotePropertyValue $item.CurrentHash -Force + $sub | Add-Member -NotePropertyName 'installedAt' -NotePropertyValue (Get-Date).ToString('o') -Force + Log "✓ Updated: $($sub.name) ($($sub.category))" + $updated++ + } catch { + Log "Failed to update $($sub.name): $_" 'ERROR' + } +} + +# Persist updated hashes back to the manifest +$manifest | Add-Member -NotePropertyName 'updatedAt' -NotePropertyValue (Get-Date).ToString('o') -Force +$manifest | Add-Member -NotePropertyName 'subscriptions' -NotePropertyValue $subs -Force +$manifest | ConvertTo-Json -Depth 5 | Set-Content $ManifestPath -Encoding UTF8 + +Write-Host "" +Log "$updated resource(s) updated in $GithubDir" 'SUCCESS' +Log "Tip: commit .github/ to share the updates with your team." + +#endregion # Apply updates diff --git a/sync-awesome-copilot.ps1 b/sync-awesome-copilot.ps1 deleted file mode 100644 index 5639dfa..0000000 --- a/sync-awesome-copilot.ps1 +++ /dev/null @@ -1,328 +0,0 @@ -[CmdletBinding()] param( - [string]$Dest = "$HOME/.awesome-copilot", - # Default now excludes 'collections' (can still be added explicitly via -Categories) - [string]$Categories = 'chatmodes,instructions,prompts', - [switch]$Quiet, - [switch]$NoDelete, - [switch]$DiffOnly, - [switch]$Plan, # Dry-run: compute changes, no file writes / deletions / manifest update - [switch]$SkipBackup, # Skip pre-deletion backup snapshot - [int]$BackupRetention = 5, # Number of recent backups to retain - [int]$LogRetentionDays = 14, - [int]$TimeoutSeconds = 600 -) - -$ErrorActionPreference = 'Inquire' - -$script:StartTime = Get-Date -$script:Deadline = $script:StartTime.AddSeconds($TimeoutSeconds) - -function Write-Log { - param([string]$Message, [string]$Level = 'INFO') - $ts = (Get-Date).ToString('s') - $line = "[$ts][$Level] $Message" - if (-not $Quiet) { Write-Host $line } - Add-Content -Path $Global:LogFile -Value $line -} - -function Check-Timeout { - if ((Get-Date) -gt $script:Deadline) { - Write-Log "Timeout reached, aborting." 'ERROR' - exit 1 - } -} - -# Prepare paths -$Root = Resolve-Path -Path . | Select-Object -ExpandProperty Path -$RunId = (Get-Date -Format 'yyyyMMdd-HHmmss') -if (-not (Test-Path logs)) { New-Item -ItemType Directory -Path logs | Out-Null } -$Global:LogFile = Join-Path logs "sync-$RunId.log" - -Write-Log "Starting Awesome Copilot scheduled sync. Dest=$Dest Categories=$Categories" 'INFO' - -# Ensure destination -if (-not (Test-Path $Dest)) { New-Item -ItemType Directory -Path $Dest -Force | Out-Null } - -$ManifestPath = Join-Path $Dest 'manifest.json' -$StatusPath = Join-Path $Dest 'status.txt' - -# Load previous manifest -$PrevManifest = $null -if (Test-Path $ManifestPath) { - try { $PrevManifest = Get-Content $ManifestPath -Raw | ConvertFrom-Json } catch { Write-Log "Failed to parse previous manifest: $_" 'WARN' } -} - -$CategoriesList = $Categories.Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ } - -$Repo = 'github/awesome-copilot' -$ApiBase = 'https://api.github.com' -$UserAgent = 'awesome-copilot-scheduled-sync' -$Token = $env:GITHUB_TOKEN - -function Invoke-Github { - param( - [string]$Url, - [int]$Attempt = 1 - ) - Check-Timeout - $Headers = @{ 'User-Agent' = $UserAgent; 'Accept' = 'application/vnd.github.v3+json' } - if ($Token) { $Headers['Authorization'] = "Bearer $Token" } - try { - return Invoke-RestMethod -Uri $Url -Headers $Headers -TimeoutSec 60 - } - catch { - # Rate limit detection (403 + Remaining=0) - try { - $resp = $_.Exception.Response - if ($resp -and $resp.StatusCode.value__ -eq 403) { - $remainingHeader = $resp.Headers['X-RateLimit-Remaining'] - if ($remainingHeader -eq '0') { - $script:RateLimitHit = $true - Write-Log "Rate limit hit for $Url (Remaining=0)." 'WARN' - } - } - } - catch {} - if ($Attempt -lt 3 -and ($_.Exception.Response.StatusCode.value__ -ge 500 -or $_.Exception.Response.StatusCode.value__ -eq 429)) { - $delay = [math]::Pow(2, $Attempt) - Write-Log "Transient error on $Url. Retry in $delay s" 'WARN' - Start-Sleep -Seconds $delay - return Invoke-Github -Url $Url -Attempt ($Attempt + 1) - } - Write-Log "Request failed: $Url :: $_" 'ERROR' - throw - } -} - -function Get-FileHashSha256String { - param([byte[]]$Bytes) - $sha256 = [System.Security.Cryptography.SHA256]::Create() - $hashBytes = $sha256.ComputeHash($Bytes) - ($hashBytes | ForEach-Object { $_.ToString('x2') }) -join '' -} - -if ($DiffOnly) { - if (-not $PrevManifest) { Write-Log "No previous manifest; diff-only mode cannot proceed." 'ERROR'; exit 1 } - Write-Log "Diff-only mode: no network calls. Summarizing previous manifest." 'INFO' - $summary = $PrevManifest.summary - $content = @() - $content += "Diff-only summary (previous run)" - $content += "Added: $($summary.added)" - $content += "Updated: $($summary.updated)" - $content += "Removed: $($summary.removed)" - $content += "Unchanged:$($summary.unchanged)" - Set-Content -Path $StatusPath -Value ($content -join [Environment]::NewLine) - exit 0 -} - -$NewItems = @() -$Added = 0; $Updated = 0; $Removed = 0; $Unchanged = 0 -$PrevIndex = @{} -if ($PrevManifest -and $PrevManifest.items) { - foreach ($it in $PrevManifest.items) { $PrevIndex["$($it.category)|$($it.path)"] = $it } -} - -foreach ($cat in $CategoriesList) { - Write-Log "Fetching category: $cat" 'INFO' - $url = "$ApiBase/repos/$Repo/contents/$cat" - try { - $listing = Invoke-Github -Url $url - } - catch { - Write-Log "Failed to list $cat" 'ERROR' - continue - } - - if (-not $script:SuccessfulCategories) { $script:SuccessfulCategories = @() } - $script:SuccessfulCategories += $cat - - foreach ($entry in $listing) { - if ($entry.type -ne 'file') { continue } - if (-not ($entry.name -match '\.(md|markdown|json)$')) { continue } - Check-Timeout - $downloadUrl = $entry.download_url - if (-not $downloadUrl) { continue } - $rawBytes = $null - try { - # Primary attempt: Invoke-WebRequest and derive bytes (ContentBytes is not a valid property in modern PowerShell) - $resp = Invoke-WebRequest -Uri $downloadUrl -UserAgent $UserAgent -TimeoutSec 60 -ErrorAction Stop - if ($resp.RawContentStream) { - $ms = New-Object System.IO.MemoryStream - $resp.RawContentStream.CopyTo($ms) - $rawBytes = $ms.ToArray() - } - elseif ($resp.Content) { - # Fallback: encode string content as UTF8 (raw text files like md/json are UTF-8 on GitHub) - $rawBytes = [System.Text.Encoding]::UTF8.GetBytes($resp.Content) - } - if (-not $rawBytes -or $rawBytes.Length -eq 0) { throw "Empty response body" } - } - catch { - Write-Log "Direct download failed for $($entry.path): $_ (will fallback to contents API)" 'WARN' - try { - # Fallback: GitHub contents API returns base64 content - $fileMeta = Invoke-Github -Url "$ApiBase/repos/$Repo/contents/$($entry.path)" - if ($fileMeta.content) { - $b64 = ($fileMeta.content -replace "\s", '') - $rawBytes = [System.Convert]::FromBase64String($b64) - } - else { - throw "No content field in contents API response" - } - } - catch { - Write-Log "Failed download $($entry.path): $_" 'ERROR' - continue - } - } - $hash = Get-FileHashSha256String -Bytes $rawBytes - $key = "$cat|$($entry.path)" - $prev = $PrevIndex[$key] - $relativePath = $entry.path - $targetFile = Join-Path $Dest $relativePath - $targetDir = Split-Path $targetFile -Parent - if (-not (Test-Path $targetDir)) { New-Item -ItemType Directory -Path $targetDir -Force | Out-Null } - - $isChange = $true - if ($prev -and $prev.sha -eq $entry.sha -and $prev.hash -eq $hash) { $isChange = $false } - if ($isChange) { - if ($Plan) { - Write-Log "[Plan] Would save: $relativePath" 'INFO' - } - else { - # Ensure the file is fully replaced by removing it first if it exists - if (Test-Path $targetFile) { - Remove-Item $targetFile -Force - } - [System.IO.File]::WriteAllBytes($targetFile, $rawBytes) - } - if ($prev) { $Updated++ } else { $Added++ } - if (-not $Plan) { Write-Log "Saved: $relativePath" 'INFO' } - } - else { $Unchanged++ } - - $NewItems += [pscustomobject]@{ - category = $cat - path = $relativePath - sha = $entry.sha - size = $entry.size - lastFetched = (Get-Date).ToString('o') - hash = $hash - } - } -} - -# Determine removals (only for categories successfully fetched this run) -if (-not $Plan -and -not $NoDelete -and $PrevManifest) { - if ($script:RateLimitHit) { - Write-Log 'Rate limit encountered this run; skipping stale file deletion.' 'WARN' - } - else { - $successful = $script:SuccessfulCategories | Sort-Object -Unique - if (-not $successful -or $successful.Count -eq 0) { - Write-Log 'No categories fetched successfully this run; skipping stale file deletion for safety.' 'WARN' - } - else { - # Backup snapshot before deletions - if (-not $SkipBackup) { - try { - $backupRoot = Join-Path $Dest 'backups' - if (-not (Test-Path $backupRoot)) { New-Item -ItemType Directory -Path $backupRoot | Out-Null } - $backupFile = Join-Path $backupRoot ("pre-delete-" + $RunId + '.zip') - Write-Log "Creating backup snapshot: $backupFile" 'INFO' - Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction SilentlyContinue - # Zip only successfully fetched category folders (if present) - $tempStage = Join-Path $backupRoot ("stage-" + $RunId) - New-Item -ItemType Directory -Path $tempStage | Out-Null - foreach ($c in $successful) { - $cDir = Join-Path $Dest $c - if (Test-Path $cDir) { Copy-Item $cDir (Join-Path $tempStage $c) -Recurse -Force } - } - [IO.Compression.ZipFile]::CreateFromDirectory($tempStage, $backupFile) - Remove-Item $tempStage -Recurse -Force -ErrorAction SilentlyContinue - # Retention - $backups = Get-ChildItem $backupRoot -Filter 'pre-delete-*.zip' | Sort-Object LastWriteTime -Descending - if ($backups.Count -gt $BackupRetention) { - $toRemove = $backups | Select-Object -Skip $BackupRetention - foreach ($oldB in $toRemove) { Remove-Item $oldB.FullName -Force } - } - } - catch { - Write-Log "Backup snapshot failed (continuing without backup): $_" 'WARN' - } - } - $NewKeySet = @{} - foreach ($ni in $NewItems) { $NewKeySet["$($ni.category)|$($ni.path)"] = $true } - foreach ($old in $PrevManifest.items) { - $k = "$($old.category)|$($old.path)" - # Only consider deletion if the category was fetched this run - if ($successful -contains $old.category) { - if (-not $NewKeySet.ContainsKey($k)) { - $Removed++ - $fileToRemove = Join-Path $Dest $old.path - if (Test-Path $fileToRemove) { Remove-Item $fileToRemove -Force } - Write-Log "Removed stale file: $($old.path)" 'INFO' - } - } - } - } - } -} - -if ($Plan) { - Write-Log "[Plan] Summary Added=$Added Updated=$Updated Removed=(planned) Unchanged=$Unchanged" 'INFO' - Write-Log '[Plan] No files written. Exiting without manifest/status update.' 'INFO' - exit 0 -} - -# Write manifest -$Manifest = [pscustomobject]@{ - version = 1 - repo = $Repo - fetchedAt = (Get-Date).ToString('o') - categories = $CategoriesList - items = $NewItems - summary = [pscustomobject]@{ - added = $Added - updated = $Updated - removed = $Removed - unchanged = $Unchanged - } -} -if ($script:SuccessfulCategories -and $script:SuccessfulCategories.Count -gt 0) { - $Manifest | ConvertTo-Json -Depth 6 | Set-Content -Path $ManifestPath -Encoding UTF8 - # Integrity marker - try { - $integrity = [pscustomobject]@{ - fetchedAt = (Get-Date).ToString('o') - successfulCategories = ($script:SuccessfulCategories | Sort-Object -Unique) - summary = $Manifest.summary - manifestSha256 = (Get-FileHash -Algorithm SHA256 $ManifestPath).Hash - } - $integrity | ConvertTo-Json -Depth 4 | Set-Content -Path (Join-Path $Dest 'last-success.json') -Encoding UTF8 - } - catch { Write-Log "Failed writing integrity marker: $_" 'WARN' } -} -else { - Write-Log 'No successful categories; manifest not updated this run.' 'WARN' -} - -# Status file -$StatusLines = @() -$StatusLines += "Sync run: $(Get-Date -Format o)" -$StatusLines += "Added: $Added" -$StatusLines += "Updated: $Updated" -$StatusLines += "Removed: $Removed" -$StatusLines += "Unchanged:$Unchanged" -$StatusLines += "Total: $($Manifest.items.Count)" -$StatusLines += "Manifest: manifest.json" -$StatusLines += "Repo: $Repo" -$StatusLines += "Duration: $([int]((Get-Date)-$script:StartTime).TotalSeconds)s" -$StatusLines | Set-Content -Path $StatusPath -Encoding UTF8 - -Write-Log "Summary Added=$Added Updated=$Updated Removed=$Removed Unchanged=$Unchanged" 'INFO' - -# Log retention -Get-ChildItem logs -Filter 'sync-*.log' | Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-$LogRetentionDays) } | ForEach-Object { Remove-Item $_.FullName -Force } - -exit 0 diff --git a/uninstall-scheduled-task.ps1 b/uninstall-scheduled-task.ps1 deleted file mode 100644 index ec17132..0000000 --- a/uninstall-scheduled-task.ps1 +++ /dev/null @@ -1,10 +0,0 @@ -[CmdletBinding()] param( - [string]$TaskName = 'AwesomeCopilotSync' -) -if (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue) { - Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false - Write-Host "Removed scheduled task $TaskName" -ForegroundColor Yellow -} -else { - Write-Host "Task $TaskName not found" -ForegroundColor Gray -}