diff --git a/.changeset/787-cline-hooks-agents.md b/.changeset/787-cline-hooks-agents.md new file mode 100644 index 000000000..f6eefd754 --- /dev/null +++ b/.changeset/787-cline-hooks-agents.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 803 +--- +Elevate the Cline runtime to hook parity. The installer now emits the Cline `.clinerules/` directory form (`.clinerules/gsd.md`) instead of a single `.clinerules` file, adds a `.clinerules/hooks/PreToolUse` lifecycle hook (Cline v3.36+ JSON stdin → `{cancel,errorMessage,contextModification}` protocol; guards `.planning/` artifacts and fails open), and merges GSD instructions into the cross-tool global `~/.agents/AGENTS.md` target on global installs. A legacy single-file `.clinerules` is migrated to the directory form in place, and `--uninstall` removes the new artifacts and strips the GSD block from `~/.agents/AGENTS.md`. (#787) diff --git a/bin/install.js b/bin/install.js index 6e7b5e61e..006f3d85d 100755 --- a/bin/install.js +++ b/bin/install.js @@ -5064,6 +5064,213 @@ function stripGsdFromCopilotInstructions(content) { return content; } +// ── Cline directory-form rules + hooks + AGENTS.md (issue #787) ──────────────── +// +// Cline v3.36 added a hooks system and a `.clinerules/` directory form. Because +// `.clinerules` cannot be both a file AND a directory, emitting hooks under +// `.clinerules/hooks/` requires migrating the rules content into the directory +// form (`.clinerules/gsd.md`). Sources adjudicated: +// - https://cline.bot/blog/cline-v3-36-hooks +// - https://docs.cline.bot/customization/cline-rules + +const GSD_AGENTS_MD_MARKER = ''; +const GSD_AGENTS_MD_CLOSE_MARKER = ''; + +/** + * The GSD instruction body shared by the Cline directory-form rules file and + * the cross-tool AGENTS.md block. Self-contained — references only the gsd-core + * engine layout, not the (separate) #782 Cline skills directory. + */ +function buildClineRulesBody() { + return [ + '# GSD Core — Git. Ship. Done.', + '', + '- GSD workflows live in `gsd-core/workflows/`. Load the relevant workflow when', + ' the user runs a `/gsd-*` command.', + '- GSD agents live in `agents/`. Use the matching agent when spawning subagents.', + '- GSD tools are at `gsd-core/bin/gsd-tools.cjs`. Run with `node`.', + '- Planning artifacts live in `.planning/`. Never edit them outside a GSD workflow.', + '- Do not apply GSD workflows unless the user explicitly asks for them.', + '- When a GSD command triggers a deliverable (feature, fix, docs), offer the next', + ' step to the user using Cline\'s ask_user tool after completing it.', + ].join('\n') + '\n'; +} + +/** AGENTS.md body for the cross-tool global instruction target (`~/.agents/AGENTS.md`). */ +function buildClineAgentsMdBody() { + return buildClineRulesBody(); +} + +/** + * The Cline PreToolUse hook script (issue #787). + * + * Cline invokes hooks as executable scripts named exactly after the event with + * no extension, passing the operation context as JSON on stdin and reading a + * JSON decision from stdout ({ cancel, errorMessage, contextModification }). + * + * This hook is a self-standing planning-artifact guard: it cancels write-class + * tool calls that target `.planning/` (GSD-owned artifacts), and otherwise + * allows the operation. It FAILS OPEN — any parse/IO error allows the call so a + * hook bug can never wedge the user. No dependency on the #782 skills work. + */ +function buildClinePreToolUseHook() { + return `#!/usr/bin/env node +'use strict'; +/* GSD-managed Cline PreToolUse hook — gsd-core issue #787. + * Protocol: JSON on stdin -> JSON decision on stdout. + * Honored fields: { cancel, errorMessage, contextModification }. + * Fails open: any error allows the operation. */ +let raw = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (c) => { raw += c; }); +process.stdin.on('end', () => { + const allow = () => process.stdout.write(JSON.stringify({ cancel: false })); + let input; + try { input = JSON.parse(raw || '{}'); } catch { return allow(); } + try { + const tool = String( + input.toolName || input.tool_name || input.tool || + (input.toolInput && input.toolInput.name) || (input.tool_input && input.tool_input.name) || '' + ).toLowerCase(); + const isWrite = /write|edit|replace|create|delete|remove|append|apply|patch|insert|mkdir/.test(tool); + // Collect only PATH-bearing field values (not free-form content), so a doc + // that merely mentions ".planning/" in its body is never falsely blocked. + const paths = []; + const PATH_KEY = /^(path|file|file_?path|filepath|target_?path|target|dir|directory|uri|filename)$/i; + const walk = (v, depth) => { + if (depth > 5 || paths.length > 64) return; + if (Array.isArray(v)) { for (const x of v) walk(x, depth + 1); return; } + if (v && typeof v === 'object') { + for (const k of Object.keys(v)) { + const val = v[k]; + if (typeof val === 'string' && PATH_KEY.test(k)) paths.push(val); + else walk(val, depth + 1); + } + } + }; + walk(input, 0); + const isPlanningPath = (s) => /(^|[\\\\/])\\.planning([\\\\/]|$)/.test(s); + if (isWrite && paths.some(isPlanningPath)) { + return process.stdout.write(JSON.stringify({ + cancel: true, + errorMessage: + 'GSD: .planning/ artifacts are managed by GSD workflows. Edit them only through a /gsd-* command, not directly.', + })); + } + } catch { /* fall through to allow */ } + return allow(); +}); +`; +} + +/** + * Merge the GSD AGENTS.md block into an existing file (or create it), preserving + * any user content. Mirrors mergeCopilotInstructions: marker-delimited, idempotent. + */ +function mergeGsdAgentsMd(filePath, gsdContent) { + const gsdBlock = GSD_AGENTS_MD_MARKER + '\n' + gsdContent.trim() + '\n' + GSD_AGENTS_MD_CLOSE_MARKER; + + if (!fs.existsSync(filePath)) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, gsdBlock + '\n'); + return; + } + + const existing = fs.readFileSync(filePath, 'utf8'); + const openIndex = existing.indexOf(GSD_AGENTS_MD_MARKER); + const closeIndex = existing.indexOf(GSD_AGENTS_MD_CLOSE_MARKER); + + if (openIndex !== -1 && closeIndex !== -1) { + const before = existing.substring(0, openIndex).trimEnd(); + const after = existing.substring(closeIndex + GSD_AGENTS_MD_CLOSE_MARKER.length).trimStart(); + let newContent = ''; + if (before) newContent += before + '\n\n'; + newContent += gsdBlock; + if (after) newContent += '\n\n' + after; + newContent += '\n'; + fs.writeFileSync(filePath, newContent); + return; + } + + fs.writeFileSync(filePath, existing.trimEnd() + '\n\n' + gsdBlock + '\n'); +} + +/** + * Strip the GSD block from AGENTS.md content. Returns null if the file became + * empty (was GSD-only), the unchanged content if no markers were found, or the + * cleaned content otherwise. + */ +function stripGsdFromAgentsMd(content) { + const openIndex = content.indexOf(GSD_AGENTS_MD_MARKER); + const closeIndex = content.indexOf(GSD_AGENTS_MD_CLOSE_MARKER); + if (openIndex !== -1 && closeIndex !== -1) { + const before = content.substring(0, openIndex).trimEnd(); + const after = content.substring(closeIndex + GSD_AGENTS_MD_CLOSE_MARKER.length).trimStart(); + const cleaned = (before + (before && after ? '\n\n' : '') + after).trim(); + if (!cleaned) return null; + return cleaned + '\n'; + } + return content; +} + +/** + * Write the full Cline runtime artifact set (directory-form rules + PreToolUse + * hook) into targetDir, migrating a legacy single-file `.clinerules` if present. + * For global installs, also merge the cross-tool ~/.agents/AGENTS.md target. + * + * Returns the list of manifest-relative paths written under targetDir (so the + * caller can hash-track them). + */ +function writeClineArtifacts(targetDir, isGlobalInstall) { + const written = []; + const clinerulesDir = path.join(targetDir, '.clinerules'); + + // Migrate a pre-#787 single-file `.clinerules` — a path cannot be both a + // file and a directory, so the legacy file must be removed first. The legacy + // file is GSD-authored (the installer wrote its full contents with no user + // merge surface), so replacing it with the newer directory form is the + // intended upgrade. Use lstat so a symlink is unlinked in place rather than + // followed (which would write GSD files through the link into an external dir). + try { + if (fs.existsSync(clinerulesDir)) { + const st = fs.lstatSync(clinerulesDir); + if (st.isFile() || st.isSymbolicLink()) { + fs.unlinkSync(clinerulesDir); + console.log(` ${green}✓${reset} Migrated legacy .clinerules to directory form`); + } + } + } catch { /* best-effort migration */ } + + fs.mkdirSync(clinerulesDir, { recursive: true }); + fs.writeFileSync(path.join(clinerulesDir, 'gsd.md'), buildClineRulesBody()); + written.push('.clinerules/gsd.md'); + console.log(` ${green}✓${reset} Wrote .clinerules/gsd.md`); + + const hooksDir = path.join(clinerulesDir, 'hooks'); + fs.mkdirSync(hooksDir, { recursive: true }); + const hookPath = path.join(hooksDir, 'PreToolUse'); + fs.writeFileSync(hookPath, buildClinePreToolUseHook()); + try { fs.chmodSync(hookPath, 0o755); } catch { /* Windows: hooks unsupported anyway */ } + written.push('.clinerules/hooks/PreToolUse'); + console.log(` ${green}✓${reset} Wrote .clinerules/hooks/PreToolUse`); + + // Global cross-tool instruction target. Cline reads ~/.agents/AGENTS.md + // (docs.cline.bot/customization/cline-rules). Merge-safe so we never clobber + // a user's or another tool's AGENTS.md. Tracked via markers (like copilot), + // not the per-configDir manifest, since it lives outside configDir. + if (isGlobalInstall) { + try { + const agentsPath = path.join(os.homedir(), '.agents', 'AGENTS.md'); + mergeGsdAgentsMd(agentsPath, buildClineAgentsMdBody()); + console.log(` ${green}✓${reset} Merged GSD instructions into ~/.agents/AGENTS.md`); + } catch (err) { + console.warn(` ${yellow}⚠${reset} Could not write ~/.agents/AGENTS.md: ${err.message}`); + } + } + + return written; +} + /** * #786 — Build the GSD-managed GitHub Copilot lifecycle hook config object. * @@ -6896,10 +7103,14 @@ function uninstall(isGlobal, runtime = 'claude') { const isCodebuddy = runtime === 'codebuddy'; const dirName = getDirName(runtime); - // Get the target directory based on runtime and install type + // Get the target directory based on runtime and install type. Cline local + // installs write to the project root (.clinerules/ lives at the root, not in + // a .cline/ subdir), mirroring the install() path resolution (#787). const targetDir = isGlobal ? getGlobalConfigDir(runtime, explicitConfigDir) - : path.join(process.cwd(), dirName); + : runtime === 'cline' + ? process.cwd() + : path.join(process.cwd(), dirName); const locationLabel = isGlobal ? targetDir.replace(os.homedir(), '~') @@ -7036,6 +7247,55 @@ function uninstall(isGlobal, runtime = 'claude') { // existence early-return, since it lives outside targetDir (#786). } + // 1b-cline. Non-layout Cline side-effects (issue #787): remove the + // directory-form rules + PreToolUse hook, and strip the GSD block from the + // global cross-tool ~/.agents/AGENTS.md target. + if (runtime === 'cline') { + const clinerulesDir = path.join(targetDir, '.clinerules'); + for (const rel of ['gsd.md', path.join('hooks', 'PreToolUse')]) { + const p = path.join(clinerulesDir, rel); + try { + if (fs.existsSync(p)) { + fs.unlinkSync(p); + removedCount++; + } + } catch { /* best-effort */ } + } + // Also remove a legacy single-file .clinerules left by pre-#787 installs. + try { + if (fs.existsSync(clinerulesDir) && fs.statSync(clinerulesDir).isFile()) { + fs.unlinkSync(clinerulesDir); + removedCount++; + } + } catch { /* best-effort */ } + // Prune now-empty GSD-created directories (leave any user-added rule files). + for (const dir of [path.join(clinerulesDir, 'hooks'), clinerulesDir]) { + try { + if (fs.existsSync(dir) && fs.statSync(dir).isDirectory() && fs.readdirSync(dir).length === 0) { + fs.rmdirSync(dir); + } + } catch { /* best-effort */ } + } + if (isGlobal) { + const agentsPath = path.join(os.homedir(), '.agents', 'AGENTS.md'); + try { + if (fs.existsSync(agentsPath)) { + const content = fs.readFileSync(agentsPath, 'utf8'); + const cleaned = stripGsdFromAgentsMd(content); + if (cleaned === null) { + fs.unlinkSync(agentsPath); + removedCount++; + console.log(` ${green}✓${reset} Removed ~/.agents/AGENTS.md (was GSD-only)`); + } else if (cleaned !== content) { + fs.writeFileSync(agentsPath, cleaned); + removedCount++; + console.log(` ${green}✓${reset} Cleaned GSD section from ~/.agents/AGENTS.md`); + } + } + } catch { /* best-effort */ } + } + } + // 1c. Claude local: remove commands/gsd/ (primary local install location). // The layout's _removeGsdEntries uses the 'gsd-' prefix which applies to // flat command dirs (OpenCode/Kilo). Claude local files use no prefix inside @@ -7800,11 +8060,15 @@ function writeManifest(configDir, runtime = 'claude', options = {}) { } } } - // Track .clinerules file in manifest for Cline installs + // Track Cline directory-form artifacts in the manifest (issue #787): the + // rules file and the PreToolUse hook. (~/.agents/AGENTS.md is tracked via its + // marker block, not the per-configDir manifest, since it lives outside it.) if (isCline) { - const clinerulesDest = path.join(configDir, '.clinerules'); - if (fs.existsSync(clinerulesDest)) { - manifest.files['.clinerules'] = fileHash(clinerulesDest); + for (const rel of ['.clinerules/gsd.md', '.clinerules/hooks/PreToolUse']) { + const dest = path.join(configDir, rel); + if (fs.existsSync(dest)) { + manifest.files[rel] = fileHash(dest); + } } } @@ -9529,22 +9793,13 @@ function install(isGlobal, runtime = 'claude', options = {}) { } if (configIntent.installSurface === 'cline-rules') { - // Cline uses .clinerules — generate a rules file with GSD system instructions - const clinerulesDest = path.join(targetDir, '.clinerules'); - const clinerules = [ - '# GSD Core — Git. Ship. Done.', - '', - '- GSD workflows live in `gsd-core/workflows/`. Load the relevant workflow when', - ' the user runs a `/gsd-*` command.', - '- GSD agents live in `agents/`. Use the matching agent when spawning subagents.', - '- GSD tools are at `gsd-core/bin/gsd-tools.cjs`. Run with `node`.', - '- Planning artifacts live in `.planning/`. Never edit them outside a GSD workflow.', - '- Do not apply GSD workflows unless the user explicitly asks for them.', - '- When a GSD command triggers a deliverable (feature, fix, docs), offer the next', - ' step to the user using Cline\'s ask_user tool after completing it.', - ].join('\n') + '\n'; - fs.writeFileSync(clinerulesDest, clinerules); - console.log(` ${green}✓${reset} Wrote .clinerules`); + // Cline uses the `.clinerules/` directory form (issue #787): GSD rules live + // at .clinerules/gsd.md and a PreToolUse lifecycle hook at + // .clinerules/hooks/PreToolUse. Global installs also get ~/.agents/AGENTS.md. + writeClineArtifacts(targetDir, isGlobal); + // Re-run the manifest pass: these artifacts are written *after* the earlier + // writeManifest() call, so a second pass is needed to hash-track them. + writeManifest(targetDir, runtime, { mode: _effectiveInstallMode }); persistActiveProfileMarker(); return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir }; } @@ -11004,6 +11259,14 @@ module.exports = { convertClaudeAgentToCodebuddyAgent, convertClaudeToCliineMarkdown, convertClaudeAgentToClineAgent, + buildClineRulesBody, + buildClineAgentsMdBody, + buildClinePreToolUseHook, + writeClineArtifacts, + mergeGsdAgentsMd, + stripGsdFromAgentsMd, + GSD_AGENTS_MD_MARKER, + GSD_AGENTS_MD_CLOSE_MARKER, writeManifest, saveLocalPatches, reportLocalPatches, diff --git a/docs/how-to/install-on-your-runtime.md b/docs/how-to/install-on-your-runtime.md index d5de73f47..3bae71c01 100644 --- a/docs/how-to/install-on-your-runtime.md +++ b/docs/how-to/install-on-your-runtime.md @@ -205,7 +205,7 @@ WINDSURF_CONFIG_DIR=~/.codeium/windsurf-alt npx @opengsd/gsd-core@latest --winds ### Cline -Cline uses a rules-based integration — GSD installs as `.clinerules` rather than slash commands. +Cline uses a rules-based integration — GSD installs as Cline rules rather than slash commands. ```bash # Global install (all projects) @@ -215,7 +215,24 @@ npx @opengsd/gsd-core@latest --cline --global npx @opengsd/gsd-core@latest --cline --local ``` -Global installs write to `~/.cline/`. Local installs write to `./.cline/`. Rules are loaded automatically by Cline — no custom slash commands are registered. +GSD writes the [`.clinerules/` directory form](https://docs.cline.bot/customization/cline-rules): + +- **`.clinerules/gsd.md`** — the GSD rule file. Cline loads every `.md`/`.txt` file in + the `.clinerules/` directory automatically; no custom slash commands are registered. +- **`.clinerules/hooks/PreToolUse`** — a [lifecycle hook](https://cline.bot/blog/cline-v3-36-hooks) + (Cline v3.36+). It is an executable script that receives the tool-call context as JSON on + stdin and returns a JSON decision (`cancel` / `errorMessage` / `contextModification`). The + GSD hook guards `.planning/` artifacts from direct edits and otherwise allows the operation; + it fails open, so a hook error never blocks you. Cline runs hooks on macOS and Linux only. + +Global installs additionally merge GSD instructions into **`~/.agents/AGENTS.md`**, the +cross-tool global instruction file Cline reads. The block is marker-delimited, so your own +`AGENTS.md` content (and other tools' entries) is preserved, and `--uninstall` strips only the +GSD block. + +> Cline's *global* hook directory (`~/Documents/Cline/Rules/Hooks/`) is not yet populated by the +> installer — project-scope hooks (`.clinerules/hooks/`) and the global `AGENTS.md` instruction +> target cover the common cases. --- diff --git a/tests/cline-install.test.cjs b/tests/cline-install.test.cjs index adbde38d9..19365ff87 100644 --- a/tests/cline-install.test.cjs +++ b/tests/cline-install.test.cjs @@ -142,17 +142,19 @@ describe('Cline install (local)', () => { cleanup(tmpDir); }); - test('install creates .clinerules file', () => { + test('install creates .clinerules directory with gsd.md (#787 directory form)', () => { install(false, 'cline'); - const clinerules = path.join(tmpDir, '.clinerules'); - assert.ok(fs.existsSync(clinerules), '.clinerules must exist after cline install'); + const clinerulesDir = path.join(tmpDir, '.clinerules'); + assert.ok(fs.existsSync(clinerulesDir), '.clinerules must exist after cline install'); + assert.ok(fs.statSync(clinerulesDir).isDirectory(), '.clinerules must be a directory (#787)'); + assert.ok(fs.existsSync(path.join(clinerulesDir, 'gsd.md')), '.clinerules/gsd.md must exist'); }); - test('.clinerules contains GSD instructions', () => { + test('.clinerules/gsd.md contains GSD instructions', () => { install(false, 'cline'); - const clinerules = path.join(tmpDir, '.clinerules'); - const content = fs.readFileSync(clinerules, 'utf8'); - assert.ok(content.includes('GSD') || content.includes('gsd'), '.clinerules must reference GSD'); + const ruleFile = path.join(tmpDir, '.clinerules', 'gsd.md'); + const content = fs.readFileSync(ruleFile, 'utf8'); + assert.ok(content.includes('GSD') || content.includes('gsd'), '.clinerules/gsd.md must reference GSD'); }); test('install creates gsd-core engine directory', () => { diff --git a/tests/installer-migration-install-integration.test.cjs b/tests/installer-migration-install-integration.test.cjs index 857bc101f..f9802657a 100644 --- a/tests/installer-migration-install-integration.test.cjs +++ b/tests/installer-migration-install-integration.test.cjs @@ -232,10 +232,11 @@ function assertFreshInstallContract(runtime, targetDir) { `${runtime} should install commands/gsd entries` ); } else if (contract.surface === 'clinerules') { + // #787: Cline now uses the .clinerules/ directory form (rules at gsd.md). assert.match( - fs.readFileSync(path.join(targetDir, '.clinerules'), 'utf8'), + fs.readFileSync(path.join(targetDir, '.clinerules', 'gsd.md'), 'utf8'), /GSD workflows live in `gsd-core\/workflows\/`/, - 'Cline should install root .clinerules guidance' + 'Cline should install .clinerules/gsd.md guidance' ); } diff --git a/tests/issue-787-cline-hooks-agents.test.cjs b/tests/issue-787-cline-hooks-agents.test.cjs new file mode 100644 index 000000000..5f33bfa48 --- /dev/null +++ b/tests/issue-787-cline-hooks-agents.test.cjs @@ -0,0 +1,313 @@ +// allow-test-rule: source-text-is-the-product +// The Cline rules markdown, the PreToolUse hook script, and the AGENTS.md block +// ARE the deployed contract that the Cline runtime loads/executes — testing their +// text/behavior tests the shipped artifact. Per CONTRIBUTING.md exception matrix. + +/** + * Issue #787 — elevate Cline: write hooks (.clinerules/hooks/) + AGENTS.md. + * + * Verifies the installer now emits the Cline directory-form rules, a + * PreToolUse lifecycle hook (Cline JSON stdin → {cancel,errorMessage, + * contextModification} protocol), and a global ~/.agents/AGENTS.md instruction + * target. Self-contained: does NOT depend on the #782 Cline skills work. + * + * Primary sources adjudicated: + * - https://cline.bot/blog/cline-v3-36-hooks + * hooks live at .clinerules/hooks/ (project) and + * ~/Documents/Cline/Rules/Hooks/ (global); executable scripts named + * exactly after the event with no extension; JSON stdin → JSON stdout + * with cancel / errorMessage / contextModification. + * - https://docs.cline.bot/customization/cline-rules + * Cline processes all .md/.txt files inside a .clinerules/ directory and + * reads cross-tool global instructions from ~/.agents/AGENTS.md. + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { spawnSync } = require('node:child_process'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js'); + +const { + install, + uninstall, + buildClineRulesBody, + buildClinePreToolUseHook, + buildClineAgentsMdBody, + mergeGsdAgentsMd, + stripGsdFromAgentsMd, + GSD_AGENTS_MD_MARKER, + GSD_AGENTS_MD_CLOSE_MARKER, +} = require('../bin/install.js'); + +// ─── Pure helpers ───────────────────────────────────────────────────────────── + +describe('#787 Cline pure helpers', () => { + test('buildClineRulesBody returns GSD directory-form rules markdown', () => { + const body = buildClineRulesBody(); + assert.equal(typeof body, 'string'); + assert.match(body, /GSD workflows live in `gsd-core\/workflows\/`/); + assert.ok(body.endsWith('\n'), 'rules body should end with a trailing newline'); + }); + + test('buildClinePreToolUseHook returns a syntactically valid Node script', () => { + const script = buildClinePreToolUseHook(); + assert.match(script, /^#!\/usr\/bin\/env node/, 'must carry a node shebang'); + // Cline protocol fields must be present in the emitted decision surface. + assert.match(script, /cancel/); + assert.match(script, /errorMessage/); + const tmp = createTempDir('gsd-787-hookcheck-'); + try { + const p = path.join(tmp, 'PreToolUse'); + fs.writeFileSync(p, script); + const res = spawnSync(process.execPath, ['--check', p], { encoding: 'utf8' }); + assert.equal(res.status, 0, `node --check failed: ${res.stderr}`); + } finally { + cleanup(tmp); + } + }); + + test('PreToolUse hook allows a normal tool call (cancel:false)', () => { + const tmp = createTempDir('gsd-787-hookrun-'); + try { + const p = path.join(tmp, 'PreToolUse'); + fs.writeFileSync(p, buildClinePreToolUseHook()); + const res = spawnSync(process.execPath, [p], { + input: JSON.stringify({ toolName: 'read_file', toolInput: { path: 'src/index.ts' } }), + encoding: 'utf8', + }); + assert.equal(res.status, 0); + const out = JSON.parse(res.stdout); + assert.equal(out.cancel, false); + } finally { + cleanup(tmp); + } + }); + + test('PreToolUse hook cancels a write into .planning/ with an errorMessage', () => { + const tmp = createTempDir('gsd-787-hookguard-'); + try { + const p = path.join(tmp, 'PreToolUse'); + fs.writeFileSync(p, buildClinePreToolUseHook()); + const res = spawnSync(process.execPath, [p], { + input: JSON.stringify({ toolName: 'write_to_file', toolInput: { path: '.planning/ROADMAP.md', content: 'x' } }), + encoding: 'utf8', + }); + assert.equal(res.status, 0); + const out = JSON.parse(res.stdout); + assert.equal(out.cancel, true); + assert.match(out.errorMessage, /\.planning/); + } finally { + cleanup(tmp); + } + }); + + test('PreToolUse hook does NOT cancel a write to a non-planning path whose CONTENT mentions .planning/', () => { + const tmp = createTempDir('gsd-787-hookfp-'); + try { + const p = path.join(tmp, 'PreToolUse'); + fs.writeFileSync(p, buildClinePreToolUseHook()); + const res = spawnSync(process.execPath, [p], { + input: JSON.stringify({ + toolName: 'write_to_file', + toolInput: { path: 'docs/guide.md', content: 'Edit your .planning/ROADMAP.md via /gsd commands.' }, + }), + encoding: 'utf8', + }); + assert.equal(res.status, 0); + assert.equal(JSON.parse(res.stdout).cancel, false, 'content mentioning .planning must not trigger a cancel'); + } finally { + cleanup(tmp); + } + }); + + test('PreToolUse hook fails open on malformed stdin', () => { + const tmp = createTempDir('gsd-787-hookbad-'); + try { + const p = path.join(tmp, 'PreToolUse'); + fs.writeFileSync(p, buildClinePreToolUseHook()); + const res = spawnSync(process.execPath, [p], { input: 'not json{', encoding: 'utf8' }); + assert.equal(res.status, 0); + assert.equal(JSON.parse(res.stdout).cancel, false); + } finally { + cleanup(tmp); + } + }); + + test('mergeGsdAgentsMd creates a marker-delimited block when no file exists', () => { + const tmp = createTempDir('gsd-787-agents-new-'); + try { + const p = path.join(tmp, 'AGENTS.md'); + mergeGsdAgentsMd(p, buildClineAgentsMdBody()); + const content = fs.readFileSync(p, 'utf8'); + assert.ok(content.includes(GSD_AGENTS_MD_MARKER)); + assert.ok(content.includes(GSD_AGENTS_MD_CLOSE_MARKER)); + assert.match(content, /GSD/); + } finally { + cleanup(tmp); + } + }); + + test('mergeGsdAgentsMd preserves pre-existing user content', () => { + const tmp = createTempDir('gsd-787-agents-merge-'); + try { + const p = path.join(tmp, 'AGENTS.md'); + fs.writeFileSync(p, '# My rules\n\nKeep me.\n'); + mergeGsdAgentsMd(p, buildClineAgentsMdBody()); + const content = fs.readFileSync(p, 'utf8'); + assert.match(content, /Keep me\./); + assert.ok(content.includes(GSD_AGENTS_MD_MARKER)); + // Idempotent: second merge does not duplicate the block. + mergeGsdAgentsMd(p, buildClineAgentsMdBody()); + const twice = fs.readFileSync(p, 'utf8'); + const occurrences = twice.split(GSD_AGENTS_MD_MARKER).length - 1; + assert.equal(occurrences, 1, 'GSD block must not duplicate on re-merge'); + assert.match(twice, /Keep me\./); + } finally { + cleanup(tmp); + } + }); + + test('stripGsdFromAgentsMd returns null when file was GSD-only, else cleaned content', () => { + const onlyGsd = `${GSD_AGENTS_MD_MARKER}\nhi\n${GSD_AGENTS_MD_CLOSE_MARKER}\n`; + assert.equal(stripGsdFromAgentsMd(onlyGsd), null); + const mixed = `# Keep\n\n${GSD_AGENTS_MD_MARKER}\nhi\n${GSD_AGENTS_MD_CLOSE_MARKER}\n`; + const cleaned = stripGsdFromAgentsMd(mixed); + assert.match(cleaned, /# Keep/); + assert.ok(!cleaned.includes(GSD_AGENTS_MD_MARKER)); + }); +}); + +// ─── Local install: directory form + hook ─────────────────────────────────────── + +describe('#787 Cline local install — directory form + PreToolUse hook', () => { + let tmpDir; + let previousCwd; + + beforeEach(() => { + tmpDir = createTempDir('gsd-787-cline-local-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('writes .clinerules/ as a directory containing gsd.md', () => { + install(false, 'cline'); + const dir = path.join(tmpDir, '.clinerules'); + assert.ok(fs.statSync(dir).isDirectory(), '.clinerules must be a directory'); + const ruleFile = path.join(dir, 'gsd.md'); + assert.ok(fs.existsSync(ruleFile), '.clinerules/gsd.md must exist'); + assert.match(fs.readFileSync(ruleFile, 'utf8'), /gsd-core\/workflows\//); + }); + + test('writes an executable PreToolUse hook with no extension', () => { + install(false, 'cline'); + const hook = path.join(tmpDir, '.clinerules', 'hooks', 'PreToolUse'); + assert.ok(fs.existsSync(hook), '.clinerules/hooks/PreToolUse must exist'); + if (process.platform !== 'win32') { + const mode = fs.statSync(hook).mode; + assert.ok((mode & 0o111) !== 0, 'PreToolUse must be executable'); + } + }); + + test('migrates a legacy single-file .clinerules into the directory form', () => { + // Simulate a pre-#787 install that wrote a .clinerules FILE. + fs.writeFileSync(path.join(tmpDir, '.clinerules'), '# legacy file\n'); + install(false, 'cline'); + const dir = path.join(tmpDir, '.clinerules'); + assert.ok(fs.statSync(dir).isDirectory(), 'legacy file must be replaced by a directory'); + assert.ok(fs.existsSync(path.join(dir, 'gsd.md'))); + }); + + test('does not follow a symlinked .clinerules (writes the real directory in place)', () => { + if (process.platform === 'win32') return; // symlink perms differ on Windows + // Point .clinerules at an external directory via symlink; install must NOT + // write GSD files through the link. + const external = path.join(tmpDir, 'external-target'); + fs.mkdirSync(external); + fs.symlinkSync(external, path.join(tmpDir, '.clinerules')); + install(false, 'cline'); + const dir = path.join(tmpDir, '.clinerules'); + assert.ok(fs.lstatSync(dir).isDirectory() && !fs.lstatSync(dir).isSymbolicLink(), + '.clinerules must be a real directory, not the symlink'); + assert.ok(!fs.existsSync(path.join(external, 'gsd.md')), 'must not write through the symlink target'); + assert.ok(fs.existsSync(path.join(dir, 'gsd.md'))); + }); + + test('manifest tracks the new directory-form artifacts', () => { + install(false, 'cline'); + const manifestPath = path.join(tmpDir, 'gsd-file-manifest.json'); + assert.ok(fs.existsSync(manifestPath)); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + assert.ok(manifest.files['.clinerules/gsd.md'], 'manifest should track .clinerules/gsd.md'); + assert.ok(manifest.files['.clinerules/hooks/PreToolUse'], 'manifest should track the hook'); + }); +}); + +// ─── Global install: ~/.agents/AGENTS.md (subprocess, HOME-isolated) ───────────── + +describe('#787 Cline global install — ~/.agents/AGENTS.md', () => { + function runGlobalClineInstall() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-787-cline-global-')); + const env = { ...process.env, HOME: root, USERPROFILE: root }; + delete env.GSD_TEST_MODE; + const res = spawnSync( + process.execPath, + [INSTALL_SCRIPT, '--cline', '--global', '--config-dir', path.join(root, '.cline')], + { cwd: root, encoding: 'utf8', env }, + ); + return { root, res }; + } + + test('writes ~/.agents/AGENTS.md with a GSD marker block', () => { + const { root, res } = runGlobalClineInstall(); + try { + assert.equal(res.status, 0, `installer failed: ${res.stderr}`); + const agents = path.join(root, '.agents', 'AGENTS.md'); + assert.ok(fs.existsSync(agents), '~/.agents/AGENTS.md must exist after a global Cline install'); + const content = fs.readFileSync(agents, 'utf8'); + assert.ok(content.includes(GSD_AGENTS_MD_MARKER)); + assert.match(content, /GSD/); + } finally { + cleanup(root); + } + }); +}); + +// ─── Uninstall symmetry ───────────────────────────────────────────────────────── + +describe('#787 Cline uninstall removes managed artifacts', () => { + let tmpDir; + let previousCwd; + + beforeEach(() => { + tmpDir = createTempDir('gsd-787-cline-uninstall-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('local uninstall removes .clinerules/gsd.md and the hook', () => { + install(false, 'cline'); + assert.ok(fs.existsSync(path.join(tmpDir, '.clinerules', 'gsd.md'))); + uninstall(false, 'cline'); + assert.ok(!fs.existsSync(path.join(tmpDir, '.clinerules', 'gsd.md')), 'gsd.md should be removed'); + assert.ok(!fs.existsSync(path.join(tmpDir, '.clinerules', 'hooks', 'PreToolUse')), 'hook should be removed'); + }); +});