diff --git a/.cursor/rules/common-git-workflow.md b/.cursor/rules/common-git-workflow.md
index 591d45ddf..6d71b0a47 100644
--- a/.cursor/rules/common-git-workflow.md
+++ b/.cursor/rules/common-git-workflow.md
@@ -13,7 +13,7 @@ alwaysApply: true
Types: feat, fix, refactor, docs, test, chore, perf, ci
-Note: To disable co-author attribution on commits, set `"includeCoAuthoredBy": false` in `~/.claude/settings.json` (Claude Code appends `Co-Authored-By` by default; ECC does not ship this setting).
+Note: ECC-managed installs set `"includeCoAuthoredBy": false` in `~/.claude/settings.json`, so commits carry no `Co-Authored-By` trailer by default. To keep Claude attribution, set `"includeCoAuthoredBy": true` or configure `attribution`; ECC never overwrites an explicit choice.
## Pull Request Workflow
diff --git a/.kiro/steering/git-workflow.md b/.kiro/steering/git-workflow.md
index 9fee1ab20..b78b2163d 100644
--- a/.kiro/steering/git-workflow.md
+++ b/.kiro/steering/git-workflow.md
@@ -15,7 +15,7 @@ description: Git workflow guidelines for conventional commits and pull request p
Types: feat, fix, refactor, docs, test, chore, perf, ci
-Note: To disable co-author attribution on commits, set `"includeCoAuthoredBy": false` in `~/.claude/settings.json` (Claude Code appends `Co-Authored-By` by default; ECC does not ship this setting).
+Note: ECC-managed installs set `"includeCoAuthoredBy": false` in `~/.claude/settings.json`, so commits carry no `Co-Authored-By` trailer by default. To keep Claude attribution, set `"includeCoAuthoredBy": true` or configure `attribution`; ECC never overwrites an explicit choice.
## Pull Request Workflow
diff --git a/docs/es/rules/common/git-workflow.md b/docs/es/rules/common/git-workflow.md
index 3b48b772e..3806dab70 100644
--- a/docs/es/rules/common/git-workflow.md
+++ b/docs/es/rules/common/git-workflow.md
@@ -9,7 +9,7 @@
Tipos: feat, fix, refactor, docs, test, chore, perf, ci
-Nota: Para desactivar la atribución de coautoría, configure `"includeCoAuthoredBy": false` en `~/.claude/settings.json`; Claude Code agrega `Co-Authored-By` de forma predeterminada y ECC no incluye esta configuración.
+Nota: Las instalaciones gestionadas por ECC configuran `"includeCoAuthoredBy": false` en `~/.claude/settings.json`, por lo que los commits no incluyen `Co-Authored-By` de forma predeterminada. Para conservar la atribución de Claude, configure `"includeCoAuthoredBy": true` o `attribution`; ECC nunca sobrescribe una elección explícita.
## Flujo de Trabajo de Pull Request
diff --git a/docs/ja-JP/rules/common/git-workflow.md b/docs/ja-JP/rules/common/git-workflow.md
index 7fb2e7f1e..6767d0725 100644
--- a/docs/ja-JP/rules/common/git-workflow.md
+++ b/docs/ja-JP/rules/common/git-workflow.md
@@ -10,7 +10,7 @@
タイプ: feat, fix, refactor, docs, test, chore, perf, ci
-注記: コミットの共同作成者の属性を無効にするには、`~/.claude/settings.json` に `"includeCoAuthoredBy": false` を設定します。Claude Code は既定で `Co-Authored-By` を付与し、ECC はこの設定を同梱しません。
+注記: ECC が管理するインストールは `~/.claude/settings.json` に `"includeCoAuthoredBy": false` を設定するため、コミットには既定で `Co-Authored-By` が付きません。Claude の属性を残す場合は `"includeCoAuthoredBy": true` または `attribution` を設定してください。ECC は明示的な設定を上書きしません。
## Pull Request ワークフロー
diff --git a/docs/ko-KR/rules/git-workflow.md b/docs/ko-KR/rules/git-workflow.md
index 9ad47756c..dbf30ee3e 100644
--- a/docs/ko-KR/rules/git-workflow.md
+++ b/docs/ko-KR/rules/git-workflow.md
@@ -9,7 +9,7 @@
타입: feat, fix, refactor, docs, test, chore, perf, ci
-참고: 공동 작성자 표기를 비활성화하려면 `~/.claude/settings.json`에 `"includeCoAuthoredBy": false`를 설정하세요. Claude Code는 기본적으로 `Co-Authored-By`를 추가하며 ECC는 이 설정을 포함하지 않습니다.
+참고: ECC가 관리하는 설치는 `~/.claude/settings.json`에 `"includeCoAuthoredBy": false`를 설정하므로 커밋에 기본적으로 `Co-Authored-By`가 붙지 않습니다. Claude 표기를 유지하려면 `"includeCoAuthoredBy": true`를 설정하거나 `attribution`을 구성하세요. ECC는 명시적인 선택을 덮어쓰지 않습니다.
## Pull Request 워크플로우
diff --git a/docs/pt-BR/rules/git-workflow.md b/docs/pt-BR/rules/git-workflow.md
index 761ce3e2c..5b75622b1 100644
--- a/docs/pt-BR/rules/git-workflow.md
+++ b/docs/pt-BR/rules/git-workflow.md
@@ -9,7 +9,7 @@
Tipos: feat, fix, refactor, docs, test, chore, perf, ci
-Nota: Para desativar a atribuição de coautoria, defina `"includeCoAuthoredBy": false` em `~/.claude/settings.json`; o Claude Code adiciona `Co-Authored-By` por padrão e o ECC não inclui essa configuração.
+Nota: As instalações gerenciadas pelo ECC definem `"includeCoAuthoredBy": false` em `~/.claude/settings.json`, portanto os commits não incluem `Co-Authored-By` por padrão. Para manter a atribuição do Claude, defina `"includeCoAuthoredBy": true` ou configure `attribution`; o ECC nunca sobrescreve uma escolha explícita.
## Fluxo de Trabalho de Pull Request
diff --git a/docs/tr/rules/common/git-workflow.md b/docs/tr/rules/common/git-workflow.md
index 25b71cab9..5fab67267 100644
--- a/docs/tr/rules/common/git-workflow.md
+++ b/docs/tr/rules/common/git-workflow.md
@@ -9,7 +9,7 @@
Types: feat, fix, refactor, docs, test, chore, perf, ci
-Not: Ortak yazar atfını devre dışı bırakmak için `~/.claude/settings.json` içinde `"includeCoAuthoredBy": false` ayarlayın; Claude Code varsayılan olarak `Co-Authored-By` ekler ve ECC bu ayarı içermez.
+Not: ECC tarafından yönetilen kurulumlar `~/.claude/settings.json` içinde `"includeCoAuthoredBy": false` ayarlar, bu nedenle commitler varsayılan olarak `Co-Authored-By` içermez. Claude atfını korumak için `"includeCoAuthoredBy": true` veya `attribution` ayarlayın; ECC açık bir tercihin üzerine asla yazmaz.
## Pull Request İş Akışı
diff --git a/docs/zh-CN/rules/common/git-workflow.md b/docs/zh-CN/rules/common/git-workflow.md
index a575ee137..4cb6f079a 100644
--- a/docs/zh-CN/rules/common/git-workflow.md
+++ b/docs/zh-CN/rules/common/git-workflow.md
@@ -10,7 +10,7 @@
类型:feat, fix, refactor, docs, test, chore, perf, ci
-注意:若要禁用共同作者归因,请在 `~/.claude/settings.json` 中设置 `"includeCoAuthoredBy": false`;Claude Code 默认会附加 `Co-Authored-By`,而 ECC 不会附带此设置。
+注意:ECC 管理的安装会在 `~/.claude/settings.json` 中设置 `"includeCoAuthoredBy": false`,因此提交默认不带 `Co-Authored-By`。若要保留 Claude 的归因,请设置 `"includeCoAuthoredBy": true` 或配置 `attribution`;ECC 不会覆盖用户的显式选择。
## 拉取请求工作流程
diff --git a/docs/zh-TW/rules/git-workflow.md b/docs/zh-TW/rules/git-workflow.md
index 415a6b491..8c5dbb3ad 100644
--- a/docs/zh-TW/rules/git-workflow.md
+++ b/docs/zh-TW/rules/git-workflow.md
@@ -10,7 +10,7 @@
類型:feat、fix、refactor、docs、test、chore、perf、ci
-注意:若要停用共同作者歸屬,請在 `~/.claude/settings.json` 中設定 `"includeCoAuthoredBy": false`;Claude Code 預設會附加 `Co-Authored-By`,而 ECC 不會隨附這個設定。
+注意:ECC 管理的安裝會在 `~/.claude/settings.json` 中設定 `"includeCoAuthoredBy": false`,因此提交預設不會附帶 `Co-Authored-By`。若要保留 Claude 的歸屬,請設定 `"includeCoAuthoredBy": true` 或設定 `attribution`;ECC 不會覆寫使用者的明確選擇。
## Pull Request 工作流程
diff --git a/rules/common/git-workflow.md b/rules/common/git-workflow.md
index 304fba798..29a72e2ae 100644
--- a/rules/common/git-workflow.md
+++ b/rules/common/git-workflow.md
@@ -9,7 +9,7 @@
Types: feat, fix, refactor, docs, test, chore, perf, ci
-Note: To disable co-author attribution on commits, set `"includeCoAuthoredBy": false` in `~/.claude/settings.json` (Claude Code appends `Co-Authored-By` by default; ECC does not ship this setting).
+Note: ECC-managed installs set `"includeCoAuthoredBy": false` in `~/.claude/settings.json`, so commits carry no `Co-Authored-By` trailer by default. To keep Claude attribution, set `"includeCoAuthoredBy": true` or configure `attribution`; ECC never overwrites an explicit choice.
## Pull Request Workflow
diff --git a/scripts/lib/claude-commit-attribution.js b/scripts/lib/claude-commit-attribution.js
new file mode 100644
index 000000000..cac44d52a
--- /dev/null
+++ b/scripts/lib/claude-commit-attribution.js
@@ -0,0 +1,43 @@
+'use strict';
+
+// Claude Code appends a `Co-Authored-By` trailer to commits and PRs unless the
+// user opts out, so ECC-managed installs default that off.
+//
+// Two settings control the trailer. `attribution: { commit, pr }` is the current
+// one and wins when set; `includeCoAuthoredBy` is deprecated as of Claude Code
+// 2.1.x but still honored, and is the only one older versions understand. We
+// write the deprecated key because unknown keys fail settings validation, so
+// writing `attribution` would break users on older Claude Code. Either key being
+// present counts as a deliberate user choice that ECC must not overwrite.
+const COAUTHOR_SETTING_KEY = 'includeCoAuthoredBy';
+
+function hasExplicitCommitAttributionPreference(settings) {
+ if (!settings || typeof settings !== 'object') {
+ return false;
+ }
+ if (typeof settings[COAUTHOR_SETTING_KEY] === 'boolean') {
+ return true;
+ }
+
+ const attribution = settings.attribution;
+ return Boolean(attribution)
+ && typeof attribution === 'object'
+ && !Array.isArray(attribution)
+ && (attribution.commit !== undefined || attribution.pr !== undefined);
+}
+
+function withCommitAttributionDisabled(settings) {
+ if (hasExplicitCommitAttributionPreference(settings)) {
+ return settings;
+ }
+ return {
+ ...settings,
+ [COAUTHOR_SETTING_KEY]: false,
+ };
+}
+
+module.exports = {
+ COAUTHOR_SETTING_KEY,
+ hasExplicitCommitAttributionPreference,
+ withCommitAttributionDisabled,
+};
diff --git a/scripts/lib/claude-plugin-setup.js b/scripts/lib/claude-plugin-setup.js
index 45fe3a9e0..ac1bdd4aa 100644
--- a/scripts/lib/claude-plugin-setup.js
+++ b/scripts/lib/claude-plugin-setup.js
@@ -5,6 +5,10 @@ const path = require('path');
const { spawnSync } = require('child_process');
const { writeFileAtomic } = require('./atomic-write');
+const {
+ hasExplicitCommitAttributionPreference,
+ withCommitAttributionDisabled,
+} = require('./claude-commit-attribution');
const { normalizeGitHubGitOrigin } = require('./github-origin');
const {
CURRENT_PLUGIN_ID,
@@ -320,21 +324,32 @@ function deriveHookMode(settings) {
return options.hooks_enabled ? options.hook_profile : 'off';
}
+function withClaudeCommitAttributionPreference(settings) {
+ return withCommitAttributionDisabled(settings);
+}
+
+function needsClaudeCommitAttributionPreferenceWrite(settings) {
+ return !hasExplicitCommitAttributionPreference(settings);
+}
+
function writeClaudePluginOptions(settingsPath, hooks) {
const settings = readSettings(settingsPath);
const pluginConfigs = settings.pluginConfigs || {};
const eccConfig = pluginConfigs[CURRENT_PLUGIN_ID] || {};
const options = eccConfig.options || {};
+ const nextOptions = hooks === undefined
+ ? { ...options }
+ : {
+ ...options,
+ ...hookOptions(hooks),
+ };
const nextSettings = {
- ...settings,
+ ...withClaudeCommitAttributionPreference(settings),
pluginConfigs: {
...pluginConfigs,
[CURRENT_PLUGIN_ID]: {
...eccConfig,
- options: {
- ...options,
- ...hookOptions(hooks),
- },
+ options: nextOptions,
},
},
};
@@ -609,8 +624,15 @@ function setupClaudePlugin(options = {}, dependencies = {}) {
run,
scope: inventory.scope,
});
- if (options.hooks !== undefined || !inventory.installed) {
- writeClaudePluginOptions(settingsPath, hooks);
+ const hooksToPersist = options.hooks !== undefined || !inventory.installed
+ ? hooks
+ : undefined;
+ if (
+ options.hooks !== undefined
+ || !inventory.installed
+ || needsClaudeCommitAttributionPreferenceWrite(initialSettings)
+ ) {
+ writeClaudePluginOptions(settingsPath, hooksToPersist);
}
return {
@@ -648,5 +670,7 @@ module.exports = {
runClaude,
setupClaudePlugin,
verifyPluginAtScope,
+ needsClaudeCommitAttributionPreferenceWrite,
+ withClaudeCommitAttributionPreference,
writeClaudePluginOptions,
};
diff --git a/scripts/lib/claude-scope-migration.js b/scripts/lib/claude-scope-migration.js
index ae7b926a6..8c442685f 100644
--- a/scripts/lib/claude-scope-migration.js
+++ b/scripts/lib/claude-scope-migration.js
@@ -16,6 +16,7 @@ const {
ensurePluginAtScope,
hookOptions,
isOfficialMarketplace,
+ needsClaudeCommitAttributionPreferenceWrite,
parseMarketplaceList,
parsePluginList,
readSettings,
@@ -264,6 +265,7 @@ function migrateClaudePluginScope(options = {}, dependencies = {}) {
const hookConfiguration = options.hooks === undefined
? readStoredHookOptions(settings)
: hookOptions(options.hooks);
+ const needsCommitAttributionPreference = needsClaudeCommitAttributionPreferenceWrite(settings);
const marketplaces = parseMarketplaceList(
run(
@@ -296,14 +298,23 @@ function migrateClaudePluginScope(options = {}, dependencies = {}) {
...result,
dryRun: true,
preferencesUpdated: false,
- plannedActions: options.hooks === undefined ? [] : [{
- action: 'write-hook-preferences',
- ...hookConfiguration,
- }],
+ plannedActions: [
+ ...(options.hooks === undefined ? [] : [{
+ action: 'write-hook-preferences',
+ ...hookConfiguration,
+ }]),
+ ...(needsCommitAttributionPreference ? [{
+ action: 'write-commit-attribution-preference',
+ includeCoAuthoredBy: false,
+ }] : []),
+ ],
};
}
- if (options.hooks !== undefined) {
- writeClaudePluginOptions(settingsPath, options.hooks);
+ if (options.hooks !== undefined || needsCommitAttributionPreference) {
+ writeClaudePluginOptions(
+ settingsPath,
+ options.hooks !== undefined ? options.hooks : undefined
+ );
return { ...result, preferencesUpdated: true };
}
return result;
@@ -371,8 +382,11 @@ function migrateClaudePluginScope(options = {}, dependencies = {}) {
const warnings = uninstallSource(run, paths, migration, options.scope);
verifyFinalState(run, paths, options.scope);
- if (options.hooks !== undefined) {
- writeClaudePluginOptions(settingsPath, options.hooks);
+ if (options.hooks !== undefined || needsCommitAttributionPreference) {
+ writeClaudePluginOptions(
+ settingsPath,
+ options.hooks !== undefined ? options.hooks : undefined
+ );
}
const result = {
diff --git a/scripts/lib/install/apply.js b/scripts/lib/install/apply.js
index 659ad18eb..91c70e7fe 100644
--- a/scripts/lib/install/apply.js
+++ b/scripts/lib/install/apply.js
@@ -4,6 +4,10 @@ const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
+const {
+ hasExplicitCommitAttributionPreference,
+ withCommitAttributionDisabled,
+} = require('../claude-commit-attribution');
const { writeInstallState } = require('../install-state');
const { filterMcpConfig, parseDisabledMcpServers } = require('../mcp-config');
const { assertWithinTrustedRoot } = require('../path-safety');
@@ -105,6 +109,52 @@ function formatJson(value) {
return `${JSON.stringify(value, null, 2)}\n`;
}
+function shouldSetClaudeCommitAttributionPreference(plan) {
+ if (!plan?.adapter || !['claude', 'claude-project'].includes(plan.adapter.target)) {
+ return false;
+ }
+
+ return plan.operations.some(operation => {
+ if (typeof operation?.destinationPath !== 'string') {
+ return false;
+ }
+ const relativePath = path.relative(plan.targetRoot, operation.destinationPath);
+ return relativePath && !relativePath.startsWith(`docs${path.sep}`) && relativePath !== 'docs';
+ });
+}
+
+function writeClaudeCommitAttributionPreference(settingsPath) {
+ // Read once rather than probing with existsSync first. Checking for the file and
+ // then writing it is a file system race (CodeQL js/file-system-race), and a
+ // missing file is simply the fresh-install case.
+ let settings;
+ try {
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
+ } catch (error) {
+ if (error.code !== 'ENOENT') {
+ // Unreadable or malformed settings belong to the user; leave them untouched.
+ return false;
+ }
+ settings = {};
+ }
+
+ if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
+ return false;
+ }
+
+ if (hasExplicitCommitAttributionPreference(settings)) {
+ return false;
+ }
+
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
+ fs.writeFileSync(
+ settingsPath,
+ formatJson(withCommitAttributionDisabled(settings)),
+ 'utf8'
+ );
+ return true;
+}
+
function replacePluginRootPlaceholders(value, pluginRoot) {
if (!pluginRoot) {
return value;
@@ -325,6 +375,11 @@ function applyInstallPlan(plan, dependencies = {}) {
if (hasLegacyMigration) {
removeLegacyClaudeSkillFiles(migration, plan.targetRoot);
}
+
+ if (shouldSetClaudeCommitAttributionPreference(appliedPlan)) {
+ writeClaudeCommitAttributionPreference(path.join(plan.targetRoot, 'settings.json'));
+ }
+
const finalState = stateWithContentDigests(migration.finalState);
if (typeof beforeInstallStateWrite === 'function') {
beforeInstallStateWrite({ plan: appliedPlan, state: finalState });
diff --git a/tests/lib/claude-commit-attribution.test.js b/tests/lib/claude-commit-attribution.test.js
new file mode 100644
index 000000000..60255757f
--- /dev/null
+++ b/tests/lib/claude-commit-attribution.test.js
@@ -0,0 +1,77 @@
+'use strict';
+
+const assert = require('assert');
+const {
+ hasExplicitCommitAttributionPreference,
+ withCommitAttributionDisabled,
+} = require('../../scripts/lib/claude-commit-attribution');
+
+let passed = 0;
+let failed = 0;
+
+function test(name, fn) {
+ try {
+ fn();
+ console.log(` ✓ ${name}`);
+ return true;
+ } catch (error) {
+ console.log(` ✗ ${name}`);
+ console.log(` Error: ${error.message}`);
+ return false;
+ }
+}
+
+console.log('\nClaude commit attribution preference');
+
+if (test('treats absent settings as unconfigured', () => {
+ assert.strictEqual(hasExplicitCommitAttributionPreference(undefined), false);
+ assert.strictEqual(hasExplicitCommitAttributionPreference(null), false);
+ assert.strictEqual(hasExplicitCommitAttributionPreference({}), false);
+ assert.strictEqual(hasExplicitCommitAttributionPreference({ theme: 'dark' }), false);
+})) passed++; else failed++;
+
+if (test('treats either includeCoAuthoredBy boolean as an explicit choice', () => {
+ assert.strictEqual(hasExplicitCommitAttributionPreference({ includeCoAuthoredBy: true }), true);
+ assert.strictEqual(hasExplicitCommitAttributionPreference({ includeCoAuthoredBy: false }), true);
+})) passed++; else failed++;
+
+if (test('treats a configured attribution as an explicit choice', () => {
+ // `attribution` supersedes `includeCoAuthoredBy` in Claude Code, so a user who
+ // set it has already decided and ECC must not write a key that loses to it.
+ assert.strictEqual(hasExplicitCommitAttributionPreference({ attribution: { commit: '' } }), true);
+ assert.strictEqual(hasExplicitCommitAttributionPreference({ attribution: { pr: '' } }), true);
+ assert.strictEqual(
+ hasExplicitCommitAttributionPreference({ attribution: { commit: 'Co-Authored-By: Someone ' } }),
+ true
+ );
+})) passed++; else failed++;
+
+if (test('ignores attribution values that carry no commit or pr choice', () => {
+ assert.strictEqual(hasExplicitCommitAttributionPreference({ attribution: {} }), false);
+ assert.strictEqual(hasExplicitCommitAttributionPreference({ attribution: null }), false);
+ assert.strictEqual(hasExplicitCommitAttributionPreference({ attribution: [] }), false);
+ assert.strictEqual(hasExplicitCommitAttributionPreference({ attribution: 'off' }), false);
+ assert.strictEqual(hasExplicitCommitAttributionPreference({ attribution: { sessionUrl: false } }), false);
+})) passed++; else failed++;
+
+if (test('disables attribution while preserving unrelated settings', () => {
+ assert.deepStrictEqual(
+ withCommitAttributionDisabled({ theme: 'dark' }),
+ { theme: 'dark', includeCoAuthoredBy: false }
+ );
+ assert.deepStrictEqual(withCommitAttributionDisabled({}), { includeCoAuthoredBy: false });
+})) passed++; else failed++;
+
+if (test('returns explicit settings unchanged', () => {
+ const optIn = { includeCoAuthoredBy: true };
+ assert.strictEqual(withCommitAttributionDisabled(optIn), optIn);
+
+ const alreadyOff = { includeCoAuthoredBy: false };
+ assert.strictEqual(withCommitAttributionDisabled(alreadyOff), alreadyOff);
+
+ const customAttribution = { attribution: { commit: 'Signed-off-by: Someone ' } };
+ assert.strictEqual(withCommitAttributionDisabled(customAttribution), customAttribution);
+})) passed++; else failed++;
+
+console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
+process.exit(failed > 0 ? 1 : 0);
diff --git a/tests/lib/claude-plugin-setup.test.js b/tests/lib/claude-plugin-setup.test.js
index 99efa54c0..bd7ed2db1 100644
--- a/tests/lib/claude-plugin-setup.test.js
+++ b/tests/lib/claude-plugin-setup.test.js
@@ -260,6 +260,7 @@ test('an existing single-scope install defaults to its detected scope', () => {
['plugin', 'list', '--json'],
]);
const settings = JSON.parse(fs.readFileSync(fixture.settingsPath, 'utf8'));
+ assert.strictEqual(settings.includeCoAuthoredBy, false);
assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hook_profile, 'minimal');
});
});
@@ -345,6 +346,7 @@ test('same-scope repeat setup updates ECC and changes durable user hook preferen
setupClaudePlugin(setupOptions(fixture, { scope: 'local', hooks: 'off' }));
const settings = JSON.parse(fs.readFileSync(fixture.settingsPath, 'utf8'));
assert.strictEqual(settings.theme, 'dark');
+ assert.strictEqual(settings.includeCoAuthoredBy, false);
assert.deepStrictEqual(settings.pluginConfigs['another@market'], { enabled: false });
assert.deepStrictEqual(settings.pluginConfigs['ecc@ecc'].futureKey, { keep: true });
assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.unknown, 'keep');
@@ -373,11 +375,63 @@ test('repeat setup preserves the current hook preference when --hooks is omitted
const result = setupClaudePlugin(setupOptions(fixture, { hooks: undefined }));
const settings = JSON.parse(fs.readFileSync(fixture.settingsPath, 'utf8'));
assert.strictEqual(result.hooks, 'off');
+ assert.strictEqual(settings.includeCoAuthoredBy, false);
assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hooks_enabled, false);
assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hook_profile, 'strict');
});
});
+test('setup preserves an explicit includeCoAuthoredBy opt-in', () => {
+ withFixture({
+ plugins: [installedPlugin('user')],
+ marketplaces: [officialMarketplace('user')],
+ }, fixture => {
+ fs.writeFileSync(fixture.settingsPath, `${JSON.stringify({
+ includeCoAuthoredBy: true,
+ pluginConfigs: {
+ 'ecc@ecc': {
+ options: {
+ hooks_enabled: true,
+ hook_profile: 'minimal',
+ },
+ },
+ },
+ }, null, 2)}\n`);
+
+ setupClaudePlugin(setupOptions(fixture, { hooks: 'strict' }));
+ const settings = JSON.parse(fs.readFileSync(fixture.settingsPath, 'utf8'));
+ assert.strictEqual(settings.includeCoAuthoredBy, true);
+ assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hook_profile, 'strict');
+ });
+});
+
+test('setup preserves an explicit attribution opt-in', () => {
+ withFixture({
+ plugins: [installedPlugin('user')],
+ marketplaces: [officialMarketplace('user')],
+ }, fixture => {
+ // `attribution` wins over `includeCoAuthoredBy` in Claude Code, so ECC must not
+ // add a deprecated key that would silently lose to the user's own setting.
+ fs.writeFileSync(fixture.settingsPath, `${JSON.stringify({
+ attribution: { commit: 'Signed-off-by: Someone ' },
+ pluginConfigs: {
+ 'ecc@ecc': {
+ options: {
+ hooks_enabled: true,
+ hook_profile: 'minimal',
+ },
+ },
+ },
+ }, null, 2)}\n`);
+
+ setupClaudePlugin(setupOptions(fixture, { hooks: 'strict' }));
+ const settings = JSON.parse(fs.readFileSync(fixture.settingsPath, 'utf8'));
+ assert.strictEqual(settings.includeCoAuthoredBy, undefined);
+ assert.deepStrictEqual(settings.attribution, { commit: 'Signed-off-by: Someone ' });
+ assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hook_profile, 'strict');
+ });
+});
+
test('malformed user settings fail preflight without provider mutation or corruption', () => {
withFixture({}, fixture => {
const malformed = '{"theme":';
diff --git a/tests/lib/claude-scope-migration.test.js b/tests/lib/claude-scope-migration.test.js
index f7fe98f59..3936afe8b 100644
--- a/tests/lib/claude-scope-migration.test.js
+++ b/tests/lib/claude-scope-migration.test.js
@@ -321,11 +321,17 @@ test('destination-only migration honors explicit hook preferences and reports dr
assert.strictEqual(result.action, 'already-migrated');
assert.strictEqual(result.dryRun, true);
assert.strictEqual(result.preferencesUpdated, false);
- assert.deepStrictEqual(result.plannedActions, [{
- action: 'write-hook-preferences',
- hooks_enabled: false,
- hook_profile: 'standard',
- }]);
+ assert.deepStrictEqual(result.plannedActions, [
+ {
+ action: 'write-hook-preferences',
+ hooks_enabled: false,
+ hook_profile: 'standard',
+ },
+ {
+ action: 'write-commit-attribution-preference',
+ includeCoAuthoredBy: false,
+ },
+ ]);
assert.ok(!fs.existsSync(fixture.settingsPath));
});
});
@@ -614,7 +620,10 @@ test('migration preserves hook preferences unless --hooks is explicit', () => {
assert.strictEqual(result.hooks, 'off');
assert.deepStrictEqual(
JSON.parse(fs.readFileSync(fixture.settingsPath, 'utf8')),
- original
+ {
+ ...original,
+ includeCoAuthoredBy: false,
+ }
);
assert.ok(readCalls(fixture).some(argv => (
JSON.stringify(argv) === JSON.stringify(installArgv('project', 'off', 'strict'))
@@ -637,6 +646,7 @@ test('migration preserves hook preferences unless --hooks is explicit', () => {
migrateClaudePluginScope(migrationOptions(fixture, 'project', { hooks: 'strict' }));
const settings = JSON.parse(fs.readFileSync(fixture.settingsPath, 'utf8'));
assert.strictEqual(settings.theme, 'dark');
+ assert.strictEqual(settings.includeCoAuthoredBy, false);
assert.strictEqual(settings.pluginConfigs['ecc@ecc'].futureKey, true);
assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.unknown, 'keep');
assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hooks_enabled, true);
@@ -644,5 +654,26 @@ test('migration preserves hook preferences unless --hooks is explicit', () => {
});
});
+test('migration preserves an explicit includeCoAuthoredBy opt-in', () => {
+ withFixture({
+ plugins: [plugin('user')],
+ marketplaces: [marketplace('user')],
+ }, fixture => {
+ fs.writeFileSync(fixture.settingsPath, `${JSON.stringify({
+ includeCoAuthoredBy: true,
+ pluginConfigs: {
+ 'ecc@ecc': {
+ options: { hooks_enabled: true, hook_profile: 'minimal' },
+ },
+ },
+ }, null, 2)}\n`);
+
+ migrateClaudePluginScope(migrationOptions(fixture, 'project', { hooks: 'strict' }));
+ const settings = JSON.parse(fs.readFileSync(fixture.settingsPath, 'utf8'));
+ assert.strictEqual(settings.includeCoAuthoredBy, true);
+ assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hook_profile, 'strict');
+ });
+});
+
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
diff --git a/tests/scripts/install-apply.test.js b/tests/scripts/install-apply.test.js
index 970c016e1..d8b4ec516 100644
--- a/tests/scripts/install-apply.test.js
+++ b/tests/scripts/install-apply.test.js
@@ -681,7 +681,7 @@ function runTests() {
assert.ok(result.stderr.includes('Unknown install module: ghost-module'));
})) passed++; else failed++;
- if (test('installs claude hooks without generating settings.json', () => {
+ if (test('installs claude hooks and defaults commit attribution off', () => {
const homeDir = createTempDir('install-apply-home-');
const projectDir = createTempDir('install-apply-project-');
@@ -691,7 +691,10 @@ function runTests() {
const claudeRoot = path.join(homeDir, '.claude');
assert.ok(fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json')), 'hooks.json should be copied');
- assert.ok(!fs.existsSync(path.join(claudeRoot, 'settings.json')), 'settings.json should not be created just to install managed hooks');
+ assert.deepStrictEqual(
+ readJson(path.join(claudeRoot, 'settings.json')),
+ { includeCoAuthoredBy: false }
+ );
} finally {
cleanup(homeDir);
cleanup(projectDir);
@@ -742,7 +745,7 @@ function runTests() {
}
})) passed++; else failed++;
- if (test('preserves existing settings.json without mutating it during claude install', () => {
+ if (test('preserves existing settings.json while disabling Claude co-author attribution', () => {
const homeDir = createTempDir('install-apply-home-');
const projectDir = createTempDir('install-apply-project-');
@@ -766,6 +769,7 @@ function runTests() {
const settings = readJson(path.join(claudeRoot, 'settings.json'));
assert.strictEqual(settings.effortLevel, 'high', 'existing effortLevel should be preserved');
+ assert.strictEqual(settings.includeCoAuthoredBy, false, 'Claude co-author attribution should be disabled by default');
assert.deepStrictEqual(settings.env, { MY_VAR: '1' }, 'existing env should be preserved');
assert.deepStrictEqual(
settings.hooks.UserPromptSubmit,
@@ -857,7 +861,7 @@ function runTests() {
}
})) passed++; else failed++;
- if (test('reinstall does not create settings.json when only managed hooks are installed', () => {
+ if (test('reinstall keeps commit attribution disabled when only managed hooks are installed', () => {
const homeDir = createTempDir('install-apply-home-');
const projectDir = createTempDir('install-apply-project-');
@@ -868,14 +872,17 @@ function runTests() {
const secondInstall = run(['--profile', 'core'], { cwd: projectDir, homeDir });
assert.strictEqual(secondInstall.code, 0, secondInstall.stderr);
- assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'settings.json')));
+ assert.deepStrictEqual(
+ readJson(path.join(homeDir, '.claude', 'settings.json')),
+ { includeCoAuthoredBy: false }
+ );
} finally {
cleanup(homeDir);
cleanup(projectDir);
}
})) passed++; else failed++;
- if (test('reinstall leaves pre-existing hook-based settings.json untouched', () => {
+ if (test('reinstall leaves pre-existing hook-based settings.json untouched apart from co-author preference', () => {
const homeDir = createTempDir('install-apply-home-');
const projectDir = createTempDir('install-apply-project-');
@@ -894,7 +901,62 @@ function runTests() {
assert.strictEqual(secondInstall.code, 0, secondInstall.stderr);
const afterSecondInstall = readJson(settingsPath);
- assert.deepStrictEqual(afterSecondInstall, legacySettings);
+ assert.deepStrictEqual(afterSecondInstall, {
+ ...legacySettings,
+ includeCoAuthoredBy: false,
+ });
+ } finally {
+ cleanup(homeDir);
+ cleanup(projectDir);
+ }
+ })) passed++; else failed++;
+
+ if (test('reinstall preserves an explicit includeCoAuthoredBy opt-in', () => {
+ const homeDir = createTempDir('install-apply-home-');
+ const projectDir = createTempDir('install-apply-project-');
+
+ try {
+ const claudeRoot = path.join(homeDir, '.claude');
+ fs.mkdirSync(claudeRoot, { recursive: true });
+ const settingsPath = path.join(claudeRoot, 'settings.json');
+ const customSettings = {
+ includeCoAuthoredBy: true,
+ theme: 'dark',
+ };
+ fs.writeFileSync(settingsPath, JSON.stringify(customSettings, null, 2));
+
+ const install = run(['--profile', 'core'], { cwd: projectDir, homeDir });
+ assert.strictEqual(install.code, 0, install.stderr);
+
+ const afterInstall = readJson(settingsPath);
+ assert.deepStrictEqual(afterInstall, customSettings);
+ } finally {
+ cleanup(homeDir);
+ cleanup(projectDir);
+ }
+ })) passed++; else failed++;
+
+ if (test('reinstall preserves an explicit attribution opt-in', () => {
+ const homeDir = createTempDir('install-apply-home-');
+ const projectDir = createTempDir('install-apply-project-');
+
+ try {
+ const claudeRoot = path.join(homeDir, '.claude');
+ fs.mkdirSync(claudeRoot, { recursive: true });
+ const settingsPath = path.join(claudeRoot, 'settings.json');
+ // `attribution` supersedes `includeCoAuthoredBy` in Claude Code, so writing
+ // the deprecated key here would be dead config that loses to the user's choice.
+ const customSettings = {
+ attribution: { commit: 'Signed-off-by: Someone ' },
+ theme: 'dark',
+ };
+ fs.writeFileSync(settingsPath, JSON.stringify(customSettings, null, 2));
+
+ const install = run(['--profile', 'core'], { cwd: projectDir, homeDir });
+ assert.strictEqual(install.code, 0, install.stderr);
+
+ const afterInstall = readJson(settingsPath);
+ assert.deepStrictEqual(afterInstall, customSettings);
} finally {
cleanup(homeDir);
cleanup(projectDir);