From 23763f920b6c509dd562b986a2af52f32fd8ee89 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 11 Apr 2026 09:15:36 -0400 Subject: [PATCH] feat(config): add configurable claude_md_path setting (#2010) (#2102) Allow users to control where GSD writes its managed CLAUDE.md sections via a `claude_md_path` setting in .planning/config.json, enabling separation of GSD content from team-shared CLAUDE.md in shared repos. Co-authored-by: Claude Sonnet 4.6 --- get-shit-done/bin/lib/config.cjs | 2 + get-shit-done/bin/lib/core.cjs | 3 +- get-shit-done/bin/lib/profile-output.cjs | 18 +- get-shit-done/templates/config.json | 3 +- tests/claude-md-path.test.cjs | 201 +++++++++++++++++++++++ 5 files changed, 222 insertions(+), 5 deletions(-) create mode 100644 tests/claude-md-path.test.cjs diff --git a/get-shit-done/bin/lib/config.cjs b/get-shit-done/bin/lib/config.cjs index 07da8c4e7..756233189 100644 --- a/get-shit-done/bin/lib/config.cjs +++ b/get-shit-done/bin/lib/config.cjs @@ -37,6 +37,7 @@ const VALID_CONFIG_KEYS = new Set([ 'manager.flags.discuss', 'manager.flags.plan', 'manager.flags.execute', 'response_language', 'intel.enabled', + 'claude_md_path', ]); /** @@ -161,6 +162,7 @@ function buildNewProjectConfig(userChoices) { project_code: null, phase_naming: 'sequential', agent_skills: {}, + claude_md_path: './CLAUDE.md', }; // Three-level deep merge: hardcoded <- userDefaults <- choices diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index 1e2f620fb..6e7e1f584 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -325,7 +325,7 @@ function loadConfig(cwd) { // Section containers that hold nested sub-keys 'git', 'workflow', 'planning', 'hooks', 'features', // Internal keys loadConfig reads but config-set doesn't expose - 'model_overrides', 'agent_skills', 'context_window', 'resolve_model_ids', + 'model_overrides', 'agent_skills', 'context_window', 'resolve_model_ids', 'claude_md_path', // Deprecated keys (still accepted for migration, not in config-set) 'depth', 'multiRepo', ]); @@ -386,6 +386,7 @@ function loadConfig(cwd) { agent_skills: parsed.agent_skills || {}, manager: parsed.manager || {}, response_language: get('response_language') || null, + claude_md_path: get('claude_md_path') || null, }; } catch { // Fall back to ~/.gsd/defaults.json only for truly pre-project contexts (#1683) diff --git a/get-shit-done/bin/lib/profile-output.cjs b/get-shit-done/bin/lib/profile-output.cjs index adf5ffaeb..570400799 100644 --- a/get-shit-done/bin/lib/profile-output.cjs +++ b/get-shit-done/bin/lib/profile-output.cjs @@ -12,7 +12,7 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); -const { output, error, safeReadFile } = require('./core.cjs'); +const { output, error, safeReadFile, loadConfig } = require('./core.cjs'); // ─── Constants ──────────────────────────────────────────────────────────────── @@ -870,7 +870,13 @@ function cmdGenerateClaudeProfile(cwd, options, raw) { } else if (options.output) { targetPath = path.isAbsolute(options.output) ? options.output : path.join(cwd, options.output); } else { - targetPath = path.join(cwd, 'CLAUDE.md'); + // Read claude_md_path from config, default to ./CLAUDE.md + let configClaudeMdPath = './CLAUDE.md'; + try { + const config = loadConfig(cwd); + if (config.claude_md_path) configClaudeMdPath = config.claude_md_path; + } catch { /* use default */ } + targetPath = path.isAbsolute(configClaudeMdPath) ? configClaudeMdPath : path.join(cwd, configClaudeMdPath); } let action; @@ -944,7 +950,13 @@ function cmdGenerateClaudeMd(cwd, options, raw) { let outputPath = options.output; if (!outputPath) { - outputPath = path.join(cwd, 'CLAUDE.md'); + // Read claude_md_path from config, default to ./CLAUDE.md + let configClaudeMdPath = './CLAUDE.md'; + try { + const config = loadConfig(cwd); + if (config.claude_md_path) configClaudeMdPath = config.claude_md_path; + } catch { /* use default */ } + outputPath = path.isAbsolute(configClaudeMdPath) ? configClaudeMdPath : path.join(cwd, configClaudeMdPath); } else if (!path.isAbsolute(outputPath)) { outputPath = path.join(cwd, outputPath); } diff --git a/get-shit-done/templates/config.json b/get-shit-done/templates/config.json index a3f699294..71a1f44fa 100644 --- a/get-shit-done/templates/config.json +++ b/get-shit-done/templates/config.json @@ -44,5 +44,6 @@ "context_warnings": true }, "project_code": null, - "agent_skills": {} + "agent_skills": {}, + "claude_md_path": "./CLAUDE.md" } diff --git a/tests/claude-md-path.test.cjs b/tests/claude-md-path.test.cjs new file mode 100644 index 000000000..8789ad76e --- /dev/null +++ b/tests/claude-md-path.test.cjs @@ -0,0 +1,201 @@ +/** + * Tests for configurable claude_md_path setting (#2010) + */ + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +describe('claude_md_path config key', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('claude_md_path is in VALID_CONFIG_KEYS', () => { + const { VALID_CONFIG_KEYS } = require('../get-shit-done/bin/lib/config.cjs'); + assert.ok(VALID_CONFIG_KEYS.has('claude_md_path')); + }); + + test('config template includes claude_md_path', () => { + const templatePath = path.join(__dirname, '..', 'get-shit-done', 'templates', 'config.json'); + const template = JSON.parse(fs.readFileSync(templatePath, 'utf-8')); + assert.strictEqual(template.claude_md_path, './CLAUDE.md'); + }); + + test('config-get claude_md_path returns default value when not set', () => { + // Create a config.json without claude_md_path + const configPath = path.join(tmpDir, '.planning', 'config.json'); + fs.writeFileSync(configPath, JSON.stringify({ mode: 'interactive' }), 'utf-8'); + + const result = runGsdTools('config-get claude_md_path --default ./CLAUDE.md', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `Expected success but got error: ${result.error}`); + assert.strictEqual(JSON.parse(result.output), './CLAUDE.md'); + }); + + test('config-set claude_md_path works', () => { + const configPath = path.join(tmpDir, '.planning', 'config.json'); + fs.writeFileSync(configPath, JSON.stringify({ mode: 'interactive' }), 'utf-8'); + + const setResult = runGsdTools('config-set claude_md_path .claude/CLAUDE.md', tmpDir, { HOME: tmpDir }); + assert.ok(setResult.success, `Expected success but got error: ${setResult.error}`); + + const getResult = runGsdTools('config-get claude_md_path', tmpDir, { HOME: tmpDir }); + assert.ok(getResult.success, `Expected success but got error: ${getResult.error}`); + assert.strictEqual(JSON.parse(getResult.output), '.claude/CLAUDE.md'); + }); + + test('buildNewProjectConfig includes claude_md_path default', () => { + // Use config-new-project which calls buildNewProjectConfig + const result = runGsdTools('config-new-project', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `Expected success but got error: ${result.error}`); + + const configPath = path.join(tmpDir, '.planning', 'config.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + assert.strictEqual(config.claude_md_path, './CLAUDE.md'); + }); +}); + +describe('cmdGenerateClaudeProfile reads claude_md_path from config', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('uses claude_md_path from config when no --output or --global', () => { + // Set up config with custom claude_md_path + const configPath = path.join(tmpDir, '.planning', 'config.json'); + const customPath = '.claude/CLAUDE.md'; + fs.writeFileSync(configPath, JSON.stringify({ claude_md_path: customPath }), 'utf-8'); + + // Create the target directory + fs.mkdirSync(path.join(tmpDir, '.claude'), { recursive: true }); + + // Create a minimal analysis file + const analysisPath = path.join(tmpDir, '.planning', 'analysis.json'); + const analysis = { + dimensions: { + communication_style: { rating: 'terse-direct', confidence: 'HIGH' }, + }, + data_source: 'test', + }; + fs.writeFileSync(analysisPath, JSON.stringify(analysis), 'utf-8'); + + const result = runGsdTools( + ['generate-claude-profile', '--analysis', analysisPath], + tmpDir, + { HOME: tmpDir } + ); + assert.ok(result.success, `Expected success but got error: ${result.error}`); + + const parsed = JSON.parse(result.output); + const realTmpDir = fs.realpathSync(tmpDir); + const expectedPath = path.join(realTmpDir, customPath); + assert.strictEqual(parsed.claude_md_path, expectedPath); + assert.ok(fs.existsSync(expectedPath), `Expected file at ${expectedPath}`); + }); + + test('--output flag overrides claude_md_path from config', () => { + // Set up config with custom claude_md_path + const configPath = path.join(tmpDir, '.planning', 'config.json'); + fs.writeFileSync(configPath, JSON.stringify({ claude_md_path: '.claude/CLAUDE.md' }), 'utf-8'); + + // Create analysis file + const analysisPath = path.join(tmpDir, '.planning', 'analysis.json'); + const analysis = { + dimensions: { + communication_style: { rating: 'terse-direct', confidence: 'HIGH' }, + }, + data_source: 'test', + }; + fs.writeFileSync(analysisPath, JSON.stringify(analysis), 'utf-8'); + + const outputFile = 'custom-output.md'; + const result = runGsdTools( + ['generate-claude-profile', '--analysis', analysisPath, '--output', outputFile], + tmpDir, + { HOME: tmpDir } + ); + assert.ok(result.success, `Expected success but got error: ${result.error}`); + + const parsed = JSON.parse(result.output); + const realTmpDir = fs.realpathSync(tmpDir); + assert.strictEqual(parsed.claude_md_path, path.join(realTmpDir, outputFile)); + }); +}); + +describe('cmdGenerateClaudeMd reads claude_md_path from config', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + // Create minimal project files so generate-claude-md has something to read + fs.writeFileSync( + path.join(tmpDir, '.planning', 'PROJECT.md'), + ['# Test Project', '', 'A test project.'].join('\n'), + 'utf-8' + ); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('uses claude_md_path from config when no --output', () => { + // Set up config with custom claude_md_path + const configPath = path.join(tmpDir, '.planning', 'config.json'); + const customPath = '.claude/CLAUDE.md'; + fs.writeFileSync(configPath, JSON.stringify({ claude_md_path: customPath }), 'utf-8'); + + // Create the target directory + fs.mkdirSync(path.join(tmpDir, '.claude'), { recursive: true }); + + const result = runGsdTools('generate-claude-md', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `Expected success but got error: ${result.error}`); + + const parsed = JSON.parse(result.output); + const realTmpDir = fs.realpathSync(tmpDir); + const expectedPath = path.join(realTmpDir, customPath); + assert.strictEqual(parsed.claude_md_path, expectedPath); + assert.ok(fs.existsSync(expectedPath), `Expected file at ${expectedPath}`); + }); + + test('--output flag overrides claude_md_path from config', () => { + // Set up config with custom claude_md_path + const configPath = path.join(tmpDir, '.planning', 'config.json'); + fs.writeFileSync(configPath, JSON.stringify({ claude_md_path: '.claude/CLAUDE.md' }), 'utf-8'); + + const outputFile = 'my-custom.md'; + const result = runGsdTools(['generate-claude-md', '--output', outputFile], tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `Expected success but got error: ${result.error}`); + + const parsed = JSON.parse(result.output); + const realTmpDir = fs.realpathSync(tmpDir); + assert.strictEqual(parsed.claude_md_path, path.join(realTmpDir, outputFile)); + }); + + test('defaults to ./CLAUDE.md when config has no claude_md_path', () => { + // Set up config without claude_md_path + const configPath = path.join(tmpDir, '.planning', 'config.json'); + fs.writeFileSync(configPath, JSON.stringify({ mode: 'interactive' }), 'utf-8'); + + const result = runGsdTools('generate-claude-md', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `Expected success but got error: ${result.error}`); + + const parsed = JSON.parse(result.output); + const realTmpDir = fs.realpathSync(tmpDir); + assert.strictEqual(parsed.claude_md_path, path.join(realTmpDir, 'CLAUDE.md')); + }); +});