From d1654960ab41d5664c30dc4b065ef64b960dbc1f Mon Sep 17 00:00:00 2001 From: superresistant Date: Mon, 2 Feb 2026 13:45:50 +0100 Subject: [PATCH] feat(install): respect attribution.commit setting (compatible opencode) (#286) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reads user's attribution settings during install and processes Co-Authored-By lines accordingly - Claude Code: reads `attribution.commit` from `~/.claude/settings.json` - Empty string ("") → removes Co-Authored-By - Custom value ("jojo99") → uses custom attribution - Not set → keeps default - OpenCode: reads `disable_ai_attribution` from `~/.config/opencode/opencode.json` - true → removes Co-Authored-By - Not set → keeps default - Gemini: reads `attribution.commit` from `~/.gemini/settings.json` (same as Claude) - Caches attribution setting per-runtime to avoid repeated file reads - Escapes $ in custom attribution to prevent regex backreference injection Rebased onto v1.11.1 --- bin/install.js | 70 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/bin/install.js b/bin/install.js index 4e59584a4..9734c5f80 100755 --- a/bin/install.js +++ b/bin/install.js @@ -194,6 +194,71 @@ function writeSettings(settingsPath, settings) { fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); } +// Cache for attribution settings (populated once per runtime during install) +const attributionCache = new Map(); + +/** + * Get commit attribution setting for a runtime + * @param {string} runtime - 'claude', 'opencode', or 'gemini' + * @returns {null|undefined|string} null = remove, undefined = keep default, string = custom + */ +function getCommitAttribution(runtime) { + // Return cached value if available + if (attributionCache.has(runtime)) { + return attributionCache.get(runtime); + } + + let result; + + if (runtime === 'opencode') { + const config = readSettings(path.join(getGlobalDir('opencode', null), 'opencode.json')); + result = config.disable_ai_attribution === true ? null : undefined; + } else if (runtime === 'gemini') { + // Gemini: check gemini settings.json for attribution config + const settings = readSettings(path.join(getGlobalDir('gemini', explicitConfigDir), 'settings.json')); + if (!settings.attribution || settings.attribution.commit === undefined) { + result = undefined; + } else if (settings.attribution.commit === '') { + result = null; + } else { + result = settings.attribution.commit; + } + } else { + // Claude Code + const settings = readSettings(path.join(getGlobalDir('claude', explicitConfigDir), 'settings.json')); + if (!settings.attribution || settings.attribution.commit === undefined) { + result = undefined; + } else if (settings.attribution.commit === '') { + result = null; + } else { + result = settings.attribution.commit; + } + } + + // Cache and return + attributionCache.set(runtime, result); + return result; +} + +/** + * Process Co-Authored-By lines based on attribution setting + * @param {string} content - File content to process + * @param {null|undefined|string} attribution - null=remove, undefined=keep, string=replace + * @returns {string} Processed content + */ +function processAttribution(content, attribution) { + if (attribution === null) { + // Remove Co-Authored-By lines and the preceding blank line + return content.replace(/(\r?\n){2}Co-Authored-By:.*$/gim, ''); + } + if (attribution === undefined) { + return content; + } + // Replace with custom attribution (escape $ to prevent backreference injection) + const safeAttribution = attribution.replace(/\$/g, '$$$$'); + return content.replace(/Co-Authored-By:.*$/gim, `Co-Authored-By: ${safeAttribution}`); +} + /** * Convert Claude Code frontmatter to opencode format * - Converts 'allowed-tools:' array to 'permission:' object @@ -560,6 +625,7 @@ function copyFlattenedCommands(srcDir, destDir, prefix, pathPrefix, runtime) { const opencodeDirRegex = /~\/\.opencode\//g; content = content.replace(claudeDirRegex, pathPrefix); content = content.replace(opencodeDirRegex, pathPrefix); + content = processAttribution(content, getCommitAttribution(runtime)); content = convertClaudeToOpencodeFrontmatter(content); fs.writeFileSync(destPath, content); @@ -598,7 +664,8 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime) { let content = fs.readFileSync(srcPath, 'utf8'); const claudeDirRegex = /~\/\.claude\//g; content = content.replace(claudeDirRegex, pathPrefix); - + content = processAttribution(content, getCommitAttribution(runtime)); + // Convert frontmatter for opencode compatibility if (isOpencode) { content = convertClaudeToOpencodeFrontmatter(content); @@ -1081,6 +1148,7 @@ function install(isGlobal, runtime = 'claude') { // Always replace ~/.claude/ as it is the source of truth in the repo const dirRegex = /~\/\.claude\//g; content = content.replace(dirRegex, pathPrefix); + content = processAttribution(content, getCommitAttribution(runtime)); // Convert frontmatter for runtime compatibility if (isOpencode) { content = convertClaudeToOpencodeFrontmatter(content);