Files
msd-core/tests/copilot-install.test.cjs
erre c71c15c76e feat: add Copilot CLI runtime support and gsd-autonomous skill (#911)
* gsd: Installed

* docs: complete project research

Research for adding GitHub Copilot CLI as 5th runtime to installer.

Files:
- STACK.md: Zero new deps, Copilot reads from .github/, tool name mapping
- FEATURES.md: 18 table stakes, 4 differentiators, 6 anti-features
- ARCHITECTURE.md: Codex-parallel pattern, 5 new functions, 12 existing changes
- PITFALLS.md: 10 pitfalls with prevention strategies and phase mapping
- SUMMARY.md: Synthesized findings, 4-phase roadmap suggestion

* docs(01): create phase plan for core installer plumbing

* feat(01-01): add Copilot as 5th runtime across all install.js locations

- Add --copilot flag parsing and selectedRuntimes integration
- Add 'copilot' to --all array (5 runtimes)
- getDirName('copilot') returns '.github' (local path)
- getGlobalDir('copilot') returns ~/.copilot with COPILOT_CONFIG_DIR override
- getConfigDirFromHome handles copilot for both local/global
- Banner and help text updated to include Copilot
- promptRuntime: Copilot as option 5, All renumbered to option 6
- install(): isCopilot variable, runtimeLabel, skip hooks (Codex pattern)
- install(): Copilot early return before hooks/settings configuration
- finishInstall(): Copilot program name and /gsd-new-project command
- uninstall(): Copilot runtime label and isCopilot variable
- GSD_TEST_MODE exports: getDirName, getGlobalDir, getConfigDirFromHome

* test(01-01): add Copilot plumbing unit tests

- 19 tests covering getDirName, getGlobalDir, getConfigDirFromHome
- getGlobalDir: default path, explicit dir, COPILOT_CONFIG_DIR env var, priority
- Source code integration checks for CLI-01 through CLI-06
- Verifies --both flag unchanged, hooks skipped, prompt options correct
- All 481 tests pass (19 new + 462 existing, no regressions)

* docs(01-01): complete core installer plumbing plan

- Mark Phase 1 and Plan 01-01 as complete in ROADMAP.md
- All 6 requirements (CLI-01 through CLI-06) fulfilled

* gsd: planning

* docs(02): create phase 2 content conversion engine plans

* feat(02-01): add Copilot tool mapping constant and conversion functions

- Add claudeToCopilotTools constant (13 Claude→Copilot tool mappings)
- Add convertCopilotToolName() with mcp__context7__ wildcard handling
- Add convertClaudeToCopilotContent() for CONV-06 (4 path patterns) + CONV-07 (gsd:→gsd-)
- Add convertClaudeCommandToCopilotSkill() for skill frontmatter transformation
- Add convertClaudeAgentToCopilotAgent() with tool dedup and JSON array format
- Export all new functions + constant via GSD_TEST_MODE

* feat(02-01): wire Copilot conversion into install() flow

- Add copyCommandsAsCopilotSkills() for folder-per-skill structure
- Add isCopilot branch in install() skill copy section
- Add isCopilot branch in agent loop with .agent.md rename
- Skip generic path replacement for Copilot (converter handles it)
- Add isCopilot branch in copyWithPathReplacement for .md files
- Add .cjs/.js content transformation for CONV-06/CONV-07
- Export copyCommandsAsCopilotSkills via GSD_TEST_MODE
- CONV-09 not generated (discarded), CONV-10 confirmed working

* docs(02-01): complete content conversion engine plan

- Create 02-01-SUMMARY.md with execution results
- Update STATE.md with Phase 2 position and decisions
- Mark CONV-01 through CONV-10 requirements complete

* test(02-02): add unit tests for Copilot conversion functions

- 16 tests for convertCopilotToolName (all 12 direct mappings, mcp prefix, wildcard, unknown fallback, constant size)
- 8 tests for convertClaudeToCopilotContent (4 path patterns, gsd: conversion, mixed content, no double-replace, passthrough)
- 7 tests for convertClaudeCommandToCopilotSkill (all fields, missing optional fields, CONV-06/07, no frontmatter, agent field)
- 7 tests for convertClaudeAgentToCopilotAgent (dedup, JSON array, field preservation, mcp tools, no tools, CONV-06/07, no frontmatter)

* test(02-02): add integration tests for Copilot skill copy and agent conversion

- copyCommandsAsCopilotSkills produces 31 skill folders with SKILL.md files
- Skill content verified: comma-separated allowed-tools, no YAML multiline, CONV-06/07 applied
- Old skill directories cleaned up on re-run
- gsd-executor agent: 6 tools → 4 after dedup (Write+Edit→edit, Grep+Glob→search)
- gsd-phase-researcher: mcp__context7__* wildcard → io.github.upstash/context7/*
- All 11 agents convert without error, all have frontmatter and tools
- Engine .md and .cjs files: no ~/.claude/ or gsd: references after conversion
- Full suite: 527 tests pass, zero regressions

* docs(02-02): complete Copilot conversion test suite plan

- SUMMARY: 46 new tests covering all conversion functions
- STATE: Phase 02 complete, 3/3 plans done
- ROADMAP: Phase 02 marked complete

* docs(03): research phase domain

* docs(03-instructions-lifecycle): create phase plan

* feat(03-01): add copilot-instructions template and merge/strip functions

- Create get-shit-done/templates/copilot-instructions.md with 5 GSD instructions
- Add GSD_COPILOT_INSTRUCTIONS_MARKER and GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER constants
- Add mergeCopilotInstructions() with 3-case merge (create, replace, append)
- Add stripGsdFromCopilotInstructions() with null-return for GSD-only content

* feat(03-01): wire install, fix uninstall/manifest/patches for Copilot

- Wire mergeCopilotInstructions() into install() before Copilot early return
- Add else-if isCopilot uninstall branch: remove skills/gsd-*/ + clean instructions
- Fix writeManifest() to hash Copilot skills: (isCodex || isCopilot)
- Fix reportLocalPatches() to show /gsd-reapply-patches for Copilot
- Export new functions and constants in GSD_TEST_MODE
- All 527 existing tests pass with zero regressions

* docs(03-01): complete instructions lifecycle plan

- Create 03-01-SUMMARY.md with execution results
- Update STATE.md: Phase 3 Plan 1 position, decisions, session
- Update ROADMAP.md: Phase 03 progress (1/2 plans)
- Mark INST-01, INST-02, LIFE-01, LIFE-02, LIFE-03 complete

* test(03-02): add unit tests for mergeCopilotInstructions and stripGsdFromCopilotInstructions

- 10 new tests: 5 merge cases + 5 strip cases
- Tests cover create/replace/append merge scenarios
- Tests cover null-return, content preservation, no-markers passthrough
- Added beforeEach/afterEach imports for temp dir lifecycle
- Exported writeManifest and reportLocalPatches via GSD_TEST_MODE for Task 2

* test(03-02): add integration tests for uninstall, manifest, and patches Copilot fixes

- 3 uninstall tests: gsd-* skill identification, instructions cleanup, GSD-only deletion
- writeManifest hashes Copilot skills in manifest JSON (proves isCopilot fix)
- reportLocalPatches uses /gsd-reapply-patches for Copilot (dash format)
- reportLocalPatches uses /gsd:reapply-patches for Claude (no regression)
- Full suite: 543 tests pass, 0 failures

* docs(03-02): complete instructions lifecycle tests plan

- SUMMARY.md with 16 new tests documented
- STATE.md updated: Phase 3 complete, 5/5 plans done
- ROADMAP.md updated: Phase 03 marked complete

* docs(04): capture phase context

* docs(04): research phase domain

* docs(04): create phase plan — E2E integration tests for Copilot install/uninstall

* test(04-01): add E2E Copilot full install verification tests

- 9 tests: skills count/structure, agents count/names, instructions markers
- Manifest structure, categories, SHA256 integrity verification
- Engine directory completeness (bin, references, templates, workflows, CHANGELOG, VERSION)
- Uses execFileSync in isolated /tmp dirs with GSD_TEST_MODE stripped from env

* test(04-01): add E2E Copilot uninstall verification tests

- 6 tests: engine removal, instructions removal, GSD skills/agents cleanup
- Preserves non-GSD custom skills and agents after uninstall
- Standalone lifecycle tests for preservation (install → add custom → uninstall → verify)
- Full suite: 558 tests passing, 0 failures

* docs(04-01): complete E2E Copilot install/uninstall integration tests plan

- SUMMARY.md: 15 E2E tests, SHA256 integrity, 558 total tests passing
- STATE.md: Phase 4 complete, 6/6 plans done
- ROADMAP.md: Phase 4 marked complete
- REQUIREMENTS.md: QUAL-01 complete, QUAL-02 out of scope

* fix: use .github paths for Copilot --local instead of ~/.copilot

convertClaudeToCopilotContent() was hardcoded to always map ~/.claude/
and $HOME/.claude/ to ~/.copilot/ and $HOME/.copilot/ regardless of
install mode. For --local installs these should map to .github/ (repo-
relative, no ./ prefix) since Copilot resolves @file references from
the repo root.

Local mode:  ~/.claude/ → .github/  |  $HOME/.claude/ → .github/
Global mode: ~/.claude/ → ~/.copilot/  |  $HOME/.claude/ → $HOME/.copilot/

Added isGlobal parameter to convertClaudeToCopilotContent,
convertClaudeCommandToCopilotSkill, convertClaudeAgentToCopilotAgent,
copyCommandsAsCopilotSkills, and copyWithPathReplacement. All call
sites in install() now pass isGlobal through.

Tests updated to cover both local (default) and global modes.
565 tests passing, 0 failures.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: use double quotes for argument-hint in Copilot skills

The converter was hardcoding single quotes around argument-hint values
in skill frontmatter. This breaks YAML parsing when the value itself
contains single quotes (e.g., "e.g., 'v1.1 Notifications'").

Now uses yamlQuote() (JSON.stringify) which produces double-quoted
strings with proper escaping.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* chore: complete v1.23 milestone — Copilot CLI Support

Archive milestone artifacts, retrospective, and update project docs.

- Archive: v1.23-ROADMAP.md, v1.23-REQUIREMENTS.md, v1.23-MILESTONE-AUDIT.md
- Create: MILESTONES.md, RETROSPECTIVE.md
- Evolve: PROJECT.md (validated reqs, key decisions, shipped context)
- Reorganize: ROADMAP.md (collapsed v1.23, progress table)
- Update: STATE.md (status: completed)
- Delete: REQUIREMENTS.md (archived, fresh for next milestone)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* chore: archive phase directories from v1.23 milestone

* chore: Clean gsd tracking

* fix: update test counts for new upstream commands and agents

Upstream added validate-phase command (32 skills) and nyquist-auditor agent (12 agents).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* chore: Remove copilot instructions

* chore: Improve loop

* gsd: installation

* docs: start milestone v1.24 Autonomous Skill

* docs: internal research for autonomous skill

* docs: define milestone v1.24 requirements

* docs: create milestone v1.24 roadmap (4 phases)

* docs: phase 5 context — skill scaffolding decisions

* docs(5): research phase domain

* docs(05): create phase plan — 2 plans in 2 waves

* docs(phase-5): add validation strategy

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test(05-01): add failing tests for colon-outside-bold regex format

- Test get-phase with **Goal**: format (colon outside bold)
- Test analyze with **Goal**: and **Depends on**: formats
- Test mixed colon-inside and colon-outside bold formats
- All 3 new tests fail confirming the regex bug

* fix(05-01): fix regex for goal/depends_on extraction in roadmap.cjs

- Fix 3 regex patterns to support both **Goal:** and **Goal**: formats
- Pattern: /\*\*Goal(?::\*\*|\*\*:)/ handles colon inside or outside bold
- Fix in both source (get-shit-done/) and runtime (.github/) copies
- All 28 tests pass including 4 new colon-outside-bold tests
- Live verification: all 4 phases return non-null goals from real ROADMAP.md

* feat(05-01): create gsd:autonomous command file

- name: gsd:autonomous with argument-hint: [--from N]
- Sections: objective, execution_context, context, process
- References workflows/autonomous.md and references/ui-brand.md
- Follows exact pattern of new-milestone.md (42 lines)

* docs(05-01): complete roadmap regex fix + autonomous command plan

* feat(05-02): create autonomous workflow with phase discovery and Skill() execution

- Initialize step with milestone-op bootstrap and --from N flag parsing
- Phase discovery via roadmap analyze with incomplete filtering and sort
- Execute step uses Skill() flat calls for discuss/plan/execute (not Task())
- Progress banner: GSD ► AUTONOMOUS ▸ Phase N/T format with bar
- Iterate step re-reads ROADMAP.md after each phase for dynamic phase detection
- Handle blocker step with retry/skip/stop user options

* test(05-02): add autonomous skill generation tests and fix skill count

- Test autonomous.md converts to gsd-autonomous Copilot skill with correct frontmatter
- Test CONV-07 converts gsd: to gsd- in autonomous command body content
- Update skill count from 32 to 33 (autonomous.md added in plan 01)
- All 645 tests pass across full suite

* docs(05-02): complete autonomous workflow plan

* docs: phase 5 complete — update roadmap and state

* docs: phase 6 context — smart discuss decisions

* docs(06): research smart discuss phase domain

* docs(06): create phase plan

* feat(06-01): replace Skill(discuss-phase) with inline smart discuss

- Add <step name="smart_discuss"> with 5 sub-steps: load prior context, scout codebase, analyze phase with infrastructure detection, present proposals per area in tables, write CONTEXT.md
- Rewire execute_phase step 3a: check has_context before/after, reference smart_discuss inline
- Remove Skill(gsd:discuss-phase) call entirely
- Preserve Skill(gsd:plan-phase) and Skill(gsd:execute-phase) calls unchanged
- Update success criteria to mention smart discuss
- Grey area proposals use table format with recommended/alternative columns
- AskUserQuestion offers Accept all, Change QN, Discuss deeper per area
- Infrastructure phases auto-detected and skip to minimal CONTEXT.md
- CONTEXT.md output uses identical XML-wrapped sections as discuss-phase.md

* docs(06-01): complete smart discuss inline logic plan

* docs(07): phase execution chain context — flag strategy, validation routing, error recovery

* docs(07): research phase execution chain domain

* docs(07): create phase plan

* feat(07-01): wire phase execution chain with verification routing

- Add --no-transition flag to execute-phase Skill() call in step 3c
- Replace step 3d transition with VERIFICATION.md-based routing
- Route on passed/human_needed/gaps_found with appropriate user prompts
- Add gap closure cycle with 1-retry limit to prevent infinite loops
- Route execute-phase failures (no VERIFICATION.md) to handle_blocker
- Update success_criteria with all new verification behaviors

* docs(07-01): complete phase execution chain plan

* docs(08): multi-phase orchestration & lifecycle context

* docs(08): research phase domain

* docs(08): create phase plan

* feat(08-01): add lifecycle step, fix progress bar, document smart_discuss

- Add lifecycle step (audit→complete→cleanup) after all phases complete
- Fix progress bar N/T to use phase number/total milestone phases
- Add smart_discuss CTRL-03 compliance documentation note
- Rewire iterate step to route to lifecycle instead of manual banner
- Renumber handle_blocker from step 5 to step 6
- Add 10 lifecycle-related items to success criteria
- File grows from 630 to 743 lines, 6 to 7 named steps

* docs(08-01): complete multi-phase orchestration & lifecycle plan

* docs: v1.24 milestone audit — passed (18/18 requirements)

* chore: complete v1.24 milestone — Autonomous Skill

* chore: archive phase directories from v1.24 milestone

* gsd: clean

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-14 17:41:19 -06:00

1363 lines
55 KiB
JavaScript

/**
* GSD Tools Tests - Copilot Install Plumbing
*
* Tests for Copilot runtime directory resolution, config paths,
* and integration with the multi-runtime installer.
*
* Requirements: CLI-01, CLI-02, CLI-03, CLI-04, CLI-05, CLI-06
*/
process.env.GSD_TEST_MODE = '1';
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert');
const path = require('path');
const os = require('os');
const fs = require('fs');
const {
getDirName,
getGlobalDir,
getConfigDirFromHome,
claudeToCopilotTools,
convertCopilotToolName,
convertClaudeToCopilotContent,
convertClaudeCommandToCopilotSkill,
convertClaudeAgentToCopilotAgent,
copyCommandsAsCopilotSkills,
GSD_COPILOT_INSTRUCTIONS_MARKER,
GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER,
mergeCopilotInstructions,
stripGsdFromCopilotInstructions,
writeManifest,
reportLocalPatches,
} = require('../bin/install.js');
// ─── getDirName ─────────────────────────────────────────────────────────────────
describe('getDirName (Copilot)', () => {
test('returns .github for copilot', () => {
assert.strictEqual(getDirName('copilot'), '.github');
});
test('does not break existing runtimes', () => {
assert.strictEqual(getDirName('claude'), '.claude');
assert.strictEqual(getDirName('opencode'), '.opencode');
assert.strictEqual(getDirName('gemini'), '.gemini');
assert.strictEqual(getDirName('codex'), '.codex');
});
});
// ─── getGlobalDir ───────────────────────────────────────────────────────────────
describe('getGlobalDir (Copilot)', () => {
test('returns ~/.copilot with no env var or explicit dir', () => {
const original = process.env.COPILOT_CONFIG_DIR;
try {
delete process.env.COPILOT_CONFIG_DIR;
const result = getGlobalDir('copilot');
assert.strictEqual(result, path.join(os.homedir(), '.copilot'));
} finally {
if (original !== undefined) {
process.env.COPILOT_CONFIG_DIR = original;
} else {
delete process.env.COPILOT_CONFIG_DIR;
}
}
});
test('returns explicit dir when provided', () => {
const result = getGlobalDir('copilot', '/custom/path');
assert.strictEqual(result, '/custom/path');
});
test('respects COPILOT_CONFIG_DIR env var', () => {
const original = process.env.COPILOT_CONFIG_DIR;
try {
process.env.COPILOT_CONFIG_DIR = '~/custom-copilot';
const result = getGlobalDir('copilot');
assert.strictEqual(result, path.join(os.homedir(), 'custom-copilot'));
} finally {
if (original !== undefined) {
process.env.COPILOT_CONFIG_DIR = original;
} else {
delete process.env.COPILOT_CONFIG_DIR;
}
}
});
test('explicit dir takes priority over COPILOT_CONFIG_DIR', () => {
const original = process.env.COPILOT_CONFIG_DIR;
try {
process.env.COPILOT_CONFIG_DIR = '~/env-path';
const result = getGlobalDir('copilot', '/explicit/path');
assert.strictEqual(result, '/explicit/path');
} finally {
if (original !== undefined) {
process.env.COPILOT_CONFIG_DIR = original;
} else {
delete process.env.COPILOT_CONFIG_DIR;
}
}
});
test('does not break existing runtimes', () => {
assert.strictEqual(getGlobalDir('claude'), path.join(os.homedir(), '.claude'));
assert.strictEqual(getGlobalDir('codex'), path.join(os.homedir(), '.codex'));
});
});
// ─── getConfigDirFromHome ───────────────────────────────────────────────────────
describe('getConfigDirFromHome (Copilot)', () => {
test('returns .github path string for local (isGlobal=false)', () => {
assert.strictEqual(getConfigDirFromHome('copilot', false), "'.github'");
});
test('returns .copilot path string for global (isGlobal=true)', () => {
assert.strictEqual(getConfigDirFromHome('copilot', true), "'.copilot'");
});
test('does not break existing runtimes', () => {
assert.strictEqual(getConfigDirFromHome('opencode', true), "'.config', 'opencode'");
assert.strictEqual(getConfigDirFromHome('claude', true), "'.claude'");
assert.strictEqual(getConfigDirFromHome('gemini', true), "'.gemini'");
assert.strictEqual(getConfigDirFromHome('codex', true), "'.codex'");
});
});
// ─── Source code integration checks ─────────────────────────────────────────────
describe('Source code integration (Copilot)', () => {
const src = fs.readFileSync(path.join(__dirname, '..', 'bin', 'install.js'), 'utf8');
test('CLI-01: --copilot flag parsing exists', () => {
assert.ok(src.includes("args.includes('--copilot')"), '--copilot flag parsed');
});
test('CLI-03: --all array includes copilot', () => {
assert.ok(
src.includes("'claude', 'opencode', 'gemini', 'codex', 'copilot'"),
'--all includes copilot as 5th runtime'
);
});
test('CLI-06: banner text includes Copilot', () => {
assert.ok(src.includes('Copilot by'), 'banner mentions Copilot');
});
test('CLI-06: help text includes --copilot', () => {
assert.ok(src.includes('--copilot'), 'help text has --copilot option');
});
test('CLI-02: promptRuntime has Copilot as option 5', () => {
assert.ok(src.includes("choice === '5'"), 'choice 5 exists');
// Verify choice 5 maps to copilot (the line after choice === '5' should reference copilot)
const choice5Index = src.indexOf("choice === '5'");
const nextLines = src.substring(choice5Index, choice5Index + 100);
assert.ok(nextLines.includes('copilot'), 'choice 5 maps to copilot');
});
test('CLI-02: promptRuntime has All as option 6', () => {
assert.ok(src.includes("choice === '6'"), 'choice 6 exists');
const choice6Index = src.indexOf("choice === '6'");
const nextLines = src.substring(choice6Index, choice6Index + 150);
assert.ok(nextLines.includes('copilot'), 'choice 6 (All) includes copilot');
});
test('isCopilot variable exists in install function', () => {
assert.ok(src.includes("const isCopilot = runtime === 'copilot'"), 'isCopilot defined');
});
test('hooks are skipped for Copilot', () => {
assert.ok(src.includes('!isCodex && !isCopilot'), 'hooks skip check includes copilot');
});
test('--both flag unchanged (still claude + opencode only)', () => {
// Verify the else-if-hasBoth maps to ['claude', 'opencode'] — NOT including copilot
const bothUsage = src.indexOf('} else if (hasBoth)');
assert.ok(bothUsage > 0, 'hasBoth usage exists');
const bothSection = src.substring(bothUsage, bothUsage + 200);
assert.ok(bothSection.includes("['claude', 'opencode']"), '--both maps to claude+opencode');
assert.ok(!bothSection.includes('copilot'), '--both does NOT include copilot');
});
});
// ─── convertCopilotToolName ─────────────────────────────────────────────────────
describe('convertCopilotToolName', () => {
test('maps Read to read', () => {
assert.strictEqual(convertCopilotToolName('Read'), 'read');
});
test('maps Write to edit', () => {
assert.strictEqual(convertCopilotToolName('Write'), 'edit');
});
test('maps Edit to edit (same as Write)', () => {
assert.strictEqual(convertCopilotToolName('Edit'), 'edit');
});
test('maps Bash to execute', () => {
assert.strictEqual(convertCopilotToolName('Bash'), 'execute');
});
test('maps Grep to search', () => {
assert.strictEqual(convertCopilotToolName('Grep'), 'search');
});
test('maps Glob to search (same as Grep)', () => {
assert.strictEqual(convertCopilotToolName('Glob'), 'search');
});
test('maps Task to agent', () => {
assert.strictEqual(convertCopilotToolName('Task'), 'agent');
});
test('maps WebSearch to web', () => {
assert.strictEqual(convertCopilotToolName('WebSearch'), 'web');
});
test('maps WebFetch to web (same as WebSearch)', () => {
assert.strictEqual(convertCopilotToolName('WebFetch'), 'web');
});
test('maps TodoWrite to todo', () => {
assert.strictEqual(convertCopilotToolName('TodoWrite'), 'todo');
});
test('maps AskUserQuestion to ask_user', () => {
assert.strictEqual(convertCopilotToolName('AskUserQuestion'), 'ask_user');
});
test('maps SlashCommand to skill', () => {
assert.strictEqual(convertCopilotToolName('SlashCommand'), 'skill');
});
test('maps mcp__context7__ prefix to io.github.upstash/context7/', () => {
assert.strictEqual(
convertCopilotToolName('mcp__context7__resolve-library-id'),
'io.github.upstash/context7/resolve-library-id'
);
});
test('maps mcp__context7__* wildcard', () => {
assert.strictEqual(
convertCopilotToolName('mcp__context7__*'),
'io.github.upstash/context7/*'
);
});
test('lowercases unknown tools as fallback', () => {
assert.strictEqual(convertCopilotToolName('SomeNewTool'), 'somenewtool');
});
test('mapping constant has 13 entries (12 direct + mcp handled separately)', () => {
assert.strictEqual(Object.keys(claudeToCopilotTools).length, 12);
});
});
// ─── convertClaudeToCopilotContent ──────────────────────────────────────────────
describe('convertClaudeToCopilotContent', () => {
test('replaces ~/.claude/ with .github/ in local mode (default)', () => {
assert.strictEqual(
convertClaudeToCopilotContent('see ~/.claude/foo'),
'see .github/foo'
);
});
test('replaces ~/.claude/ with ~/.copilot/ in global mode', () => {
assert.strictEqual(
convertClaudeToCopilotContent('see ~/.claude/foo', true),
'see ~/.copilot/foo'
);
});
test('replaces ./.claude/ with ./.github/', () => {
assert.strictEqual(
convertClaudeToCopilotContent('at ./.claude/bar'),
'at ./.github/bar'
);
});
test('replaces bare .claude/ with .github/', () => {
assert.strictEqual(
convertClaudeToCopilotContent('in .claude/baz'),
'in .github/baz'
);
});
test('replaces $HOME/.claude/ with .github/ in local mode (default)', () => {
assert.strictEqual(
convertClaudeToCopilotContent('"$HOME/.claude/config"'),
'".github/config"'
);
});
test('replaces $HOME/.claude/ with $HOME/.copilot/ in global mode', () => {
assert.strictEqual(
convertClaudeToCopilotContent('"$HOME/.claude/config"', true),
'"$HOME/.copilot/config"'
);
});
test('converts gsd: to gsd- in command names', () => {
assert.strictEqual(
convertClaudeToCopilotContent('run /gsd:health or gsd:progress'),
'run /gsd-health or gsd-progress'
);
});
test('handles mixed content in local mode', () => {
const input = 'Config at ~/.claude/settings and $HOME/.claude/config.\n' +
'Local at ./.claude/data and .claude/commands.\n' +
'Run gsd:health and /gsd:progress.';
const result = convertClaudeToCopilotContent(input);
assert.ok(result.includes('.github/settings'), 'tilde path converted to local');
assert.ok(!result.includes('$HOME/.claude/'), '$HOME path converted');
assert.ok(result.includes('./.github/data'), 'dot-slash path converted');
assert.ok(result.includes('.github/commands'), 'bare path converted');
assert.ok(result.includes('gsd-health'), 'command name converted');
assert.ok(result.includes('/gsd-progress'), 'slash command converted');
});
test('handles mixed content in global mode', () => {
const input = 'Config at ~/.claude/settings and $HOME/.claude/config.\n' +
'Local at ./.claude/data and .claude/commands.\n' +
'Run gsd:health and /gsd:progress.';
const result = convertClaudeToCopilotContent(input, true);
assert.ok(result.includes('~/.copilot/settings'), 'tilde path converted to global');
assert.ok(result.includes('$HOME/.copilot/config'), '$HOME path converted to global');
assert.ok(result.includes('./.github/data'), 'dot-slash path converted');
assert.ok(result.includes('.github/commands'), 'bare path converted');
});
test('does not double-replace in local mode', () => {
const input = '~/.claude/foo and ./.claude/bar and .claude/baz';
const result = convertClaudeToCopilotContent(input);
assert.ok(!result.includes('.github/.github/'), 'no .github/.github/ artifact');
assert.strictEqual(result, '.github/foo and ./.github/bar and .github/baz');
});
test('does not double-replace in global mode', () => {
const input = '~/.claude/foo and ./.claude/bar and .claude/baz';
const result = convertClaudeToCopilotContent(input, true);
assert.ok(!result.includes('.copilot/.github/'), 'no .copilot/.github/ artifact');
assert.strictEqual(result, '~/.copilot/foo and ./.github/bar and .github/baz');
});
test('preserves content with no matches', () => {
assert.strictEqual(
convertClaudeToCopilotContent('hello world'),
'hello world'
);
});
});
// ─── convertClaudeCommandToCopilotSkill ─────────────────────────────────────────
describe('convertClaudeCommandToCopilotSkill', () => {
test('converts frontmatter with all fields', () => {
const input = `---
name: gsd:health
description: Diagnose planning directory health
argument-hint: [--repair]
allowed-tools:
- Read
- Bash
- Write
- AskUserQuestion
---
Body content here referencing ~/.claude/foo and gsd:health.`;
const result = convertClaudeCommandToCopilotSkill(input, 'gsd-health');
assert.ok(result.startsWith('---\nname: gsd-health\n'), 'name uses param');
assert.ok(result.includes('description: Diagnose planning directory health'), 'description preserved');
assert.ok(result.includes('argument-hint: "[--repair]"'), 'argument-hint double-quoted');
assert.ok(result.includes('allowed-tools: Read, Bash, Write, AskUserQuestion'), 'tools comma-separated');
assert.ok(result.includes('.github/foo'), 'CONV-06 applied to body (local mode default)');
assert.ok(result.includes('gsd-health'), 'CONV-07 applied to body');
assert.ok(!result.includes('gsd:health'), 'no gsd: references remain');
});
test('handles skill without allowed-tools', () => {
const input = `---
name: gsd:help
description: Show available GSD commands
---
Help content.`;
const result = convertClaudeCommandToCopilotSkill(input, 'gsd-help');
assert.ok(result.includes('name: gsd-help'), 'name set');
assert.ok(result.includes('description: Show available GSD commands'), 'description preserved');
assert.ok(!result.includes('allowed-tools:'), 'no allowed-tools line');
});
test('handles skill without argument-hint', () => {
const input = `---
name: gsd:progress
description: Show project progress
allowed-tools:
- Read
- Bash
---
Progress body.`;
const result = convertClaudeCommandToCopilotSkill(input, 'gsd-progress');
assert.ok(!result.includes('argument-hint:'), 'no argument-hint line');
assert.ok(result.includes('allowed-tools: Read, Bash'), 'tools present');
});
test('argument-hint with inner single quotes uses double-quote YAML delimiter', () => {
const input = `---
name: gsd:new-milestone
description: Start milestone
argument-hint: "[milestone name, e.g., 'v1.1 Notifications']"
allowed-tools:
- Read
---
Body.`;
const result = convertClaudeCommandToCopilotSkill(input, 'gsd-new-milestone');
assert.ok(result.includes(`argument-hint: "[milestone name, e.g., 'v1.1 Notifications']"`), 'inner single quotes preserved with double-quote delimiter');
});
test('applies CONV-06 path conversion to body (local mode)', () => {
const input = `---
name: gsd:test
description: Test skill
---
Check ~/.claude/settings and ./.claude/local and $HOME/.claude/global.`;
const result = convertClaudeCommandToCopilotSkill(input, 'gsd-test');
assert.ok(result.includes('.github/settings'), 'tilde path converted to local');
assert.ok(result.includes('./.github/local'), 'dot-slash path converted');
assert.ok(result.includes('.github/global'), '$HOME path converted to local');
});
test('applies CONV-06 path conversion to body (global mode)', () => {
const input = `---
name: gsd:test
description: Test skill
---
Check ~/.claude/settings and ./.claude/local and $HOME/.claude/global.`;
const result = convertClaudeCommandToCopilotSkill(input, 'gsd-test', true);
assert.ok(result.includes('~/.copilot/settings'), 'tilde path converted to global');
assert.ok(result.includes('./.github/local'), 'dot-slash path converted');
assert.ok(result.includes('$HOME/.copilot/global'), '$HOME path converted to global');
});
test('applies CONV-07 command name conversion to body', () => {
const input = `---
name: gsd:test
description: Test skill
---
Run gsd:health and /gsd:progress for diagnostics.`;
const result = convertClaudeCommandToCopilotSkill(input, 'gsd-test');
assert.ok(result.includes('gsd-health'), 'gsd:health converted');
assert.ok(result.includes('/gsd-progress'), '/gsd:progress converted');
assert.ok(!result.match(/gsd:[a-z]/), 'no gsd: command refs remain');
});
test('handles content without frontmatter (local mode)', () => {
const input = 'Just some markdown with ~/.claude/path and gsd:health.';
const result = convertClaudeCommandToCopilotSkill(input, 'gsd-test');
assert.ok(result.includes('.github/path'), 'CONV-06 applied (local)');
assert.ok(result.includes('gsd-health'), 'CONV-07 applied');
assert.ok(!result.includes('---'), 'no frontmatter added');
});
test('preserves agent field in frontmatter', () => {
const input = `---
name: gsd:execute-phase
description: Execute a phase
agent: gsd-planner
allowed-tools:
- Read
- Bash
---
Body.`;
const result = convertClaudeCommandToCopilotSkill(input, 'gsd-execute-phase');
assert.ok(result.includes('agent: gsd-planner'), 'agent field preserved');
});
});
// ─── convertClaudeAgentToCopilotAgent ───────────────────────────────────────────
describe('convertClaudeAgentToCopilotAgent', () => {
test('maps and deduplicates tools', () => {
const input = `---
name: gsd-executor
description: Executes GSD plans
tools: Read, Write, Edit, Bash, Grep, Glob
color: yellow
---
Agent body.`;
const result = convertClaudeAgentToCopilotAgent(input);
assert.ok(result.includes("tools: ['read', 'edit', 'execute', 'search']"), 'tools mapped and deduped');
});
test('formats tools as JSON array', () => {
const input = `---
name: gsd-test
description: Test agent
tools: Read, Bash
---
Body.`;
const result = convertClaudeAgentToCopilotAgent(input);
assert.ok(result.match(/tools: \['[a-z_]+'(, '[a-z_]+')*\]/), 'tools formatted as JSON array');
});
test('preserves name description and color', () => {
const input = `---
name: gsd-executor
description: Executes GSD plans with atomic commits
tools: Read, Bash
color: yellow
---
Body.`;
const result = convertClaudeAgentToCopilotAgent(input);
assert.ok(result.includes('name: gsd-executor'), 'name preserved');
assert.ok(result.includes('description: Executes GSD plans with atomic commits'), 'description preserved');
assert.ok(result.includes('color: yellow'), 'color preserved');
});
test('handles mcp__context7__ tools', () => {
const input = `---
name: gsd-researcher
description: Research agent
tools: Read, Bash, mcp__context7__resolve-library-id
color: cyan
---
Body.`;
const result = convertClaudeAgentToCopilotAgent(input);
assert.ok(result.includes('io.github.upstash/context7/resolve-library-id'), 'mcp tool mapped');
assert.ok(!result.includes('mcp__context7__'), 'no mcp__ prefix remains');
});
test('handles agent with no tools field', () => {
const input = `---
name: gsd-empty
description: Empty agent
color: green
---
Body.`;
const result = convertClaudeAgentToCopilotAgent(input);
assert.ok(result.includes('tools: []'), 'missing tools produces []');
});
test('applies CONV-06 and CONV-07 to body (local mode)', () => {
const input = `---
name: gsd-test
description: Test
tools: Read
---
Check ~/.claude/settings and run gsd:health.`;
const result = convertClaudeAgentToCopilotAgent(input);
assert.ok(result.includes('.github/settings'), 'CONV-06 applied (local)');
assert.ok(result.includes('gsd-health'), 'CONV-07 applied');
assert.ok(!result.includes('~/.claude/'), 'no ~/.claude/ remains');
assert.ok(!result.match(/gsd:[a-z]/), 'no gsd: command refs remain');
});
test('applies CONV-06 and CONV-07 to body (global mode)', () => {
const input = `---
name: gsd-test
description: Test
tools: Read
---
Check ~/.claude/settings and run gsd:health.`;
const result = convertClaudeAgentToCopilotAgent(input, true);
assert.ok(result.includes('~/.copilot/settings'), 'CONV-06 applied (global)');
assert.ok(result.includes('gsd-health'), 'CONV-07 applied');
});
test('handles content without frontmatter (local mode)', () => {
const input = 'Just markdown with ~/.claude/path and gsd:test.';
const result = convertClaudeAgentToCopilotAgent(input);
assert.ok(result.includes('.github/path'), 'CONV-06 applied (local)');
assert.ok(result.includes('gsd-test'), 'CONV-07 applied');
assert.ok(!result.includes('---'), 'no frontmatter added');
});
});
// ─── copyCommandsAsCopilotSkills (integration) ─────────────────────────────────
describe('copyCommandsAsCopilotSkills', () => {
const srcDir = path.join(__dirname, '..', 'commands', 'gsd');
test('creates skill folders from source commands', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-copilot-skills-'));
try {
copyCommandsAsCopilotSkills(srcDir, tempDir, 'gsd');
// Check specific folders exist
assert.ok(fs.existsSync(path.join(tempDir, 'gsd-health')), 'gsd-health folder exists');
assert.ok(fs.existsSync(path.join(tempDir, 'gsd-health', 'SKILL.md')), 'gsd-health/SKILL.md exists');
assert.ok(fs.existsSync(path.join(tempDir, 'gsd-help')), 'gsd-help folder exists');
assert.ok(fs.existsSync(path.join(tempDir, 'gsd-progress')), 'gsd-progress folder exists');
// Count gsd-* directories — should be 31
const dirs = fs.readdirSync(tempDir, { withFileTypes: true })
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
assert.strictEqual(dirs.length, 33, `expected 33 skill folders, got ${dirs.length}`);
} finally {
fs.rmSync(tempDir, { recursive: true });
}
});
test('skill content has Copilot frontmatter format', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-copilot-skills-'));
try {
copyCommandsAsCopilotSkills(srcDir, tempDir, 'gsd');
const skillContent = fs.readFileSync(path.join(tempDir, 'gsd-health', 'SKILL.md'), 'utf8');
// Frontmatter format checks
assert.ok(skillContent.startsWith('---\nname: gsd-health\n'), 'starts with name: gsd-health');
assert.ok(skillContent.includes('allowed-tools: Read, Bash, Write, AskUserQuestion'),
'allowed-tools is comma-separated');
assert.ok(!skillContent.includes('allowed-tools:\n -'), 'NOT YAML multiline format');
// CONV-06/07 applied
assert.ok(!skillContent.includes('~/.claude/'), 'no ~/.claude/ references');
assert.ok(!skillContent.match(/gsd:[a-z]/), 'no gsd: command references');
} finally {
fs.rmSync(tempDir, { recursive: true });
}
});
test('generates gsd-autonomous skill from autonomous.md command', () => {
// Fail-fast: source command must exist
const srcFile = path.join(srcDir, 'autonomous.md');
assert.ok(fs.existsSync(srcFile), 'commands/gsd/autonomous.md must exist as source');
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-copilot-skills-'));
try {
copyCommandsAsCopilotSkills(srcDir, tempDir, 'gsd');
// Skill folder and file created
assert.ok(fs.existsSync(path.join(tempDir, 'gsd-autonomous')), 'gsd-autonomous folder exists');
assert.ok(fs.existsSync(path.join(tempDir, 'gsd-autonomous', 'SKILL.md')), 'gsd-autonomous/SKILL.md exists');
const skillContent = fs.readFileSync(path.join(tempDir, 'gsd-autonomous', 'SKILL.md'), 'utf8');
// Frontmatter: name converted from gsd:autonomous to gsd-autonomous
assert.ok(skillContent.startsWith('---\nname: gsd-autonomous\n'), 'name is gsd-autonomous');
assert.ok(skillContent.includes('description: Run all remaining phases autonomously'),
'description preserved');
// argument-hint present and double-quoted
assert.ok(skillContent.includes('argument-hint: "[--from N]"'), 'argument-hint present and quoted');
// allowed-tools comma-separated
assert.ok(skillContent.includes('allowed-tools: Read, Write, Bash, Glob, Grep, AskUserQuestion, Task'),
'allowed-tools is comma-separated');
// No Claude-format remnants
assert.ok(!skillContent.includes('allowed-tools:\n -'), 'NOT YAML multiline format');
assert.ok(!skillContent.includes('~/.claude/'), 'no ~/.claude/ references in body');
} finally {
fs.rmSync(tempDir, { recursive: true });
}
});
test('autonomous skill body converts gsd: to gsd- (CONV-07)', () => {
// Use convertClaudeToCopilotContent directly on the command body content
const srcContent = fs.readFileSync(path.join(srcDir, 'autonomous.md'), 'utf8');
const result = convertClaudeToCopilotContent(srcContent);
// gsd:autonomous references should be converted to gsd-autonomous
assert.ok(!result.match(/gsd:[a-z]/), 'no gsd: command references remain after conversion');
// Specific: gsd:discuss-phase, gsd:plan-phase, gsd:execute-phase mentioned in body
// The body references gsd-tools.cjs (not a gsd: command) — those should be unaffected
// But /gsd:autonomous → /gsd-autonomous, gsd:discuss-phase → gsd-discuss-phase etc.
if (srcContent.includes('gsd:autonomous')) {
assert.ok(result.includes('gsd-autonomous'), 'gsd:autonomous converted to gsd-autonomous');
}
// Path conversion: ~/.claude/ → .github/
assert.ok(!result.includes('~/.claude/'), 'no ~/.claude/ paths remain');
});
test('cleans up old skill directories on re-run', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-copilot-skills-'));
try {
// Create a fake old directory
fs.mkdirSync(path.join(tempDir, 'gsd-fake-old'), { recursive: true });
fs.writeFileSync(path.join(tempDir, 'gsd-fake-old', 'SKILL.md'), 'old');
assert.ok(fs.existsSync(path.join(tempDir, 'gsd-fake-old')), 'fake old dir exists before');
// Run copy — should clean up old dirs
copyCommandsAsCopilotSkills(srcDir, tempDir, 'gsd');
assert.ok(!fs.existsSync(path.join(tempDir, 'gsd-fake-old')), 'fake old dir removed');
assert.ok(fs.existsSync(path.join(tempDir, 'gsd-health')), 'real dirs still exist');
} finally {
fs.rmSync(tempDir, { recursive: true });
}
});
});
// ─── Copilot agent conversion - real files ──────────────────────────────────────
describe('Copilot agent conversion - real files', () => {
const agentsSrc = path.join(__dirname, '..', 'agents');
test('converts gsd-executor agent correctly', () => {
const content = fs.readFileSync(path.join(agentsSrc, 'gsd-executor.md'), 'utf8');
const result = convertClaudeAgentToCopilotAgent(content);
assert.ok(result.startsWith('---\nname: gsd-executor\n'), 'starts with correct name');
// 6 Claude tools (Read, Write, Edit, Bash, Grep, Glob) → 4 after dedup
assert.ok(result.includes("tools: ['read', 'edit', 'execute', 'search']"),
'tools mapped and deduplicated (6→4)');
assert.ok(result.includes('color: yellow'), 'color preserved');
assert.ok(!result.includes('~/.claude/'), 'no ~/.claude/ in body');
});
test('converts agent with mcp wildcard tools correctly', () => {
const content = fs.readFileSync(path.join(agentsSrc, 'gsd-phase-researcher.md'), 'utf8');
const result = convertClaudeAgentToCopilotAgent(content);
const toolsLine = result.split('\n').find(l => l.startsWith('tools:'));
assert.ok(toolsLine.includes('io.github.upstash/context7/*'), 'mcp wildcard mapped in tools');
assert.ok(!toolsLine.includes('mcp__context7__'), 'no mcp__ prefix in tools line');
assert.ok(toolsLine.includes("'web'"), 'WebSearch/WebFetch deduplicated to web');
assert.ok(toolsLine.includes("'read'"), 'Read mapped');
});
test('all 12 agents convert without error', () => {
const agents = fs.readdirSync(agentsSrc)
.filter(f => f.startsWith('gsd-') && f.endsWith('.md'));
assert.strictEqual(agents.length, 12, `expected 12 agents, got ${agents.length}`);
for (const agentFile of agents) {
const content = fs.readFileSync(path.join(agentsSrc, agentFile), 'utf8');
const result = convertClaudeAgentToCopilotAgent(content);
assert.ok(result.startsWith('---\n'), `${agentFile} should have frontmatter`);
assert.ok(result.includes('tools:'), `${agentFile} should have tools field`);
assert.ok(!result.includes('~/.claude/'), `${agentFile} should not contain ~/.claude/`);
}
});
});
// ─── Copilot content conversion - engine files ─────────────────────────────────
describe('Copilot content conversion - engine files', () => {
test('converts engine .md files correctly (local mode default)', () => {
const healthMd = fs.readFileSync(
path.join(__dirname, '..', 'get-shit-done', 'workflows', 'health.md'), 'utf8'
);
const result = convertClaudeToCopilotContent(healthMd);
assert.ok(!result.includes('~/.claude/'), 'no ~/.claude/ references remain');
assert.ok(!result.includes('$HOME/.claude/'), 'no $HOME/.claude/ references remain');
assert.ok(!result.match(/\/gsd:[a-z]/), 'no /gsd: command references remain');
assert.ok(!result.match(/(?<!\/)gsd:[a-z]/), 'no bare gsd: command references remain');
// Local mode: ~ and $HOME resolve to .github (repo-relative, no ./ prefix)
assert.ok(result.includes('.github/'), 'paths converted to .github for local');
assert.ok(result.includes('gsd-health'), 'command name converted');
});
test('converts engine .md files correctly (global mode)', () => {
const healthMd = fs.readFileSync(
path.join(__dirname, '..', 'get-shit-done', 'workflows', 'health.md'), 'utf8'
);
const result = convertClaudeToCopilotContent(healthMd, true);
assert.ok(!result.includes('~/.claude/'), 'no ~/.claude/ references remain');
assert.ok(!result.includes('$HOME/.claude/'), 'no $HOME/.claude/ references remain');
// Global mode: ~ and $HOME resolve to .copilot
if (healthMd.includes('$HOME/.claude/')) {
assert.ok(result.includes('$HOME/.copilot/'), '$HOME path converted to .copilot');
}
assert.ok(result.includes('gsd-health'), 'command name converted');
});
test('converts engine .cjs files correctly', () => {
const verifyCjs = fs.readFileSync(
path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'verify.cjs'), 'utf8'
);
const result = convertClaudeToCopilotContent(verifyCjs);
assert.ok(!result.match(/gsd:[a-z]/), 'no gsd: references remain');
assert.ok(result.includes('gsd-new-project'), 'gsd:new-project converted');
assert.ok(result.includes('gsd-health'), 'gsd:health converted');
});
});
// ─── Copilot instructions merge/strip ──────────────────────────────────────────
describe('Copilot instructions merge/strip', () => {
let tmpDir;
const gsdContent = '- Follow project conventions\n- Use structured workflows';
function makeGsdBlock(content) {
return GSD_COPILOT_INSTRUCTIONS_MARKER + '\n' + content.trim() + '\n' + GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER;
}
describe('mergeCopilotInstructions', () => {
let tmpMergeDir;
beforeEach(() => {
tmpMergeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-merge-'));
});
afterEach(() => {
fs.rmSync(tmpMergeDir, { recursive: true, force: true });
});
test('creates file from scratch when none exists', () => {
const filePath = path.join(tmpMergeDir, 'copilot-instructions.md');
mergeCopilotInstructions(filePath, gsdContent);
assert.ok(fs.existsSync(filePath), 'file was created');
const result = fs.readFileSync(filePath, 'utf8');
assert.ok(result.includes(GSD_COPILOT_INSTRUCTIONS_MARKER), 'has opening marker');
assert.ok(result.includes(GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER), 'has closing marker');
assert.ok(result.includes('Follow project conventions'), 'has GSD content');
});
test('replaces GSD section when both markers present', () => {
const filePath = path.join(tmpMergeDir, 'copilot-instructions.md');
const oldContent = '# User Setup\n\n' +
makeGsdBlock('- Old GSD content') +
'\n\n# User Notes\n';
fs.writeFileSync(filePath, oldContent);
mergeCopilotInstructions(filePath, gsdContent);
const result = fs.readFileSync(filePath, 'utf8');
assert.ok(result.includes('# User Setup'), 'user content before preserved');
assert.ok(result.includes('# User Notes'), 'user content after preserved');
assert.ok(!result.includes('Old GSD content'), 'old GSD content removed');
assert.ok(result.includes('Follow project conventions'), 'new GSD content inserted');
});
test('appends to existing file when no markers present', () => {
const filePath = path.join(tmpMergeDir, 'copilot-instructions.md');
const userContent = '# My Custom Instructions\n\nDo things my way.\n';
fs.writeFileSync(filePath, userContent);
mergeCopilotInstructions(filePath, gsdContent);
const result = fs.readFileSync(filePath, 'utf8');
assert.ok(result.includes('# My Custom Instructions'), 'original content preserved');
assert.ok(result.includes('Do things my way.'), 'original text preserved');
assert.ok(result.includes(GSD_COPILOT_INSTRUCTIONS_MARKER), 'GSD block appended');
assert.ok(result.includes('Follow project conventions'), 'GSD content appended');
// Verify separator exists
assert.ok(result.includes('Do things my way.\n\n' + GSD_COPILOT_INSTRUCTIONS_MARKER),
'double newline separator before GSD block');
});
test('handles file that is GSD-only (re-creates cleanly)', () => {
const filePath = path.join(tmpMergeDir, 'copilot-instructions.md');
const gsdOnly = makeGsdBlock('- Old instructions') + '\n';
fs.writeFileSync(filePath, gsdOnly);
const newContent = '- Updated instructions';
mergeCopilotInstructions(filePath, newContent);
const result = fs.readFileSync(filePath, 'utf8');
assert.ok(!result.includes('Old instructions'), 'old content removed');
assert.ok(result.includes('Updated instructions'), 'new content present');
assert.ok(result.includes(GSD_COPILOT_INSTRUCTIONS_MARKER), 'has opening marker');
assert.ok(result.includes(GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER), 'has closing marker');
});
test('preserves user content before and after markers', () => {
const filePath = path.join(tmpMergeDir, 'copilot-instructions.md');
const content = '# My Setup\n\n' +
makeGsdBlock('- old content') +
'\n\n# My Notes\n';
fs.writeFileSync(filePath, content);
mergeCopilotInstructions(filePath, gsdContent);
const result = fs.readFileSync(filePath, 'utf8');
assert.ok(result.includes('# My Setup'), 'content before markers preserved');
assert.ok(result.includes('# My Notes'), 'content after markers preserved');
assert.ok(result.includes('Follow project conventions'), 'new GSD content between markers');
// Verify ordering: before → GSD → after
const setupIdx = result.indexOf('# My Setup');
const markerIdx = result.indexOf(GSD_COPILOT_INSTRUCTIONS_MARKER);
const notesIdx = result.indexOf('# My Notes');
assert.ok(setupIdx < markerIdx, 'user setup comes before GSD block');
assert.ok(markerIdx < notesIdx, 'GSD block comes before user notes');
});
});
describe('stripGsdFromCopilotInstructions', () => {
test('returns null when content is GSD-only', () => {
const content = makeGsdBlock('- GSD instructions only') + '\n';
const result = stripGsdFromCopilotInstructions(content);
assert.strictEqual(result, null, 'returns null for GSD-only content');
});
test('returns cleaned content when user content exists before markers', () => {
const content = '# My Setup\n\nCustom rules here.\n\n' +
makeGsdBlock('- GSD stuff') + '\n';
const result = stripGsdFromCopilotInstructions(content);
assert.ok(result !== null, 'does not return null');
assert.ok(result.includes('# My Setup'), 'user content preserved');
assert.ok(result.includes('Custom rules here.'), 'user text preserved');
assert.ok(!result.includes(GSD_COPILOT_INSTRUCTIONS_MARKER), 'opening marker removed');
assert.ok(!result.includes(GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER), 'closing marker removed');
assert.ok(!result.includes('GSD stuff'), 'GSD content removed');
});
test('returns cleaned content when user content exists after markers', () => {
const content = makeGsdBlock('- GSD stuff') + '\n\n# My Notes\n\nPersonal notes.\n';
const result = stripGsdFromCopilotInstructions(content);
assert.ok(result !== null, 'does not return null');
assert.ok(result.includes('# My Notes'), 'user content after preserved');
assert.ok(result.includes('Personal notes.'), 'user text after preserved');
assert.ok(!result.includes(GSD_COPILOT_INSTRUCTIONS_MARKER), 'opening marker removed');
assert.ok(!result.includes('GSD stuff'), 'GSD content removed');
});
test('returns cleaned content preserving both before and after', () => {
const content = '# Before\n\n' + makeGsdBlock('- GSD middle') + '\n\n# After\n';
const result = stripGsdFromCopilotInstructions(content);
assert.ok(result !== null, 'does not return null');
assert.ok(result.includes('# Before'), 'content before preserved');
assert.ok(result.includes('# After'), 'content after preserved');
assert.ok(!result.includes('GSD middle'), 'GSD content removed');
assert.ok(!result.includes(GSD_COPILOT_INSTRUCTIONS_MARKER), 'markers removed');
});
test('returns original content when no markers found', () => {
const content = '# Just user content\n\nNo GSD markers here.\n';
const result = stripGsdFromCopilotInstructions(content);
assert.strictEqual(result, content, 'returns content unchanged');
});
});
});
// ─── Copilot uninstall skill removal ───────────────────────────────────────────
describe('Copilot uninstall skill removal', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uninstall-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('identifies gsd-* skill directories for removal', () => {
// Create Copilot-like skills directory structure
const skillsDir = path.join(tmpDir, 'skills');
fs.mkdirSync(path.join(skillsDir, 'gsd-foo'), { recursive: true });
fs.writeFileSync(path.join(skillsDir, 'gsd-foo', 'SKILL.md'), '# Foo');
fs.mkdirSync(path.join(skillsDir, 'gsd-bar'), { recursive: true });
fs.writeFileSync(path.join(skillsDir, 'gsd-bar', 'SKILL.md'), '# Bar');
fs.mkdirSync(path.join(skillsDir, 'custom-skill'), { recursive: true });
fs.writeFileSync(path.join(skillsDir, 'custom-skill', 'SKILL.md'), '# Custom');
// Test the pattern: read skills, filter gsd-* entries
const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
const gsdSkills = entries
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'))
.map(e => e.name);
const nonGsdSkills = entries
.filter(e => e.isDirectory() && !e.name.startsWith('gsd-'))
.map(e => e.name);
assert.deepStrictEqual(gsdSkills.sort(), ['gsd-bar', 'gsd-foo'], 'identifies gsd-* skills');
assert.deepStrictEqual(nonGsdSkills, ['custom-skill'], 'preserves non-gsd skills');
});
test('cleans GSD section from copilot-instructions.md on uninstall', () => {
const content = '# My Setup\n\nMy custom rules.\n\n' +
GSD_COPILOT_INSTRUCTIONS_MARKER + '\n' +
'- GSD managed content\n' +
GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER + '\n';
const result = stripGsdFromCopilotInstructions(content);
assert.ok(result !== null, 'does not return null when user content exists');
assert.ok(result.includes('# My Setup'), 'user content preserved');
assert.ok(result.includes('My custom rules.'), 'user text preserved');
assert.ok(!result.includes('GSD managed content'), 'GSD content removed');
assert.ok(!result.includes(GSD_COPILOT_INSTRUCTIONS_MARKER), 'markers removed');
});
test('deletes copilot-instructions.md when GSD-only on uninstall', () => {
const content = GSD_COPILOT_INSTRUCTIONS_MARKER + '\n' +
'- Only GSD content\n' +
GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER + '\n';
const result = stripGsdFromCopilotInstructions(content);
assert.strictEqual(result, null, 'returns null signaling file deletion');
});
});
// ─── Copilot manifest and patches fixes ────────────────────────────────────────
describe('Copilot manifest and patches fixes', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-manifest-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('writeManifest hashes skills for Copilot runtime', () => {
// Create minimal get-shit-done dir (required by writeManifest)
const gsdDir = path.join(tmpDir, 'get-shit-done', 'bin');
fs.mkdirSync(gsdDir, { recursive: true });
fs.writeFileSync(path.join(gsdDir, 'verify.cjs'), '// verify stub');
// Create Copilot skills directory
const skillDir = path.join(tmpDir, 'skills', 'gsd-test');
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# Test Skill\n\nA test skill.');
const manifest = writeManifest(tmpDir, 'copilot');
// Check manifest file was written
const manifestPath = path.join(tmpDir, 'gsd-file-manifest.json');
assert.ok(fs.existsSync(manifestPath), 'manifest file created');
// Read and verify skills are hashed
const data = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const skillKey = 'skills/gsd-test/SKILL.md';
assert.ok(data.files[skillKey], 'skill file hashed in manifest');
assert.ok(typeof data.files[skillKey] === 'string', 'hash is a string');
assert.ok(data.files[skillKey].length === 64, 'hash is SHA-256 (64 hex chars)');
});
test('reportLocalPatches shows /gsd-reapply-patches for Copilot', () => {
// Create patches directory with metadata
const patchesDir = path.join(tmpDir, 'gsd-local-patches');
fs.mkdirSync(patchesDir, { recursive: true });
fs.writeFileSync(path.join(patchesDir, 'backup-meta.json'), JSON.stringify({
from_version: '1.0',
files: ['skills/gsd-test/SKILL.md']
}));
// Capture console output
const logs = [];
const originalLog = console.log;
console.log = (...args) => logs.push(args.join(' '));
try {
const result = reportLocalPatches(tmpDir, 'copilot');
assert.ok(result.length > 0, 'returns patched files list');
const output = logs.join('\n');
assert.ok(output.includes('/gsd-reapply-patches'), 'uses dash format for Copilot');
assert.ok(!output.includes('/gsd:reapply-patches'), 'does not use colon format');
} finally {
console.log = originalLog;
}
});
test('reportLocalPatches shows /gsd:reapply-patches for Claude (unchanged)', () => {
// Create patches directory with metadata
const patchesDir = path.join(tmpDir, 'gsd-local-patches');
fs.mkdirSync(patchesDir, { recursive: true });
fs.writeFileSync(path.join(patchesDir, 'backup-meta.json'), JSON.stringify({
from_version: '1.0',
files: ['get-shit-done/bin/verify.cjs']
}));
// Capture console output
const logs = [];
const originalLog = console.log;
console.log = (...args) => logs.push(args.join(' '));
try {
const result = reportLocalPatches(tmpDir, 'claude');
assert.ok(result.length > 0, 'returns patched files list');
const output = logs.join('\n');
assert.ok(output.includes('/gsd:reapply-patches'), 'uses colon format for Claude');
} finally {
console.log = originalLog;
}
});
});
// ============================================================================
// E2E Integration Tests — Copilot Install & Uninstall
// ============================================================================
const { execFileSync } = require('child_process');
const crypto = require('crypto');
const INSTALL_PATH = path.join(__dirname, '..', 'bin', 'install.js');
const EXPECTED_SKILLS = 33;
const EXPECTED_AGENTS = 12;
function runCopilotInstall(cwd) {
const env = { ...process.env };
delete env.GSD_TEST_MODE;
return execFileSync(process.execPath, [INSTALL_PATH, '--copilot', '--local'], {
cwd,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
env,
});
}
function runCopilotUninstall(cwd) {
const env = { ...process.env };
delete env.GSD_TEST_MODE;
return execFileSync(process.execPath, [INSTALL_PATH, '--copilot', '--local', '--uninstall'], {
cwd,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
env,
});
}
describe('E2E: Copilot full install verification', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-e2e-'));
runCopilotInstall(tmpDir);
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('installs expected number of skill directories', () => {
const skillsDir = path.join(tmpDir, '.github', 'skills');
const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
const gsdSkills = entries.filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
assert.strictEqual(gsdSkills.length, EXPECTED_SKILLS,
`Expected ${EXPECTED_SKILLS} skill directories, got ${gsdSkills.length}`);
});
test('each skill directory contains SKILL.md', () => {
const skillsDir = path.join(tmpDir, '.github', 'skills');
const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
const gsdSkills = entries.filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
for (const skill of gsdSkills) {
const skillMdPath = path.join(skillsDir, skill.name, 'SKILL.md');
assert.ok(fs.existsSync(skillMdPath),
`Missing SKILL.md in ${skill.name}`);
}
});
test('installs expected number of agent files', () => {
const agentsDir = path.join(tmpDir, '.github', 'agents');
const files = fs.readdirSync(agentsDir);
const gsdAgents = files.filter(f => f.startsWith('gsd-') && f.endsWith('.agent.md'));
assert.strictEqual(gsdAgents.length, EXPECTED_AGENTS,
`Expected ${EXPECTED_AGENTS} agent files, got ${gsdAgents.length}`);
});
test('installs all expected agent files', () => {
const agentsDir = path.join(tmpDir, '.github', 'agents');
const files = fs.readdirSync(agentsDir);
const gsdAgents = files.filter(f => f.startsWith('gsd-') && f.endsWith('.agent.md')).sort();
const expected = [
'gsd-codebase-mapper.agent.md',
'gsd-debugger.agent.md',
'gsd-executor.agent.md',
'gsd-integration-checker.agent.md',
'gsd-nyquist-auditor.agent.md',
'gsd-phase-researcher.agent.md',
'gsd-plan-checker.agent.md',
'gsd-planner.agent.md',
'gsd-project-researcher.agent.md',
'gsd-research-synthesizer.agent.md',
'gsd-roadmapper.agent.md',
'gsd-verifier.agent.md',
].sort();
assert.deepStrictEqual(gsdAgents, expected);
});
test('generates copilot-instructions.md with GSD markers', () => {
const instrPath = path.join(tmpDir, '.github', 'copilot-instructions.md');
assert.ok(fs.existsSync(instrPath), 'copilot-instructions.md should exist');
const content = fs.readFileSync(instrPath, 'utf-8');
assert.ok(content.includes('<!-- GSD Configuration'),
'Should contain GSD Configuration open marker');
assert.ok(content.includes('<!-- /GSD Configuration -->'),
'Should contain GSD Configuration close marker');
});
test('creates manifest with correct structure', () => {
const manifestPath = path.join(tmpDir, '.github', 'gsd-file-manifest.json');
assert.ok(fs.existsSync(manifestPath), 'gsd-file-manifest.json should exist');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
assert.ok(manifest.version, 'manifest should have version');
assert.ok(manifest.timestamp, 'manifest should have timestamp');
assert.ok(manifest.files && typeof manifest.files === 'object',
'manifest should have files object');
assert.ok(Object.keys(manifest.files).length > 0,
'manifest files should not be empty');
});
test('manifest contains expected file categories', () => {
const manifestPath = path.join(tmpDir, '.github', 'gsd-file-manifest.json');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
const keys = Object.keys(manifest.files);
const skillEntries = keys.filter(k => k.startsWith('skills/'));
const agentEntries = keys.filter(k => k.startsWith('agents/'));
const engineEntries = keys.filter(k => k.startsWith('get-shit-done/'));
assert.strictEqual(skillEntries.length, EXPECTED_SKILLS,
`Expected ${EXPECTED_SKILLS} skill manifest entries, got ${skillEntries.length}`);
assert.strictEqual(agentEntries.length, EXPECTED_AGENTS,
`Expected ${EXPECTED_AGENTS} agent manifest entries, got ${agentEntries.length}`);
assert.ok(engineEntries.length > 0,
'Should have get-shit-done/ engine manifest entries');
});
test('manifest SHA256 hashes match actual file contents', () => {
const manifestPath = path.join(tmpDir, '.github', 'gsd-file-manifest.json');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
const githubDir = path.join(tmpDir, '.github');
for (const [relPath, expectedHash] of Object.entries(manifest.files)) {
const filePath = path.join(githubDir, relPath);
assert.ok(fs.existsSync(filePath),
`Manifest references ${relPath} but file does not exist`);
const content = fs.readFileSync(filePath);
const actualHash = crypto.createHash('sha256').update(content).digest('hex');
assert.strictEqual(actualHash, expectedHash,
`SHA256 mismatch for ${relPath}: expected ${expectedHash}, got ${actualHash}`);
}
});
test('engine directory contains required subdirectories and files', () => {
const engineDir = path.join(tmpDir, '.github', 'get-shit-done');
const requiredDirs = ['bin', 'references', 'templates', 'workflows'];
const requiredFiles = ['CHANGELOG.md', 'VERSION'];
for (const dir of requiredDirs) {
const dirPath = path.join(engineDir, dir);
assert.ok(fs.existsSync(dirPath) && fs.statSync(dirPath).isDirectory(),
`Engine should contain directory: ${dir}`);
}
for (const file of requiredFiles) {
const filePath = path.join(engineDir, file);
assert.ok(fs.existsSync(filePath) && fs.statSync(filePath).isFile(),
`Engine should contain file: ${file}`);
}
});
});
describe('E2E: Copilot uninstall verification', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-e2e-'));
runCopilotInstall(tmpDir);
runCopilotUninstall(tmpDir);
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('removes engine directory', () => {
const engineDir = path.join(tmpDir, '.github', 'get-shit-done');
assert.ok(!fs.existsSync(engineDir),
'get-shit-done directory should not exist after uninstall');
});
test('removes copilot-instructions.md', () => {
const instrPath = path.join(tmpDir, '.github', 'copilot-instructions.md');
assert.ok(!fs.existsSync(instrPath),
'copilot-instructions.md should not exist after uninstall');
});
test('removes all GSD skill directories', () => {
const skillsDir = path.join(tmpDir, '.github', 'skills');
if (fs.existsSync(skillsDir)) {
const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
const gsdSkills = entries.filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
assert.strictEqual(gsdSkills.length, 0,
`Expected 0 GSD skill directories after uninstall, found: ${gsdSkills.map(e => e.name).join(', ')}`);
}
});
test('removes all GSD agent files', () => {
const agentsDir = path.join(tmpDir, '.github', 'agents');
if (fs.existsSync(agentsDir)) {
const files = fs.readdirSync(agentsDir);
const gsdAgents = files.filter(f => f.startsWith('gsd-') && f.endsWith('.agent.md'));
assert.strictEqual(gsdAgents.length, 0,
`Expected 0 GSD agent files after uninstall, found: ${gsdAgents.join(', ')}`);
}
});
test('preserves non-GSD content in skills directory', () => {
// Standalone lifecycle: install → add custom content → uninstall → verify
const td = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-e2e-preserve-skill-'));
try {
runCopilotInstall(td);
// Add non-GSD custom skill
const customSkillDir = path.join(td, '.github', 'skills', 'my-custom-skill');
fs.mkdirSync(customSkillDir, { recursive: true });
fs.writeFileSync(path.join(customSkillDir, 'SKILL.md'), '# My Custom Skill\n');
// Uninstall
runCopilotUninstall(td);
// Verify custom content preserved
assert.ok(fs.existsSync(path.join(customSkillDir, 'SKILL.md')),
'Non-GSD skill directory and SKILL.md should be preserved after uninstall');
} finally {
fs.rmSync(td, { recursive: true, force: true });
}
});
test('preserves non-GSD content in agents directory', () => {
// Standalone lifecycle: install → add custom content → uninstall → verify
const td = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-e2e-preserve-agent-'));
try {
runCopilotInstall(td);
// Add non-GSD custom agent
const customAgentPath = path.join(td, '.github', 'agents', 'my-agent.md');
fs.writeFileSync(customAgentPath, '# My Custom Agent\n');
// Uninstall
runCopilotUninstall(td);
// Verify custom content preserved
assert.ok(fs.existsSync(customAgentPath),
'Non-GSD agent file should be preserved after uninstall');
} finally {
fs.rmSync(td, { recursive: true, force: true });
}
});
});