* feat(#786): elevate Copilot installer with lifecycle hook + AGENTS.md Emit a self-contained sessionStart hook config (.github/hooks/gsd-session.json local, ~/.copilot/hooks/gsd-session.json global) and write AGENTS.md at the repo root (Copilot CLI reads it as primary instructions) alongside copilot-instructions.md. The hook is an inline `command` hook (no separate hook script), so it cannot dangle. Uninstall removes both and preserves user content. Verified against GitHub Copilot CLI primary docs: hooks-configuration (camelCase events, version+hooks shape, inline bash/powershell command hooks) and add-custom-instructions (AGENTS.md read at repo root as primary instructions). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#786): set changeset pr number to 804 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
5
.changeset/786-copilot-hooks-agents.md
Normal file
5
.changeset/786-copilot-hooks-agents.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 804
|
||||
---
|
||||
The GitHub Copilot installer now reaches lifecycle-hook and instruction parity with other first-class runtimes. It emits a self-contained `sessionStart` hook config (`.github/hooks/gsd-session.json` for local installs, `~/.copilot/hooks/gsd-session.json` for global) and writes `AGENTS.md` at the repository root (which Copilot CLI reads as primary instructions) alongside `copilot-instructions.md`. The hook is an inline `command` hook with no separate script file, so it cannot dangle. Both artifacts are removed — with user-authored content preserved — on `--uninstall`. (#786)
|
||||
133
bin/install.js
133
bin/install.js
@@ -101,6 +101,32 @@ function isCodexHooksFeatureKey(key) {
|
||||
const GSD_COPILOT_INSTRUCTIONS_MARKER = '<!-- GSD Configuration \u2014 managed by gsd-core installer -->';
|
||||
const GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER = '<!-- /GSD Configuration -->';
|
||||
|
||||
// #786 \u2014 GitHub Copilot CLI lifecycle hook constants.
|
||||
// Copilot reads hook configs from <config>/hooks/*.json (repo scope: .github/hooks/,
|
||||
// user scope: ~/.copilot/hooks/) with the shape { version, hooks: { <event>: [...] } }.
|
||||
// Events use camelCase (sessionStart, preToolUse, postToolUse, ...). A `command`
|
||||
// hook runs an INLINE shell command (bash / powershell), so the GSD hook is fully
|
||||
// self-contained \u2014 there is no separate hook script to install, and therefore
|
||||
// nothing that can dangle if a script copy is skipped. See
|
||||
// https://docs.github.com/en/copilot/reference/hooks-configuration
|
||||
const GSD_COPILOT_HOOK_FILE = 'gsd-session.json';
|
||||
// Copilot parses a command hook's stdout as the hook-output JSON. For sessionStart
|
||||
// the schema is `{ additionalContext?: string }` (the text is prepended to the
|
||||
// session as context). So the hook must emit that JSON envelope — not bare text.
|
||||
// The two messages contain no JSON-special characters, so they embed verbatim.
|
||||
const GSD_COPILOT_SESSION_MSG_PRESENT =
|
||||
'GSD: .planning/STATE.md present - review the current phase and any blockers before acting.';
|
||||
const GSD_COPILOT_SESSION_MSG_ABSENT =
|
||||
'GSD: no .planning/ workflow found - run /gsd-new-project to start a tracked workflow.';
|
||||
const GSD_COPILOT_SESSION_HOOK_BASH =
|
||||
'if [ -f .planning/STATE.md ]; then ' +
|
||||
`printf '%s' '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_PRESENT}"}'; else ` +
|
||||
`printf '%s' '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_ABSENT}"}'; fi`;
|
||||
const GSD_COPILOT_SESSION_HOOK_PWSH =
|
||||
'if (Test-Path .planning/STATE.md) ' +
|
||||
`{ '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_PRESENT}"}' } ` +
|
||||
`else { '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_ABSENT}"}' }`;
|
||||
|
||||
// GSD-managed files under hooks/lib/ (helpers required by gsd-*.sh hooks).
|
||||
// git-cmd.js does not start with "gsd-" (shared classifier for #3129), gsd-graphify-rebuild.sh does.
|
||||
const GSD_HOOK_LIB_FILES = ['git-cmd.js', 'gsd-graphify-rebuild.sh'];
|
||||
@@ -5015,6 +5041,57 @@ function stripGsdFromCopilotInstructions(content) {
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* #786 — Build the GSD-managed GitHub Copilot lifecycle hook config object.
|
||||
*
|
||||
* Returns the verbatim JSON shape Copilot CLI expects:
|
||||
* { version: 1, hooks: { sessionStart: [ <hook entry> ] } }
|
||||
*
|
||||
* The sessionStart entry is a `command` hook whose `bash`/`powershell` bodies
|
||||
* run inline (no external script file), so the config can never reference a
|
||||
* hook script that the installer did not also install — it is self-contained
|
||||
* by construction. The command is advisory-only (always exits 0) and orients
|
||||
* the agent toward the project's GSD planning state at session start.
|
||||
*
|
||||
* @returns {object} Copilot hooks-configuration object
|
||||
*/
|
||||
function buildCopilotHookConfig() {
|
||||
return {
|
||||
version: 1,
|
||||
hooks: {
|
||||
sessionStart: [
|
||||
{
|
||||
type: 'command',
|
||||
bash: GSD_COPILOT_SESSION_HOOK_BASH,
|
||||
powershell: GSD_COPILOT_SESSION_HOOK_PWSH,
|
||||
timeoutSec: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* #786 — Write the GSD-managed Copilot lifecycle hook config under the runtime
|
||||
* config dir (`<targetDir>/hooks/gsd-session.json`). For local installs
|
||||
* targetDir is `.github` (→ `.github/hooks/`); for global installs it is
|
||||
* `~/.copilot` (→ `~/.copilot/hooks/`) — both are valid Copilot hook locations.
|
||||
*
|
||||
* The managed file is fully owned by GSD, so it is overwritten wholesale on
|
||||
* every install (idempotent). User-authored sibling `*.json` hook files in the
|
||||
* same directory are untouched.
|
||||
*
|
||||
* @param {string} targetDir - The Copilot config dir
|
||||
* @returns {string} The path the hook config was written to
|
||||
*/
|
||||
function writeCopilotHookConfig(targetDir) {
|
||||
const hooksDir = path.join(targetDir, 'hooks');
|
||||
fs.mkdirSync(hooksDir, { recursive: true });
|
||||
const hookPath = path.join(hooksDir, GSD_COPILOT_HOOK_FILE);
|
||||
fs.writeFileSync(hookPath, JSON.stringify(buildCopilotHookConfig(), null, 2) + '\n');
|
||||
return hookPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate config.toml and per-agent .toml files for Codex.
|
||||
* Reads agent .md files from source, extracts metadata, writes .toml configs.
|
||||
@@ -6822,6 +6899,24 @@ function uninstall(isGlobal, runtime = 'claude') {
|
||||
|
||||
console.log(` Uninstalling GSD from ${cyan}${runtimeLabel}${reset} at ${cyan}${locationLabel}${reset}\n`);
|
||||
|
||||
// #786: AGENTS.md lives at the repo root (outside targetDir) for local Copilot
|
||||
// installs, so its cleanup must run even when .github (targetDir) was already
|
||||
// removed — i.e. BEFORE the "target directory missing" early-return below.
|
||||
if (isCopilot && !isGlobal) {
|
||||
const agentsMdPath = path.join(process.cwd(), 'AGENTS.md');
|
||||
if (fs.existsSync(agentsMdPath)) {
|
||||
const content = fs.readFileSync(agentsMdPath, 'utf8');
|
||||
const cleaned = stripGsdFromCopilotInstructions(content);
|
||||
if (cleaned === null) {
|
||||
fs.unlinkSync(agentsMdPath);
|
||||
console.log(` ${green}✓${reset} Removed AGENTS.md (was GSD-only)`);
|
||||
} else if (cleaned !== content) {
|
||||
fs.writeFileSync(agentsMdPath, cleaned);
|
||||
console.log(` ${green}✓${reset} Cleaned GSD section from AGENTS.md`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if target directory exists
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
console.log(` ${yellow}⚠${reset} Directory does not exist: ${locationLabel}`);
|
||||
@@ -6899,6 +6994,23 @@ function uninstall(isGlobal, runtime = 'claude') {
|
||||
console.log(` ${green}✓${reset} Cleaned GSD section from copilot-instructions.md`);
|
||||
}
|
||||
}
|
||||
|
||||
// #786: remove the GSD-managed Copilot lifecycle hook config and prune the
|
||||
// hooks dir if we left it empty.
|
||||
const hookPath = path.join(targetDir, 'hooks', GSD_COPILOT_HOOK_FILE);
|
||||
if (fs.existsSync(hookPath)) {
|
||||
fs.unlinkSync(hookPath);
|
||||
removedCount++;
|
||||
console.log(` ${green}✓${reset} Removed Copilot lifecycle hook (${GSD_COPILOT_HOOK_FILE})`);
|
||||
try {
|
||||
const hooksDir = path.join(targetDir, 'hooks');
|
||||
if (fs.existsSync(hooksDir) && fs.readdirSync(hooksDir).length === 0) {
|
||||
fs.rmdirSync(hooksDir);
|
||||
}
|
||||
} catch { /* non-fatal: leave a non-empty/locked hooks dir in place */ }
|
||||
}
|
||||
// Note: AGENTS.md (repo root) is cleaned earlier, before the targetDir
|
||||
// existence early-return, since it lives outside targetDir (#786).
|
||||
}
|
||||
|
||||
// 1c. Claude local: remove commands/gsd/ (primary local install location).
|
||||
@@ -9349,8 +9461,24 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
||||
const template = fs.readFileSync(templatePath, 'utf8');
|
||||
mergeCopilotInstructions(instructionsPath, template);
|
||||
console.log(` ${green}✓${reset} Generated copilot-instructions.md`);
|
||||
// #786: also emit AGENTS.md, which Copilot CLI reads as primary
|
||||
// instructions from the repository root. AGENTS.md is a repo-root concept
|
||||
// (no documented user-scope home), so emit it only for local installs;
|
||||
// global scope is already covered by ~/.copilot/copilot-instructions.md.
|
||||
if (!isGlobal) {
|
||||
const agentsMdPath = path.join(process.cwd(), 'AGENTS.md');
|
||||
mergeCopilotInstructions(agentsMdPath, template);
|
||||
console.log(` ${green}✓${reset} Generated AGENTS.md`);
|
||||
}
|
||||
// Copilot: no settings.json, no hooks, no statusline (like Codex)
|
||||
}
|
||||
// #786: emit a self-contained Copilot lifecycle hook (sessionStart). Copilot
|
||||
// command hooks run inline bash/powershell, so this needs no separate hook
|
||||
// script and cannot dangle. Repo scope → .github/hooks/, user → ~/.copilot/hooks/.
|
||||
// The hook is a required install artifact, so a write failure is fatal (it
|
||||
// propagates) rather than silently producing a "successful" install missing
|
||||
// the feature.
|
||||
writeCopilotHookConfig(targetDir);
|
||||
console.log(` ${green}✓${reset} Configured Copilot lifecycle hook (sessionStart)`);
|
||||
persistActiveProfileMarker();
|
||||
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
|
||||
}
|
||||
@@ -10814,6 +10942,9 @@ module.exports = {
|
||||
GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER,
|
||||
mergeCopilotInstructions,
|
||||
stripGsdFromCopilotInstructions,
|
||||
GSD_COPILOT_HOOK_FILE,
|
||||
buildCopilotHookConfig,
|
||||
writeCopilotHookConfig,
|
||||
convertClaudeToAntigravityContent,
|
||||
convertClaudeCommandToAntigravitySkill,
|
||||
convertClaudeAgentToAntigravityAgent,
|
||||
|
||||
@@ -800,7 +800,7 @@ The migration-specific ownership and source snapshots live in
|
||||
| Kilo | `~/.config/kilo` | `./.kilo` | `command/gsd-*.md` | `agents/gsd-*.md` | `kilo.json` or `kilo.jsonc`; no GSD hooks |
|
||||
| Gemini CLI | `~/.gemini` | `./.gemini` | `commands/gsd/*.toml` | `agents/gsd-*.md` | `settings.json` feature flag, hooks, and statusline |
|
||||
| Codex | `~/.codex` | `./.codex` | `skills/gsd-*/SKILL.md` | `agents/` source markdown plus per-agent TOML | `config.toml` `[agents.gsd-*]`, `[features].hooks` (canonical; legacy alias `codex_hooks` is recognized and migrated forward on reinstall, #3566), and hook tables |
|
||||
| GitHub Copilot | `~/.copilot` | `./.github` | `skills/gsd-*/SKILL.md` and `copilot-instructions.md` | `.agent.md` files | No GSD hooks or statusline |
|
||||
| GitHub Copilot | `~/.copilot` | `./.github` | `skills/gsd-*/SKILL.md`, `copilot-instructions.md`, and `AGENTS.md` (repo root, local) | `.agent.md` files | Self-contained `sessionStart` hook (`hooks/gsd-session.json`, inline `command` type); no statusline |
|
||||
| Antigravity | auto-detected: `~/.gemini/antigravity`, `~/.gemini/antigravity-ide`, or `~/.gemini/antigravity-cli` | `./.agent` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | Gemini-style `settings.json` hook entries when installed by GSD |
|
||||
| Cursor | `~/.cursor` | `./.cursor` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | Rule references under `rules/`; no GSD hooks |
|
||||
| Windsurf | `~/.codeium/windsurf` | `./.windsurf` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | Rule references under `rules/`; no GSD hooks |
|
||||
|
||||
@@ -156,6 +156,13 @@ npx @opengsd/gsd-core@latest --copilot --global
|
||||
|
||||
Skills land in `~/.copilot/`. GSD installs as agent `.md` files and repository instruction files.
|
||||
|
||||
GSD also wires Copilot's lifecycle hooks and instruction files:
|
||||
|
||||
- **`AGENTS.md`** (local installs) — written at the repository root, which GitHub Copilot CLI reads as primary instructions, alongside `copilot-instructions.md`.
|
||||
- **Lifecycle hook** — a `sessionStart` hook config is written to `.github/hooks/gsd-session.json` (local) or `~/.copilot/hooks/gsd-session.json` (global). It is a self-contained inline `command` hook (no separate hook script to install), so it can never reference a missing script. The hook is advisory-only: at session start it surfaces whether the project has a `.planning/` workflow.
|
||||
|
||||
Both are removed (and any user-authored content preserved) on `--uninstall`.
|
||||
|
||||
**Override the install directory:**
|
||||
|
||||
```bash
|
||||
|
||||
@@ -38,6 +38,9 @@ const {
|
||||
GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER,
|
||||
mergeCopilotInstructions,
|
||||
stripGsdFromCopilotInstructions,
|
||||
GSD_COPILOT_HOOK_FILE,
|
||||
buildCopilotHookConfig,
|
||||
writeCopilotHookConfig,
|
||||
writeManifest,
|
||||
reportLocalPatches,
|
||||
installRuntimeArtifacts,
|
||||
@@ -1040,6 +1043,112 @@ describe('Copilot instructions merge/strip', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Copilot lifecycle hooks (#786) ────────────────────────────────────────────
|
||||
|
||||
describe('Copilot lifecycle hook config (#786)', () => {
|
||||
describe('buildCopilotHookConfig', () => {
|
||||
test('emits the documented Copilot hooks-config shape', () => {
|
||||
const cfg = buildCopilotHookConfig();
|
||||
assert.strictEqual(cfg.version, 1, 'version must be 1 per Copilot hooks schema');
|
||||
assert.ok(cfg.hooks && typeof cfg.hooks === 'object', 'has hooks object');
|
||||
assert.ok(Array.isArray(cfg.hooks.sessionStart), 'sessionStart is an array (camelCase event name)');
|
||||
assert.strictEqual(cfg.hooks.sessionStart.length, 1, 'one sessionStart entry');
|
||||
});
|
||||
|
||||
test('sessionStart entry is a self-contained inline command hook', () => {
|
||||
const [entry] = buildCopilotHookConfig().hooks.sessionStart;
|
||||
assert.strictEqual(entry.type, 'command', 'type is command');
|
||||
assert.ok(typeof entry.bash === 'string' && entry.bash.length > 0, 'has inline bash body');
|
||||
assert.ok(typeof entry.powershell === 'string' && entry.powershell.length > 0, 'has inline powershell body');
|
||||
assert.strictEqual(entry.timeoutSec, 10, 'uses timeoutSec (Copilot field), not timeout');
|
||||
});
|
||||
|
||||
test('command bodies emit the Copilot sessionStart JSON envelope (additionalContext)', () => {
|
||||
// Copilot parses command-hook stdout as JSON; sessionStart schema is
|
||||
// { additionalContext?: string }. Bare text would be invalid hook output.
|
||||
const [entry] = buildCopilotHookConfig().hooks.sessionStart;
|
||||
assert.ok(entry.bash.includes('"additionalContext"'), 'bash body emits additionalContext JSON');
|
||||
assert.ok(entry.powershell.includes('"additionalContext"'), 'powershell body emits additionalContext JSON');
|
||||
});
|
||||
|
||||
test('executing the bash hook body produces valid sessionStart JSON', { skip: process.platform === 'win32' }, () => {
|
||||
const { execFileSync } = require('child_process');
|
||||
const [entry] = buildCopilotHookConfig().hooks.sessionStart;
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-hook-exec-'));
|
||||
try {
|
||||
// No .planning/STATE.md → absent branch
|
||||
const outAbsent = execFileSync('bash', ['-c', entry.bash], { cwd: tmp, encoding: 'utf8' });
|
||||
const parsedAbsent = JSON.parse(outAbsent);
|
||||
assert.ok(typeof parsedAbsent.additionalContext === 'string', 'absent branch yields additionalContext string');
|
||||
assert.ok(/gsd-new-project/.test(parsedAbsent.additionalContext), 'absent branch suggests gsd-new-project');
|
||||
|
||||
// With .planning/STATE.md → present branch
|
||||
fs.mkdirSync(path.join(tmp, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmp, '.planning', 'STATE.md'), '# state\n');
|
||||
const outPresent = execFileSync('bash', ['-c', entry.bash], { cwd: tmp, encoding: 'utf8' });
|
||||
const parsedPresent = JSON.parse(outPresent);
|
||||
assert.ok(/STATE\.md present/.test(parsedPresent.additionalContext), 'present branch references STATE.md');
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('hook command references no external script path (cannot dangle)', () => {
|
||||
const [entry] = buildCopilotHookConfig().hooks.sessionStart;
|
||||
// A dangling hook points at a hook SCRIPT file the installer never wrote.
|
||||
// The GSD Copilot hook is inline, so it must not reference hooks/gsd-*.js|sh.
|
||||
assert.ok(!/hooks\/gsd-[\w-]+\.(js|cjs|sh)/.test(entry.bash), 'bash body references no gsd hook script file');
|
||||
assert.ok(!/hooks\/gsd-[\w-]+\.(js|cjs|sh)/.test(entry.powershell), 'powershell body references no gsd hook script file');
|
||||
});
|
||||
|
||||
test('produces valid JSON', () => {
|
||||
const json = JSON.stringify(buildCopilotHookConfig());
|
||||
assert.doesNotThrow(() => JSON.parse(json), 'config round-trips through JSON');
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeCopilotHookConfig', () => {
|
||||
let tmpHookDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHookDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-copilot-hook-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpHookDir);
|
||||
});
|
||||
|
||||
test('writes hooks/gsd-session.json under the config dir', () => {
|
||||
const written = writeCopilotHookConfig(tmpHookDir);
|
||||
const expected = path.join(tmpHookDir, 'hooks', GSD_COPILOT_HOOK_FILE);
|
||||
assert.strictEqual(written, expected, 'returns the written path');
|
||||
assert.ok(fs.existsSync(expected), 'hook config file exists');
|
||||
const parsed = JSON.parse(fs.readFileSync(expected, 'utf8'));
|
||||
assert.strictEqual(parsed.version, 1, 'written file has version 1');
|
||||
assert.ok(Array.isArray(parsed.hooks.sessionStart), 'written file has sessionStart array');
|
||||
});
|
||||
|
||||
test('is idempotent and overwrites the managed file in place', () => {
|
||||
writeCopilotHookConfig(tmpHookDir);
|
||||
const hookPath = path.join(tmpHookDir, 'hooks', GSD_COPILOT_HOOK_FILE);
|
||||
fs.writeFileSync(hookPath, '{"stale":true}\n');
|
||||
writeCopilotHookConfig(tmpHookDir);
|
||||
const parsed = JSON.parse(fs.readFileSync(hookPath, 'utf8'));
|
||||
assert.strictEqual(parsed.stale, undefined, 'stale content replaced');
|
||||
assert.strictEqual(parsed.version, 1, 'managed content restored');
|
||||
});
|
||||
|
||||
test('preserves sibling user-authored hook files', () => {
|
||||
const hooksDir = path.join(tmpHookDir, 'hooks');
|
||||
fs.mkdirSync(hooksDir, { recursive: true });
|
||||
const userHook = path.join(hooksDir, 'my-hook.json');
|
||||
fs.writeFileSync(userHook, '{"version":1,"hooks":{}}\n');
|
||||
writeCopilotHookConfig(tmpHookDir);
|
||||
assert.ok(fs.existsSync(userHook), 'user hook file untouched');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Copilot uninstall skill removal ───────────────────────────────────────────
|
||||
|
||||
describe('Copilot uninstall skill removal', () => {
|
||||
@@ -1321,6 +1430,23 @@ describe('E2E: Copilot full install verification', () => {
|
||||
'Should contain GSD Configuration close marker');
|
||||
});
|
||||
|
||||
test('emits AGENTS.md at the repo root with GSD markers (#786)', () => {
|
||||
const agentsMdPath = path.join(tmpDir, 'AGENTS.md');
|
||||
assert.ok(fs.existsSync(agentsMdPath), 'AGENTS.md should exist at repo root for local install');
|
||||
const content = fs.readFileSync(agentsMdPath, 'utf-8');
|
||||
assert.ok(content.includes('<!-- GSD Configuration'), 'AGENTS.md has GSD open marker');
|
||||
assert.ok(content.includes('<!-- /GSD Configuration -->'), 'AGENTS.md has GSD close marker');
|
||||
});
|
||||
|
||||
test('emits a Copilot lifecycle hook config (#786)', () => {
|
||||
const hookPath = path.join(tmpDir, '.github', 'hooks', 'gsd-session.json');
|
||||
assert.ok(fs.existsSync(hookPath), '.github/hooks/gsd-session.json should exist');
|
||||
const cfg = JSON.parse(fs.readFileSync(hookPath, 'utf-8'));
|
||||
assert.strictEqual(cfg.version, 1, 'hook config has version 1');
|
||||
assert.ok(Array.isArray(cfg.hooks.sessionStart), 'hook config has sessionStart array');
|
||||
assert.strictEqual(cfg.hooks.sessionStart[0].type, 'command', 'sessionStart is a command hook');
|
||||
});
|
||||
|
||||
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');
|
||||
@@ -1429,6 +1555,16 @@ describe('E2E: Copilot uninstall verification', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('removes the Copilot lifecycle hook config (#786)', () => {
|
||||
const hookPath = path.join(tmpDir, '.github', 'hooks', 'gsd-session.json');
|
||||
assert.ok(!fs.existsSync(hookPath), 'gsd-session.json should not exist after uninstall');
|
||||
});
|
||||
|
||||
test('removes GSD-only AGENTS.md (#786)', () => {
|
||||
const agentsMdPath = path.join(tmpDir, 'AGENTS.md');
|
||||
assert.ok(!fs.existsSync(agentsMdPath), 'GSD-only AGENTS.md should be removed after uninstall');
|
||||
});
|
||||
|
||||
describe('preserves non-GSD content', () => {
|
||||
let td;
|
||||
|
||||
@@ -1463,6 +1599,90 @@ describe('E2E: Copilot uninstall verification', () => {
|
||||
assert.ok(fs.existsSync(customAgentPath),
|
||||
'Non-GSD agent file should be preserved after uninstall');
|
||||
});
|
||||
|
||||
test('preserves user-authored content in AGENTS.md on uninstall (#786)', () => {
|
||||
// After install, AGENTS.md exists with the GSD block. Prepend user content.
|
||||
const agentsMdPath = path.join(td, 'AGENTS.md');
|
||||
assert.ok(fs.existsSync(agentsMdPath), 'AGENTS.md created by install');
|
||||
const gsdBlock = fs.readFileSync(agentsMdPath, 'utf-8');
|
||||
fs.writeFileSync(agentsMdPath, '# My Project Notes\n\nKeep these.\n\n' + gsdBlock);
|
||||
// Uninstall strips only the GSD section
|
||||
runCopilotUninstall(td);
|
||||
assert.ok(fs.existsSync(agentsMdPath), 'AGENTS.md preserved (had user content)');
|
||||
const after = fs.readFileSync(agentsMdPath, 'utf-8');
|
||||
assert.ok(after.includes('# My Project Notes'), 'user content preserved');
|
||||
assert.ok(!after.includes('<!-- GSD Configuration'), 'GSD section stripped');
|
||||
});
|
||||
|
||||
test('preserves a user-authored sibling hook file on uninstall (#786)', () => {
|
||||
const userHook = path.join(td, '.github', 'hooks', 'user-hook.json');
|
||||
fs.writeFileSync(userHook, '{"version":1,"hooks":{}}\n');
|
||||
runCopilotUninstall(td);
|
||||
assert.ok(fs.existsSync(userHook), 'user-authored hook file preserved');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── E2E: Copilot global scope (#786) ──────────────────────────────────────────
|
||||
|
||||
function runCopilotInstallGlobal(cwd, configDir) {
|
||||
const env = { ...process.env };
|
||||
delete env.GSD_TEST_MODE;
|
||||
return execFileSync(process.execPath,
|
||||
[INSTALL_PATH, '--copilot', '--global', '--config-dir', configDir, '--no-sdk'], {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
function runCopilotUninstallGlobal(cwd, configDir) {
|
||||
const env = { ...process.env };
|
||||
delete env.GSD_TEST_MODE;
|
||||
return execFileSync(process.execPath,
|
||||
[INSTALL_PATH, '--copilot', '--global', '--config-dir', configDir, '--uninstall', '--no-sdk'], {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
describe('E2E: Copilot global install (#786)', () => {
|
||||
let projectDir;
|
||||
let configDir;
|
||||
|
||||
beforeEach(() => {
|
||||
projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-e2e-gproj-'));
|
||||
configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-e2e-gcfg-'));
|
||||
runCopilotInstallGlobal(projectDir, configDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(projectDir);
|
||||
cleanup(configDir);
|
||||
});
|
||||
|
||||
test('writes the lifecycle hook config under the global config dir', () => {
|
||||
const hookPath = path.join(configDir, 'hooks', 'gsd-session.json');
|
||||
assert.ok(fs.existsSync(hookPath), 'global hook config should exist under config dir');
|
||||
const cfg = JSON.parse(fs.readFileSync(hookPath, 'utf-8'));
|
||||
assert.strictEqual(cfg.version, 1, 'hook config version is 1');
|
||||
assert.ok(Array.isArray(cfg.hooks.sessionStart), 'has sessionStart array');
|
||||
});
|
||||
|
||||
test('does NOT emit AGENTS.md for global scope (no repo-root home)', () => {
|
||||
assert.ok(!fs.existsSync(path.join(projectDir, 'AGENTS.md')),
|
||||
'global install must not write AGENTS.md into the working directory');
|
||||
assert.ok(!fs.existsSync(path.join(configDir, 'AGENTS.md')),
|
||||
'global install must not write AGENTS.md into the config directory');
|
||||
});
|
||||
|
||||
test('global uninstall removes the lifecycle hook config', () => {
|
||||
runCopilotUninstallGlobal(projectDir, configDir);
|
||||
const hookPath = path.join(configDir, 'hooks', 'gsd-session.json');
|
||||
assert.ok(!fs.existsSync(hookPath), 'global hook config removed after uninstall');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user