Skip to content

Commit 0b916cf

Browse files
committed
Refactor Copilot CLI to install agents and skills in ~/.azd/copilot/ directory; update related documentation and tests
- Changed installation paths for agents and skills from ~/.copilot/ to ~/.azd/copilot/ - Updated setup functions to return installation directories - Modified error handling to reflect new paths - Adjusted tests to verify new installation behavior - Enhanced documentation for skills and extensions, including deployment commands and error handling - Removed outdated references and files related to previous structure - Updated UI text to reflect branding changes from "Azure Developer CLI Copilot" to "Azure Copilot"
1 parent 395fecc commit 0b916cf

File tree

20 files changed

+274
-655
lines changed

20 files changed

+274
-655
lines changed

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,8 @@ The `SKILL.md` must have YAML frontmatter with `name` and `description`. After a
5757
| Command | What it does |
5858
|---------|-------------|
5959
| `mage SyncSkills` | Pull latest upstream skills into `ghcp4a-skills/` (smart merge — keeps your changes) |
60-
| `mage SyncSkills -source /path` | Sync from a local clone instead of cloning remotely |
61-
| `mage SyncSkills -repo url -branch branch` | Sync from a custom repo/branch (e.g. a fork) |
60+
| `mage SyncSkills /path/to/clone` | Sync from a local clone instead of cloning remotely |
61+
| `mage SyncSkills url@branch` | Sync from a custom repo/branch (e.g. a fork) |
6262
| `mage ContributeSkills` | Create a branch with your `ghcp4a-skills/` changes for a PR to upstream |
6363

6464
## MCP Server Configuration

README.md

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -205,19 +205,16 @@ go test ./...
205205
mage SyncSkills
206206

207207
# Sync from a local clone of the upstream repo
208-
mage SyncSkills -source /path/to/local/clone
208+
mage SyncSkills C:\code\GitHub-Copilot-for-Azure
209209

210210
# Sync from a different repo or branch
211-
mage SyncSkills -repo https://github.com/user/fork.git
212-
mage SyncSkills -branch feature-x
213-
mage SyncSkills -repo https://github.com/user/fork.git -branch my-branch
211+
mage SyncSkills https://github.com/user/fork.git
212+
mage SyncSkills https://github.com/user/fork.git@my-branch
214213

215214
# Contribute your skill changes back upstream
216215
mage ContributeSkills
217216
```
218217

219-
Flags can also be set via environment variables (`SKILLS_SOURCE`, `SKILLS_REPO`, `SKILLS_BRANCH`).
220-
221218
The sync uses **smart merge** — your local changes are preserved, new upstream files are added, and only unmodified files are updated.
222219

223220
### Adding a Custom Skill

cli/build.ps1

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,5 +167,9 @@ foreach ($PLATFORM in $PLATFORMS) {
167167
}
168168
}
169169

170+
# Kill extension processes again right before azd x build copies to ~/.azd/extensions/
171+
# This prevents "file in use" errors during the install step
172+
Stop-ExtensionProcesses
173+
170174
Write-Host "`n✓ Build completed successfully!" -ForegroundColor Green
171175
Write-Host " Binaries are located in the $OUTPUT_DIR directory." -ForegroundColor Gray

cli/build.sh

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,10 @@ for PLATFORM in "${PLATFORMS[@]}"; do
133133
fi
134134
done
135135

136+
# Kill extension processes again right before azd x build copies to ~/.azd/extensions/
137+
# This prevents "file in use" errors during the install step
138+
stop_extension_processes
139+
136140
echo ""
137141
echo "✓ Build completed successfully!"
138142
echo " Binaries are located in the $OUTPUT_DIR directory."

cli/magefile.go

Lines changed: 163 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ package main
44

