* feat(#787): elevate Cline — .clinerules/ dir form, PreToolUse hook, AGENTS.md Migrate the installer's Cline output from a single-file .clinerules to the .clinerules/ directory form (.clinerules/gsd.md), which is the prerequisite for Cline's v3.36 hooks (a path cannot be both a file and a directory). Add a .clinerules/hooks/PreToolUse lifecycle hook implementing Cline's JSON stdin -> {cancel,errorMessage,contextModification} protocol; it guards .planning/ artifacts and fails open. On global installs, merge GSD instructions into the cross-tool ~/.agents/AGENTS.md target (marker-delimited, merge-safe). A legacy single-file .clinerules is migrated in place; --uninstall removes the new artifacts and strips the AGENTS.md GSD block. Also fixes the uninstall targetDir for Cline local installs (it pointed at ./.cline instead of the project root) and re-runs writeManifest after the Cline artifacts are written so they are hash-tracked. Self-contained: implemented independently of the #782 Cline skills work. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#787): address review findings - Scope PreToolUse hook path-walk to PATH_KEY fields only (eliminates false positive when doc body content mentions .planning/) - Use lstatSync + isSymbolicLink() for migration guard so GSD never writes through a user's symlinked .clinerules into an external directory - Add regression tests for both cases Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(#787): set changeset pr: 803 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
5
.changeset/787-cline-hooks-agents.md
Normal file
5
.changeset/787-cline-hooks-agents.md
Normal file
@@ -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)
|
||||
307
bin/install.js
307
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 = '<!-- GSD Configuration — managed by gsd-core installer -->';
|
||||
const GSD_AGENTS_MD_CLOSE_MARKER = '<!-- End GSD Configuration -->';
|
||||
|
||||
/**
|
||||
* 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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
313
tests/issue-787-cline-hooks-agents.test.cjs
Normal file
313
tests/issue-787-cline-hooks-agents.test.cjs
Normal file
@@ -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/<EventName> (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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user