From e586a72908aa63072a926ea5470233eeb909bf21 Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Wed, 20 May 2026 16:45:55 +0100 Subject: [PATCH 1/2] fix: scope plugin enablement per project so multi-org developers get only their current repo's MCP + skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #20 Pre-#20 versions wrote enablement to user-scope ~/.claude/settings.json. That meant every Claude Code session in every repo loaded *every* installed org's MCP server and skill catalogue. A developer with access to celliq and talentrix saw both MCPs connect and both skill sets appear in every session, regardless of which repo they were actually in. Move enablement to /.claude/settings.local.json. Plugin installation (marketplace, plugin files, installed_plugins.json) stays global — only the *enable* toggle is per-project. After this commit: cd ~/code/celliq && claude → only aictrl-celliq MCP + skills load cd ~/code/talentrix && claude → only aictrl-talentrix MCP + skills load Telemetry, credentials, and per-project routing in the slash-command hook are unchanged — they already key off .aictrl.json. Migration: on install we clean this org's user-scope enablement entry out of ~/.claude/settings.json (preserving every other entry so other orgs and non-aictrl plugins survive). The next install in another org's repo migrates that org's entry. The new settings.local.json is auto-added to the project .gitignore since it carries per-developer enablement state. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cli.ts | 3 +- src/writers/claude.ts | 67 ++++++++- test/writers/claude.test.ts | 268 +++++++++++++++++++++++++++++++----- 3 files changed, 296 insertions(+), 42 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 590be22..dacf53e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -184,7 +184,8 @@ async function main(): Promise { apiKey, baseUrl: options.baseUrl, pluginsRoot: CLAUDE_PLUGINS_ROOT, - settingsFile: CLAUDE_SETTINGS_FILE, + projectDir, + userSettingsFile: CLAUDE_SETTINGS_FILE, }); console.log(` ✓ Installed plugin aictrl-${orgSlug} (${skills.length} skills)`); console.log(` ✓ Configured MCP server aictrl-${orgSlug}`); diff --git a/src/writers/claude.ts b/src/writers/claude.ts index 4df2bc2..9ab5e49 100644 --- a/src/writers/claude.ts +++ b/src/writers/claude.ts @@ -3,6 +3,7 @@ import { join } from 'path'; import { writeSkill, clearSkillsDir, type WritableSkill } from './shared.js'; import { generateClaudeHook } from '../hooks/claude.sh.js'; import { generateClaudeSlashCommandHook } from '../hooks/claude-slash.sh.js'; +import { ensureGitignore } from '../gitignore.js'; export interface ClaudePluginOptions { orgSlug: string; @@ -10,9 +11,14 @@ export interface ClaudePluginOptions { apiKey: string; baseUrl: string; pluginsRoot: string; - settingsFile: string; + /** Project root; enablement is written to `/.claude/settings.local.json`. */ + projectDir: string; + /** Path to `~/.claude/settings.json`; consulted only to clean up legacy user-scope enablement entries. */ + userSettingsFile: string; } +const PROJECT_SETTINGS_RELPATH = join('.claude', 'settings.local.json'); + const MARKETPLACE_NAME = 'aictrl'; const PLUGIN_VERSION = '1.0.0'; @@ -35,7 +41,7 @@ interface InstalledPluginsFile { } export async function installClaudePlugin(options: ClaudePluginOptions): Promise { - const { orgSlug, skills, apiKey, baseUrl, pluginsRoot, settingsFile } = options; + const { orgSlug, skills, apiKey, baseUrl, pluginsRoot, projectDir, userSettingsFile } = options; if (!ORG_SLUG_REGEX.test(orgSlug)) { throw new Error( `Invalid orgSlug "${orgSlug}": must match ${ORG_SLUG_REGEX} (lowercase alphanumeric and hyphens, 1–63 chars).`, @@ -159,8 +165,20 @@ export async function installClaudePlugin(options: ClaudePluginOptions): Promise await mergeKnownMarketplace(pluginsRoot, marketplaceDir); await mergeInstalledPlugin(pluginsRoot, pluginDirName, pluginDir); - // Register plugin in settings.json - await mergeSettings(settingsFile, pluginDirName); + // Enable the plugin in PROJECT scope so each repo gets only its own org's + // MCP + skills (#20). Pre-#20 versions wrote enablement to user scope, which + // loaded every installed org in every Claude Code session. + const projectSettingsFile = join(projectDir, PROJECT_SETTINGS_RELPATH); + await mergeSettings(projectSettingsFile, pluginDirName); + + // Migration: remove this org's enablement entry from user-scope settings.json + // if a pre-#20 install put it there. Leaves unrelated entries (incl. other + // orgs, which get migrated when their own repo is installed) alone. + await removeUserScopeEnablement(userSettingsFile, pluginDirName); + + // The project settings.local.json file is per-developer; gitignore it so + // committing the repo does not leak enablement state across the team. + await ensureGitignore(projectDir, [PROJECT_SETTINGS_RELPATH]); } async function writeMarketplaceManifest( @@ -284,7 +302,10 @@ async function mergeSettings(settingsFile: string, pluginDirName: string): Promi let settings: Record = {}; try { const content = await readFile(settingsFile, 'utf-8'); - settings = JSON.parse(content); + const parsed = JSON.parse(content); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + settings = parsed as Record; + } } catch { // File doesn't exist or is invalid — start fresh } @@ -296,3 +317,39 @@ async function mergeSettings(settingsFile: string, pluginDirName: string): Promi await mkdir(join(settingsFile, '..'), { recursive: true }); await writeFile(settingsFile, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); } + +async function removeUserScopeEnablement( + userSettingsFile: string, + pluginDirName: string, +): Promise { + let content: string; + try { + content = await readFile(userSettingsFile, 'utf-8'); + } catch { + // No user settings file — nothing to migrate. + return; + } + + let settings: Record; + try { + const parsed = JSON.parse(content); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return; + settings = parsed as Record; + } catch { + // Malformed user settings — don't touch it. + return; + } + + const enabledPlugins = settings.enabledPlugins; + if ( + !enabledPlugins || + typeof enabledPlugins !== 'object' || + Array.isArray(enabledPlugins) || + !(pluginDirName in (enabledPlugins as Record)) + ) { + return; + } + + delete (enabledPlugins as Record)[pluginDirName]; + await writeFile(userSettingsFile, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); +} diff --git a/test/writers/claude.test.ts b/test/writers/claude.test.ts index 487a25b..ba5f5f8 100644 --- a/test/writers/claude.test.ts +++ b/test/writers/claude.test.ts @@ -10,7 +10,9 @@ describe('installClaudePlugin', () => { let tempHome: string; let pluginsRoot: string; let pluginsCache: string; - let settingsFile: string; + let projectDir: string; + let userSettingsFile: string; + let projectSettingsFile: string; const skills: WritableSkill[] = [ { @@ -29,7 +31,10 @@ describe('installClaudePlugin', () => { tempHome = await mkdtemp(join(tmpdir(), 'aictrl-test-')); pluginsRoot = join(tempHome, '.claude', 'plugins'); pluginsCache = join(pluginsRoot, 'cache'); - settingsFile = join(tempHome, '.claude', 'settings.json'); + userSettingsFile = join(tempHome, '.claude', 'settings.json'); + projectDir = join(tempHome, 'project'); + projectSettingsFile = join(projectDir, '.claude', 'settings.local.json'); + await mkdir(projectDir, { recursive: true }); }); afterEach(async () => { @@ -49,7 +54,8 @@ describe('installClaudePlugin', () => { apiKey: 'sk_live_xxx', baseUrl: 'https://aictrl.dev', pluginsRoot, - settingsFile, + projectDir, + userSettingsFile, }); const pluginDir = pluginPath(pluginsRoot); @@ -67,7 +73,8 @@ describe('installClaudePlugin', () => { apiKey: 'sk_live_xxx', baseUrl: 'https://aictrl.dev', pluginsRoot, - settingsFile, + projectDir, + userSettingsFile, }); const pluginJson = JSON.parse( @@ -84,7 +91,8 @@ describe('installClaudePlugin', () => { apiKey: 'sk_live_xxx', baseUrl: 'https://aictrl.dev', pluginsRoot, - settingsFile, + projectDir, + userSettingsFile, }); const mcpJson = JSON.parse( @@ -94,24 +102,25 @@ describe('installClaudePlugin', () => { expect(mcpJson.mcpServers['aictrl-talentrix'].headers.Authorization).toBe('Bearer sk_live_xxx'); }); - it('registers plugin in settings.json', async () => { + it('enables plugin in project-scope settings.local.json', async () => { await installClaudePlugin({ orgSlug: 'talentrix', skills, apiKey: 'sk_live_xxx', baseUrl: 'https://aictrl.dev', pluginsRoot, - settingsFile, + projectDir, + userSettingsFile, }); - const settings = JSON.parse(await readFile(settingsFile, 'utf-8')); - expect(settings.enabledPlugins['aictrl-talentrix@aictrl']).toBe(true); + const projectSettings = JSON.parse(await readFile(projectSettingsFile, 'utf-8')); + expect(projectSettings.enabledPlugins['aictrl-talentrix@aictrl']).toBe(true); }); - it('preserves existing settings when merging', async () => { - await mkdir(join(tempHome, '.claude'), { recursive: true }); + it('preserves existing entries in project settings.local.json when merging', async () => { + await mkdir(join(projectDir, '.claude'), { recursive: true }); await writeFile( - settingsFile, + projectSettingsFile, JSON.stringify({ theme: 'dark', enabledPlugins: { 'other-plugin@market': true }, @@ -124,13 +133,14 @@ describe('installClaudePlugin', () => { apiKey: 'sk_live_xxx', baseUrl: 'https://aictrl.dev', pluginsRoot, - settingsFile, + projectDir, + userSettingsFile, }); - const settings = JSON.parse(await readFile(settingsFile, 'utf-8')); - expect(settings.theme).toBe('dark'); - expect(settings.enabledPlugins['other-plugin@market']).toBe(true); - expect(settings.enabledPlugins['aictrl-talentrix@aictrl']).toBe(true); + const projectSettings = JSON.parse(await readFile(projectSettingsFile, 'utf-8')); + expect(projectSettings.theme).toBe('dark'); + expect(projectSettings.enabledPlugins['other-plugin@market']).toBe(true); + expect(projectSettings.enabledPlugins['aictrl-talentrix@aictrl']).toBe(true); }); it('registers PostToolUse Read hook in hooks.json', async () => { @@ -140,7 +150,8 @@ describe('installClaudePlugin', () => { apiKey: 'sk_live_xxx', baseUrl: 'https://aictrl.dev', pluginsRoot, - settingsFile, + projectDir, + userSettingsFile, }); const pluginDir = pluginPath(pluginsRoot); @@ -169,7 +180,8 @@ describe('installClaudePlugin', () => { apiKey: 'sk_live_xxx', baseUrl: 'https://aictrl.dev', pluginsRoot, - settingsFile, + projectDir, + userSettingsFile, }); const pluginDir = pluginPath(pluginsRoot); @@ -202,7 +214,8 @@ describe('installClaudePlugin', () => { apiKey: 'sk_live_xxx', baseUrl: 'https://aictrl.dev', pluginsRoot, - settingsFile, + projectDir, + userSettingsFile, }); const newSkills: WritableSkill[] = [ @@ -215,7 +228,8 @@ describe('installClaudePlugin', () => { apiKey: 'sk_live_xxx', baseUrl: 'https://aictrl.dev', pluginsRoot, - settingsFile, + projectDir, + userSettingsFile, }); const pluginDir = pluginPath(pluginsRoot); @@ -240,7 +254,8 @@ describe('installClaudePlugin', () => { apiKey: 'sk_live_xxx', baseUrl: 'https://aictrl.dev', pluginsRoot, - settingsFile, + projectDir, + userSettingsFile, }); const manifestPath = join( @@ -272,7 +287,8 @@ describe('installClaudePlugin', () => { apiKey: 'sk_live_xxx', baseUrl: 'https://aictrl.dev', pluginsRoot, - settingsFile, + projectDir, + userSettingsFile, }); const knownPath = join(pluginsRoot, 'known_marketplaces.json'); @@ -309,7 +325,8 @@ describe('installClaudePlugin', () => { apiKey: 'sk_live_xxx', baseUrl: 'https://aictrl.dev', pluginsRoot, - settingsFile, + projectDir, + userSettingsFile, }); const known = JSON.parse( @@ -326,7 +343,8 @@ describe('installClaudePlugin', () => { apiKey: 'sk_live_xxx', baseUrl: 'https://aictrl.dev', pluginsRoot, - settingsFile, + projectDir, + userSettingsFile, }); const installedPath = join(pluginsRoot, 'installed_plugins.json'); @@ -351,7 +369,8 @@ describe('installClaudePlugin', () => { apiKey: 'sk_live_xxx', baseUrl: 'https://aictrl.dev', pluginsRoot, - settingsFile, + projectDir, + userSettingsFile, }); const firstInstalled = JSON.parse( @@ -369,7 +388,8 @@ describe('installClaudePlugin', () => { apiKey: 'sk_live_xxx', baseUrl: 'https://aictrl.dev', pluginsRoot, - settingsFile, + projectDir, + userSettingsFile, }); const secondInstalled = JSON.parse( @@ -394,7 +414,8 @@ describe('installClaudePlugin', () => { apiKey: 'sk_live_xxx', baseUrl: 'https://aictrl.dev', pluginsRoot, - settingsFile, + projectDir, + userSettingsFile, }); expect(existsSync(legacyDir)).toBe(false); @@ -415,7 +436,8 @@ describe('installClaudePlugin', () => { apiKey: 'sk_live_xxx', baseUrl: 'https://aictrl.dev', pluginsRoot, - settingsFile, + projectDir, + userSettingsFile, }), ).rejects.toThrow(/Invalid orgSlug/); } @@ -431,7 +453,8 @@ describe('installClaudePlugin', () => { apiKey: 'sk_live_xxx', baseUrl: 'https://aictrl.dev', pluginsRoot, - settingsFile, + projectDir, + userSettingsFile, }), ).resolves.toBeUndefined(); }); @@ -454,7 +477,8 @@ describe('installClaudePlugin', () => { apiKey: 'sk_live_xxx', baseUrl: 'https://aictrl.dev', pluginsRoot, - settingsFile, + projectDir, + userSettingsFile, }); const manifest = JSON.parse(await readFile(manifestPath, 'utf-8')); @@ -479,7 +503,8 @@ describe('installClaudePlugin', () => { apiKey: 'sk_live_xxx', baseUrl: 'https://aictrl.dev', pluginsRoot, - settingsFile, + projectDir, + userSettingsFile, }); const known = JSON.parse(await readFile(knownPath, 'utf-8')); @@ -509,11 +534,13 @@ describe('installClaudePlugin', () => { apiKey: 'sk_live_xxx', baseUrl: 'https://aictrl.dev', pluginsRoot, - settingsFile, + projectDir, + userSettingsFile, }); - // 1. enabledPlugins entry shape: "@" - const settings = JSON.parse(await readFile(settingsFile, 'utf-8')); + // 1. enabledPlugins entry shape: "@" — lives in + // PROJECT-scope settings.local.json (one of the things #20 enforces). + const settings = JSON.parse(await readFile(projectSettingsFile, 'utf-8')); const enabledKeys = Object.keys(settings.enabledPlugins).filter((k) => k.startsWith('aictrl-talentrix@'), ); @@ -595,7 +622,8 @@ describe('installClaudePlugin', () => { apiKey: 'sk_live_xxx', baseUrl: 'https://aictrl.dev', pluginsRoot, - settingsFile, + projectDir, + userSettingsFile, }); const installed = JSON.parse(await readFile(installedPath, 'utf-8')); @@ -612,4 +640,172 @@ describe('installClaudePlugin', () => { expect(user.installPath).toBe(pluginPath(pluginsRoot)); expect(user.version).toBe('1.0.0'); }); + + // --------------------------------------------------------------------------- + // Regression tests for #20 — multi-org per-repo enablement + // --------------------------------------------------------------------------- + // The installer used to write enablement to user-scope ~/.claude/settings.json, + // which meant every Claude Code session in every repo loaded *every* org's + // MCP + skills. After #20 the enablement lives in project-scope + // /.claude/settings.local.json so a developer with two orgs gets + // only their current repo's plugin active. + + it('does not write any enablement entry to user-scope settings.json', async () => { + await installClaudePlugin({ + orgSlug: 'talentrix', + skills, + apiKey: 'sk_live_xxx', + baseUrl: 'https://aictrl.dev', + pluginsRoot, + projectDir, + userSettingsFile, + }); + + // Either the file was never created, or — if it existed already — it has + // no aictrl-* entries flipped on by this install. + if (existsSync(userSettingsFile)) { + const user = JSON.parse(await readFile(userSettingsFile, 'utf-8')); + const aictrlKeys = Object.keys(user.enabledPlugins ?? {}).filter((k) => + k.startsWith('aictrl-'), + ); + expect(aictrlKeys).toEqual([]); + } else { + expect(existsSync(userSettingsFile)).toBe(false); + } + }); + + it('removes legacy user-scope enablement on upgrade and preserves unrelated entries', async () => { + // Simulate a pre-#20 install: enablement landed in user-scope settings.json. + await mkdir(join(tempHome, '.claude'), { recursive: true }); + await writeFile( + userSettingsFile, + JSON.stringify({ + enabledPlugins: { + 'aictrl-talentrix@aictrl': true, + 'feature-dev@claude-code-plugins': true, // unrelated, must stay + }, + theme: 'dark', + }), + ); + + await installClaudePlugin({ + orgSlug: 'talentrix', + skills, + apiKey: 'sk_live_xxx', + baseUrl: 'https://aictrl.dev', + pluginsRoot, + projectDir, + userSettingsFile, + }); + + const user = JSON.parse(await readFile(userSettingsFile, 'utf-8')); + expect(user.enabledPlugins['aictrl-talentrix@aictrl']).toBeUndefined(); + expect(user.enabledPlugins['feature-dev@claude-code-plugins']).toBe(true); + expect(user.theme).toBe('dark'); + + // And the new project-scope location has it instead. + const project = JSON.parse(await readFile(projectSettingsFile, 'utf-8')); + expect(project.enabledPlugins['aictrl-talentrix@aictrl']).toBe(true); + }); + + it('does not touch unrelated aictrl-@aictrl entries in user settings', async () => { + // A developer who has talentrix installed at user scope (old installer) + // and is now installing celliq in a different repo: the celliq install + // should ONLY clean up celliq's own user-scope entry, not nuke talentrix's. + await mkdir(join(tempHome, '.claude'), { recursive: true }); + await writeFile( + userSettingsFile, + JSON.stringify({ + enabledPlugins: { + 'aictrl-talentrix@aictrl': true, + }, + }), + ); + + await installClaudePlugin({ + orgSlug: 'celliq', + skills, + apiKey: 'sk_live_xxx', + baseUrl: 'https://aictrl.dev', + pluginsRoot, + projectDir, + userSettingsFile, + }); + + const user = JSON.parse(await readFile(userSettingsFile, 'utf-8')); + // talentrix entry survives — it'll get migrated when the user re-runs + // the installer inside the talentrix repo. + expect(user.enabledPlugins['aictrl-talentrix@aictrl']).toBe(true); + expect(user.enabledPlugins['aictrl-celliq@aictrl']).toBeUndefined(); + }); + + it('adds .claude/settings.local.json to project .gitignore', async () => { + await installClaudePlugin({ + orgSlug: 'talentrix', + skills, + apiKey: 'sk_live_xxx', + baseUrl: 'https://aictrl.dev', + pluginsRoot, + projectDir, + userSettingsFile, + }); + + const gitignorePath = join(projectDir, '.gitignore'); + expect(existsSync(gitignorePath)).toBe(true); + const gitignore = await readFile(gitignorePath, 'utf-8'); + expect(gitignore.split('\n').map((l) => l.trim())).toContain( + '.claude/settings.local.json', + ); + }); + + it('multi-org: two project dirs each get only their own org enabled', async () => { + const projectA = join(tempHome, 'celliq-repo'); + const projectB = join(tempHome, 'talentrix-repo'); + await mkdir(projectA, { recursive: true }); + await mkdir(projectB, { recursive: true }); + + await installClaudePlugin({ + orgSlug: 'celliq', + skills, + apiKey: 'sk_celliq', + baseUrl: 'https://aictrl.dev', + pluginsRoot, + projectDir: projectA, + userSettingsFile, + }); + + await installClaudePlugin({ + orgSlug: 'talentrix', + skills, + apiKey: 'sk_talentrix', + baseUrl: 'https://aictrl.dev', + pluginsRoot, + projectDir: projectB, + userSettingsFile, + }); + + const settingsA = JSON.parse( + await readFile(join(projectA, '.claude', 'settings.local.json'), 'utf-8'), + ); + const settingsB = JSON.parse( + await readFile(join(projectB, '.claude', 'settings.local.json'), 'utf-8'), + ); + + expect(settingsA.enabledPlugins['aictrl-celliq@aictrl']).toBe(true); + expect(settingsA.enabledPlugins['aictrl-talentrix@aictrl']).toBeUndefined(); + + expect(settingsB.enabledPlugins['aictrl-talentrix@aictrl']).toBe(true); + expect(settingsB.enabledPlugins['aictrl-celliq@aictrl']).toBeUndefined(); + + // Both plugins still coexist at the global install layer (so users can + // switch repos without re-installing) — they're just not both enabled. + const manifest = JSON.parse( + await readFile( + join(pluginsRoot, 'marketplaces', 'aictrl', '.claude-plugin', 'marketplace.json'), + 'utf-8', + ), + ); + const pluginNames = manifest.plugins.map((p: { name: string }) => p.name).sort(); + expect(pluginNames).toEqual(['aictrl-celliq', 'aictrl-talentrix']); + }); }); From 422075c494e69241b0916333f8320642f2b34071 Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Wed, 20 May 2026 19:23:52 +0100 Subject: [PATCH 2/2] review(#21): atomic user-settings write + POSIX gitignore separator + migration intent comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses 3 of 4 aictrl-dev review findings: - [MAJOR] extract writeJsonAtomic helper (tmp + rename) and use it for both user-scope settings.json (high-blast-radius user-owned file) and the new project-scope settings.local.json (also carries user state like theme). Original file content stays intact until the new content is durable, so a SIGKILL/power-loss mid-write cannot truncate the file. - [MINOR] use a forward-slash literal for PROJECT_SETTINGS_RELPATH so the value is correct in both the .gitignore (which only matches POSIX separators) and Node fs operations (Node accepts forward slashes on Windows too). Avoids a Windows-only bug where the gitignore entry would silently fail to match the actual file. - [MINOR] comment clarifying that removeUserScopeEnablement runs every install intentionally — self-healing if a stale entry returns via backup restore or manual edit. The 4th finding (gitignore-responsibility-differs-across-writers) is deferred to #22 — the principled fix harmonises in the opposite direction the reviewer suggested (move cursor's gitignore call into installCursor) which is broader scope than #21. 2 new regression tests; suite 116/116 (was 114). Build clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/writers/claude.ts | 23 +++++++++--- test/writers/claude.test.ts | 70 ++++++++++++++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/src/writers/claude.ts b/src/writers/claude.ts index 9ab5e49..76b722e 100644 --- a/src/writers/claude.ts +++ b/src/writers/claude.ts @@ -1,4 +1,4 @@ -import { writeFile, mkdir, readFile, chmod, rm } from 'fs/promises'; +import { writeFile, mkdir, readFile, chmod, rm, rename } from 'fs/promises'; import { join } from 'path'; import { writeSkill, clearSkillsDir, type WritableSkill } from './shared.js'; import { generateClaudeHook } from '../hooks/claude.sh.js'; @@ -17,7 +17,10 @@ export interface ClaudePluginOptions { userSettingsFile: string; } -const PROJECT_SETTINGS_RELPATH = join('.claude', 'settings.local.json'); +// Forward-slash literal: this value is written verbatim into the project +// .gitignore, which only matches POSIX-style separators. Node's path API +// happily accepts forward slashes on Windows for filesystem operations. +const PROJECT_SETTINGS_RELPATH = '.claude/settings.local.json'; const MARKETPLACE_NAME = 'aictrl'; const PLUGIN_VERSION = '1.0.0'; @@ -174,6 +177,8 @@ export async function installClaudePlugin(options: ClaudePluginOptions): Promise // Migration: remove this org's enablement entry from user-scope settings.json // if a pre-#20 install put it there. Leaves unrelated entries (incl. other // orgs, which get migrated when their own repo is installed) alone. + // Runs unconditionally every install — cheap (one small file read) and self- + // healing if a stale entry returns via backup restore or manual edit. await removeUserScopeEnablement(userSettingsFile, pluginDirName); // The project settings.local.json file is per-developer; gitignore it so @@ -315,7 +320,7 @@ async function mergeSettings(settingsFile: string, pluginDirName: string): Promi settings.enabledPlugins = enabledPlugins; await mkdir(join(settingsFile, '..'), { recursive: true }); - await writeFile(settingsFile, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); + await writeJsonAtomic(settingsFile, settings); } async function removeUserScopeEnablement( @@ -351,5 +356,15 @@ async function removeUserScopeEnablement( } delete (enabledPlugins as Record)[pluginDirName]; - await writeFile(userSettingsFile, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); + // ~/.claude/settings.json is user-global and contains state we did not author + // (theme, hooks, other plugins). A non-atomic writeFile mid-power-loss could + // truncate the file to zero bytes. Use a temp file + rename so the original + // stays intact until the new content is fully durable. + await writeJsonAtomic(userSettingsFile, settings); +} + +async function writeJsonAtomic(filePath: string, data: unknown): Promise { + const tmp = `${filePath}.tmp`; + await writeFile(tmp, JSON.stringify(data, null, 2) + '\n', 'utf-8'); + await rename(tmp, filePath); } diff --git a/test/writers/claude.test.ts b/test/writers/claude.test.ts index ba5f5f8..3d8838d 100644 --- a/test/writers/claude.test.ts +++ b/test/writers/claude.test.ts @@ -739,7 +739,7 @@ describe('installClaudePlugin', () => { expect(user.enabledPlugins['aictrl-celliq@aictrl']).toBeUndefined(); }); - it('adds .claude/settings.local.json to project .gitignore', async () => { + it('adds .claude/settings.local.json to project .gitignore using POSIX separators', async () => { await installClaudePlugin({ orgSlug: 'talentrix', skills, @@ -753,11 +753,79 @@ describe('installClaudePlugin', () => { const gitignorePath = join(projectDir, '.gitignore'); expect(existsSync(gitignorePath)).toBe(true); const gitignore = await readFile(gitignorePath, 'utf-8'); + // Git only matches POSIX separators in .gitignore — must NOT contain a + // backslash, which path.join('.claude','settings.local.json') would + // produce on Windows. + expect(gitignore).not.toMatch(/\\/); expect(gitignore.split('\n').map((l) => l.trim())).toContain( '.claude/settings.local.json', ); }); + // --------------------------------------------------------------------------- + // Regression tests for PR #21 review feedback + // --------------------------------------------------------------------------- + + it('writes user-scope settings.json atomically (no .tmp file remains on success)', async () => { + // The user settings file contains state we did not author (theme, hooks, + // other plugins). A non-atomic writeFile mid-power-loss could truncate it + // to zero bytes. The implementation writes to .tmp then renames — + // verify the temp file does not leak on the happy path. + await mkdir(join(tempHome, '.claude'), { recursive: true }); + await writeFile( + userSettingsFile, + JSON.stringify({ + enabledPlugins: { 'aictrl-talentrix@aictrl': true }, + theme: 'dark', + }), + ); + + await installClaudePlugin({ + orgSlug: 'talentrix', + skills, + apiKey: 'sk_live_xxx', + baseUrl: 'https://aictrl.dev', + pluginsRoot, + projectDir, + userSettingsFile, + }); + + expect(existsSync(`${userSettingsFile}.tmp`)).toBe(false); + expect(existsSync(`${projectSettingsFile}.tmp`)).toBe(false); + + // And the file's contents are intact + correctly updated. + const user = JSON.parse(await readFile(userSettingsFile, 'utf-8')); + expect(user.theme).toBe('dark'); + expect(user.enabledPlugins['aictrl-talentrix@aictrl']).toBeUndefined(); + }); + + it('survives a pre-existing user settings.json with a stale .tmp sibling', async () => { + // If a previous run was killed mid-write, the .tmp file could still be on + // disk. The atomic write should overwrite it cleanly, not throw. + await mkdir(join(tempHome, '.claude'), { recursive: true }); + await writeFile( + userSettingsFile, + JSON.stringify({ enabledPlugins: { 'aictrl-talentrix@aictrl': true } }), + ); + await writeFile(`${userSettingsFile}.tmp`, 'leftover from crashed write'); + + await expect( + installClaudePlugin({ + orgSlug: 'talentrix', + skills, + apiKey: 'sk_live_xxx', + baseUrl: 'https://aictrl.dev', + pluginsRoot, + projectDir, + userSettingsFile, + }), + ).resolves.toBeUndefined(); + + expect(existsSync(`${userSettingsFile}.tmp`)).toBe(false); + const user = JSON.parse(await readFile(userSettingsFile, 'utf-8')); + expect(user.enabledPlugins['aictrl-talentrix@aictrl']).toBeUndefined(); + }); + it('multi-org: two project dirs each get only their own org enabled', async () => { const projectA = join(tempHome, 'celliq-repo'); const projectB = join(tempHome, 'talentrix-repo');