55
import (
66
"encoding/json"
7-
"flag"
87
"fmt"
98
"os"
109
"os/exec"
@@ -73,6 +72,7 @@ func All() error {
7372
// Build builds the CLI binary and installs it locally using azd x build.
7473
func Build() error {
7574
_ = killExtensionProcesses()
75+
time.Sleep(500 * time.Millisecond)
7676

7777
// Ensure azd extensions are set up (enables extensions + installs azd x if needed)
7878
if err := ensureAzdExtensions(); err != nil {
@@ -92,7 +92,7 @@ func Build() error {
9292
}
9393

9494
// Build and install directly using azd x build
95-
if err := sh.RunWithV(env, "azd", "x", "build"); err != nil {
95+
if err := runWithEnvRetry(env, "azd", "x", "build"); err != nil {
9696
return fmt.Errorf("build failed: %w", err)
9797
}
9898

@@ -183,72 +183,52 @@ const (
183183
skillsTargetPath = "src/internal/assets/ghcp4a-skills"
184184
)
185185

186-
// SyncSkills syncs upstream skills from microsoft/GitHub-Copilot-for-Azure
187-
// using a smart merge: new upstream files are added, deleted upstream files are
188-
// removed, but locally modified files are preserved.
186+
// SyncSkills syncs upstream skills from microsoft/GitHub-Copilot-for-Azure.
189187
//
190-
// Flags (also settable via environment variables):
188+
// When syncing from a local path, an exact sync is performed: the target is
189+
// mirrored to match the source, including file deletions and content updates.
191190
//
192-
// -source / SKILLS_SOURCE — path to a local clone (skips cloning)
193-
// -repo / SKILLS_REPO — GitHub repo URL to clone from (default: upstream)
194-
// -branch / SKILLS_BRANCH — branch to sync from (default: main)
191+
// When syncing from a remote repo, a smart merge is used: new files are added,
192+
// deleted files are removed, but locally modified files are preserved.
193+
//
194+
// The source parameter controls where to sync from:
195+
//
196+
// (empty) — clone upstream main (default)
197+
// local path — exact sync from a local clone (auto-detected)
198+
// repo URL — smart merge from a custom repo (main branch)
199+
// repo URL@branch — smart merge from a custom repo at a specific branch
195200
//
196201
// Examples:
197202
//
198-
// mage SyncSkills # upstream main (default)
199-
// mage SyncSkills -source /path/to/local/clone # local folder
200-
// mage SyncSkills -repo https://github.com/user/fork.git # custom repo
201-
// mage SyncSkills -branch feature-x # custom branch
202-
// mage SyncSkills -repo https://github.com/user/fork.git -branch x # both
203-
func SyncSkills() error {
204-
// Parse flags — fall back to env vars
205-
fs := flag.NewFlagSet("SyncSkills", flag.ContinueOnError)
206-
sourceFlag := fs.String("source", "", "path to a local clone of the upstream repo")
207-
repoFlag := fs.String("repo", "", "GitHub repo URL to clone from")
208-
branchFlag := fs.String("branch", "", "branch to sync from")
209-
210-
// os.Args[0] is the binary, os.Args[1] is the target name
211-
if len(os.Args) > 2 {
212-
if err := fs.Parse(os.Args[2:]); err != nil {
213-
return err
214-
}
215-
}
216-
217-
sourceDir := *sourceFlag
218-
if sourceDir == "" {
219-
sourceDir = os.Getenv("SKILLS_SOURCE")
220-
}
221-
repo := *repoFlag
222-
if repo == "" {
223-
repo = os.Getenv("SKILLS_REPO")
224-
}
225-
branch := *branchFlag
226-
if branch == "" {
227-
branch = os.Getenv("SKILLS_BRANCH")
228-
}
229-
203+
// mage SyncSkills # upstream main
204+
// mage SyncSkills C:\code\GitHub-Copilot-for-Azure # local folder
205+
// mage SyncSkills https://github.com/user/fork.git # custom repo
206+
// mage SyncSkills https://github.com/user/fork.git@my-branch # custom repo + branch
207+
func SyncSkills(source string) error {
230208
fmt.Println("🔄 Syncing upstream Azure skills...")
231209

210+
var sourceDir string
232211
var tempDir string
233212

234-
if sourceDir != "" {
235-
// Use local clone
213+
if source == "" {
214+
// Default: clone upstream main
215+
source = skillsSourceRepo
216+
}
217+
218+
// Detect if source is a local path or a repo URL
219+
isLocal := isLocalPath(source)
220+
if isLocal {
221+
sourceDir = source
236222
skillsDir := filepath.Join(sourceDir, skillsSourcePath)
237223
if _, err := os.Stat(skillsDir); os.IsNotExist(err) {
238224
return fmt.Errorf("skills not found at %s", skillsDir)
239225
}
240-
fmt.Printf("📂 Using local source: %s\n", sourceDir)
226+
fmt.Printf("📂 Using local source: %s (exact sync)\n", sourceDir)
241227
} else {
242-
// Determine repo URL and branch
243-
cloneRepo := skillsSourceRepo
244-
if repo != "" {
245-
cloneRepo = repo
246-
}
247-
if branch == "" {
248-
branch = "main"
249-
}
228+
// Parse optional @branch suffix
229+
repo, branch := parseRepoSource(source)
250230

251-
fmt.Printf("📥 Cloning %s (branch: %s, sparse)...\n", cloneRepo, branch)
231+
fmt.Printf("📥 Cloning %s (branch: %s, sparse)...\n", repo, branch)
252232
var err error
253233
tempDir, err = os.MkdirTemp("", "skills-sync-*")
254234
if err != nil {
@@ -258,7 +238,7 @@ func SyncSkills() error {
258238

259239
sourceDir = tempDir
260240
cmds := [][]string{
261-
{"git", "clone", "--depth=1", "--branch", branch, "--filter=blob:none", "--sparse", cloneRepo, tempDir},
241+
{"git", "clone", "--depth=1", "--branch", branch, "--filter=blob:none", "--sparse", repo, tempDir},
262242
{"git", "-C", tempDir, "sparse-checkout", "set", skillsSourcePath},
263243
}
264244
for _, args := range cmds {
@@ -306,9 +286,7 @@ func SyncSkills() error {
306286
}
307287
if !upstreamSet[e.Name()] {
308288
dst := filepath.Join(skillsTargetPath, e.Name())
309-
// Check if locally modified (has uncommitted changes via git)
310-
locallyModified := isLocallyModified(dst)
311-
if locallyModified {
289+
if !isLocal && isLocallyModified(dst) {
312290
fmt.Printf(" ⚠️ %s: removed upstream but locally modified — keeping\n", e.Name())
313291
kept++
314292
} else {
@@ -334,13 +312,13 @@ func SyncSkills() error {
334312
added++
335313
} else {
336314
// Existing skill — smart merge per file
337-
fileAdded, fileUpdated, fileKept, err := mergeSkillDir(src, dst)
315+
fileAdded, fileUpdated, fileKept, fileRemoved, err := mergeSkillDir(src, dst, isLocal)
338316
if err != nil {
339317
fmt.Printf(" ❌ %s: %v\n", name, err)
340318
continue
341319
}
342-
if fileAdded+fileUpdated > 0 {
343-
fmt.Printf(" ✅ %s (merged: %d new, %d updated, %d kept)\n", name, fileAdded, fileUpdated, fileKept)
320+
if fileAdded+fileUpdated+fileRemoved > 0 {
321+
fmt.Printf(" ✅ %s (merged: %d new, %d updated, %d removed, %d kept)\n", name, fileAdded, fileUpdated, fileRemoved, fileKept)
344322
} else if fileKept > 0 {
345323
fmt.Printf(" 🔒 %s (all %d files locally modified — kept)\n", name, fileKept)
346324
} else {
@@ -349,6 +327,7 @@ func SyncSkills() error {
349327
added += fileAdded
350328
updated += fileUpdated
351329
kept += fileKept
330+
removed += fileRemoved
352331
}
353332
}
354333

@@ -376,10 +355,66 @@ func runWithRetry(cmd string, args ...string) error {
376355
return err
377356
}
378357

358+
// runWithEnvRetry runs a command with environment variables, retrying up to 3 times on failure.
359+
func runWithEnvRetry(env map[string]string, cmd string, args ...string) error {
360+
const maxRetries = 3
361+
var err error
362+
for i := 0; i < maxRetries; i++ {
363+
if i > 0 {
364+
delay := time.Duration(i*5) * time.Second
365+
fmt.Printf(" ⚠️ Attempt %d/%d failed, retrying in %s...\n", i, maxRetries, delay)
366+
time.Sleep(delay)
367+
}
368+
if err = sh.RunWithV(env, cmd, args...); err == nil {
369+
return nil
370+
}
371+
}
372+
return err
373+
}
374+
379375
// mergeSkillDir merges an upstream skill directory into a local one.
380-
// Files modified locally are preserved; new/unchanged upstream files are copied.
381-
func mergeSkillDir(src, dst string) (added, updated, kept int, err error) {
382-
return added, updated, kept, filepath.Walk(src, func(path string, info os.FileInfo, walkErr error) error {
376+
// When exactSync is true (local source), all upstream changes are applied and
377+
// deleted files are removed without checking for local modifications.
378+
// When exactSync is false (remote source), locally modified files are preserved.
379+
func mergeSkillDir(src, dst string, exactSync bool) (added, updated, kept, removed int, err error) {
380+
// Build set of all files in upstream source (relative paths)
381+
upstreamFiles := make(map[string]bool)
382+
err = filepath.Walk(src, func(path string, info os.FileInfo, walkErr error) error {
383+
if walkErr != nil || info.IsDir() {
384+
return walkErr
385+
}
386+
rel, _ := filepath.Rel(src, path)
387+
upstreamFiles[rel] = true
388+
return nil
389+
})
390+
if err != nil {
391+
return
392+
}
393+
394+
// Remove local files that no longer exist upstream
395+
err = filepath.Walk(dst, func(path string, info os.FileInfo, walkErr error) error {
396+
if walkErr != nil || info.IsDir() {
397+
return walkErr
398+
}
399+
rel, _ := filepath.Rel(dst, path)
400+
if upstreamFiles[rel] {
401+
return nil // File still exists upstream
402+
}
403+
if !exactSync && isLocallyModified(path) {
404+
kept++
405+
return nil
406+
}
407+
fmt.Printf(" 🗑️ %s (deleted upstream)\n", rel)
408+
os.Remove(path)
409+
removed++
410+
return nil
411+
})
412+
if err != nil {
413+
return
414+
}
415+
416+
// Sync upstream files into local
417+
err = filepath.Walk(src, func(path string, info os.FileInfo, walkErr error) error {
383418
if walkErr != nil {
384419
return walkErr
385420
}
@@ -408,20 +443,26 @@ func mergeSkillDir(src, dst string) (added, updated, kept int, err error) {
408443

409444
// File exists locally — compare content
410445
if string(localData) == string(upstreamData) {
411-
// Identical — no action needed
412446
return nil
413447
}
414448

415449
// Different — check if locally modified (git tracks this)
416-
if isLocallyModified(dstFile) {
450+
if !exactSync && isLocallyModified(dstFile) {
417451
kept++
418-
return nil // Keep local version
452+
return nil
419453
}
420454

421455
// Not locally modified (upstream changed) — take upstream
422456
updated++
423457
return os.WriteFile(dstFile, upstreamData, 0644)
424458
})
459+
460+
// Clean up empty directories left after file removals
461+
if removed > 0 {
462+
removeEmptyDirs(dst)
463+
}
464+
465+
return added, updated, kept, removed, err
425466
}
426467

427468
// isLocallyModified checks if a path has uncommitted local modifications via git.
@@ -433,6 +474,43 @@ func isLocallyModified(path string) bool {
433474
return strings.TrimSpace(out) != ""
434475
}
435476

477+
// isLocalPath returns true if source looks like a local filesystem path
478+
// rather than a git repo URL.
479+
func isLocalPath(source string) bool {
480+
// URLs start with a scheme or git@ notation
481+
if strings.HasPrefix(source, "https://") ||
482+
strings.HasPrefix(source, "http://") ||
483+
strings.HasPrefix(source, "git@") ||
484+
strings.HasPrefix(source, "ssh://") {
485+
return false
486+
}
487+
// Check if the path actually exists on disk
488+
_, err := os.Stat(source)
489+
return err == nil
490+
}
491+
492+
// parseRepoSource splits a source string into repo URL and branch.
493+
// Supports "repo@branch" syntax; defaults to "main" if no branch specified.
494+
func parseRepoSource(source string) (repo, branch string) {
495+
// Split on last @ that comes after the scheme (to avoid splitting user@host)
496+
// Look for @ after ".git" or after the path portion
497+
repo = source
498+
branch = "main"
499+
500+
// Find @ that's not part of the scheme (git@...)
501+
// We look for @ after "github.com" or similar host portion
502+
idx := strings.LastIndex(source, "@")
503+
if idx > 0 {
504+
// Make sure the @ isn't part of git@github.com style prefix
505+
beforeAt := source[:idx]
506+
if strings.Contains(beforeAt, "/") {
507+
repo = beforeAt
508+
branch = source[idx+1:]
509+
}
510+
}
511+
return repo, branch
512+
}
513+
436514
// copyDir recursively copies a directory tree.
437515
func copyDir(src, dst string) error {
438516
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
@@ -458,6 +536,25 @@ func copyDir(src, dst string) error {
458536
})
459537
}
460538

539+
// removeEmptyDirs removes empty directories within root (bottom-up).
540+
func removeEmptyDirs(root string) {
541+
// Walk bottom-up by collecting dirs first, then checking in reverse
542+
var dirs []string
543+
filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
544+
if err == nil && info.IsDir() && path != root {
545+
dirs = append(dirs, path)
546+
}
547+
return nil
548+
})
549+
// Remove in reverse order (deepest first)
550+
for i := len(dirs) - 1; i >= 0; i-- {
551+
entries, err := os.ReadDir(dirs[i])
552+
if err == nil && len(entries) == 0 {
553+
os.Remove(dirs[i])
554+
}
555+
}
556+
}
557+
461558
const (
462559
customSkillsPath = "src/internal/assets/skills"
463560
agentsPath = "src/internal/assets/agents"

0 commit comments

Comments
 (0)