From 2fedfea13c23d9c5b79df4b746f4aff76027b02d Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Wed, 20 May 2026 13:00:54 +0100 Subject: [PATCH 1/3] fix: register aictrl marketplace so Claude Code can resolve plugin enablement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #18 Before this commit installClaudePlugin wrote the plugin to ~/.claude/plugins/cache/@aictrl/ and flipped settings.enabledPlugins["@aictrl"] = true, but never told Claude Code that the "aictrl" marketplace existed. The loader then reported "Plugin not found in marketplace aictrl" on every launch because there was no known_marketplaces.json entry, no marketplace manifest, and no installed_plugins.json record to resolve. Switch to the canonical Claude Code plugin layout: ~/.claude/plugins/marketplaces/aictrl/ .claude-plugin/marketplace.json ← declares plugin plugins/aictrl-/ ← plugin lives here .claude-plugin/plugin.json .mcp.json hooks/... skills/... ~/.claude/plugins/known_marketplaces.json ← registers aictrl ~/.claude/plugins/installed_plugins.json ← records install The marketplace is registered as source=local pointing at the local marketplace dir. The enabledPlugins key shape ("@aictrl") is unchanged, so existing settings.json entries continue to work after upgrade. Adds a cleanup step that removes the orphan pre-fix cache directory (~/.claude/plugins/cache/@aictrl/) on re-install so upgraders do not leave stale state behind. Updates the slash-command telemetry hook to search both cache/ and marketplaces/ for SKILL.md files now that plugins live in either location. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 + src/cli.ts | 4 +- src/config.ts | 3 +- src/hooks/claude-slash.sh.ts | 10 +- src/writers/claude.ts | 149 +++++++++++++++++++- test/hooks/claude-slash.test.ts | 7 +- test/writers/claude.test.ts | 233 ++++++++++++++++++++++++++++---- 7 files changed, 368 insertions(+), 39 deletions(-) diff --git a/.gitignore b/.gitignore index 1ab415f..c31e769 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules/ dist/ *.tgz +.worktrees/ diff --git a/src/cli.ts b/src/cli.ts index 3c4a0f4..590be22 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,7 +5,7 @@ import { DEFAULT_BASE_URL, CREDENTIALS_FILE, PROJECT_CONFIG_FILE, - CLAUDE_PLUGINS_CACHE, + CLAUDE_PLUGINS_ROOT, CLAUDE_SETTINGS_FILE, FETCH_BATCH_SIZE, } from './config.js'; @@ -183,7 +183,7 @@ async function main(): Promise { skills, apiKey, baseUrl: options.baseUrl, - pluginsCache: CLAUDE_PLUGINS_CACHE, + pluginsRoot: CLAUDE_PLUGINS_ROOT, settingsFile: CLAUDE_SETTINGS_FILE, }); console.log(` ✓ Installed plugin aictrl-${orgSlug} (${skills.length} skills)`); diff --git a/src/config.ts b/src/config.ts index dcdc3f6..f8850cc 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,7 +7,8 @@ export const CREDENTIALS_FILE = join(homedir(), '.aictrl', 'credentials.json'); export const PROJECT_CONFIG_FILE = '.aictrl.json'; -export const CLAUDE_PLUGINS_CACHE = join(homedir(), '.claude', 'plugins', 'cache'); +export const CLAUDE_PLUGINS_ROOT = join(homedir(), '.claude', 'plugins'); +export const CLAUDE_PLUGINS_CACHE = join(CLAUDE_PLUGINS_ROOT, 'cache'); export const CLAUDE_SETTINGS_FILE = join(homedir(), '.claude', 'settings.json'); export const OPENCODE_SKILLS_DIR = '.opencode/skills'; diff --git a/src/hooks/claude-slash.sh.ts b/src/hooks/claude-slash.sh.ts index 1e10a87..bdced07 100644 --- a/src/hooks/claude-slash.sh.ts +++ b/src/hooks/claude-slash.sh.ts @@ -37,11 +37,13 @@ for path in \\ if [ -f "$path" ]; then FOUND=1; break; fi done -# Plugin cache paths can be 4+ levels deep -# (e.g. ~/.claude/plugins/cache////skills//SKILL.md). +# Plugin paths can be 4+ levels deep under either: +# ~/.claude/plugins/cache////skills//SKILL.md +# ~/.claude/plugins/marketplaces//plugins//skills//SKILL.md # Use depth-bounded find rather than glob. -if [ "$FOUND" -eq 0 ] && [ -d "$HOME/.claude/plugins/cache" ]; then - if find "$HOME/.claude/plugins/cache" -maxdepth 6 -type f \\ +if [ "$FOUND" -eq 0 ] && [ -d "$HOME/.claude/plugins" ]; then + if find "$HOME/.claude/plugins/cache" "$HOME/.claude/plugins/marketplaces" \\ + -maxdepth 7 -type f \\ \\( -path "*/skills/$BARE_NAME/SKILL.md" -o -path "*/commands/$BARE_NAME.md" \\) \\ -print -quit 2>/dev/null | grep -q .; then FOUND=1 diff --git a/src/writers/claude.ts b/src/writers/claude.ts index 586f133..0f9285e 100644 --- a/src/writers/claude.ts +++ b/src/writers/claude.ts @@ -1,4 +1,5 @@ -import { writeFile, mkdir, readFile, chmod } from 'fs/promises'; +import { writeFile, mkdir, readFile, chmod, rm } from 'fs/promises'; +import { existsSync } from 'fs'; import { join } from 'path'; import { writeSkill, clearSkillsDir, type WritableSkill } from './shared.js'; import { generateClaudeHook } from '../hooks/claude.sh.js'; @@ -9,17 +10,45 @@ export interface ClaudePluginOptions { skills: WritableSkill[]; apiKey: string; baseUrl: string; - pluginsCache: string; + pluginsRoot: string; settingsFile: string; } +const MARKETPLACE_NAME = 'aictrl'; +const PLUGIN_VERSION = '1.0.0'; + +interface InstalledPluginEntry { + scope: string; + installPath: string; + version: string; + installedAt: string; + lastUpdated: string; +} + +interface InstalledPluginsFile { + version: number; + plugins: Record; +} + export async function installClaudePlugin(options: ClaudePluginOptions): Promise { - const { orgSlug, skills, apiKey, baseUrl, pluginsCache, settingsFile } = options; + const { orgSlug, skills, apiKey, baseUrl, pluginsRoot, settingsFile } = options; const pluginId = `aictrl-${orgSlug}`; - const pluginDirName = `${pluginId}@aictrl`; - const pluginDir = join(pluginsCache, pluginDirName); + const pluginDirName = `${pluginId}@${MARKETPLACE_NAME}`; + + // Canonical Claude Code layout: plugins live under their marketplace dir so the + // marketplace.json manifest can declare them via a relative "source" field. + const marketplaceDir = join(pluginsRoot, 'marketplaces', MARKETPLACE_NAME); + const pluginDir = join(marketplaceDir, 'plugins', pluginId); const skillsDir = join(pluginDir, 'skills'); + // Pre-v2.2 of this installer wrote the plugin to ~/.claude/plugins/cache/@aictrl/ + // without registering the `aictrl` marketplace, leaving Claude Code unable to resolve + // the enablement entry. Remove that stale directory on upgrade (#18). + const legacyCacheDir = join(pluginsRoot, 'cache', pluginDirName); + if (existsSync(legacyCacheDir)) { + await rm(legacyCacheDir, { recursive: true, force: true }); + } + // Clear and recreate skills directory await clearSkillsDir(skillsDir); @@ -32,7 +61,7 @@ export async function installClaudePlugin(options: ClaudePluginOptions): Promise { name: pluginId, description: `aictrl skills for ${orgSlug}`, - version: '1.0.0', + version: PLUGIN_VERSION, author: { name: 'aictrl.dev' }, homepage: 'https://aictrl.dev', mcpServers: './.mcp.json', @@ -114,10 +143,118 @@ export async function installClaudePlugin(options: ClaudePluginOptions): Promise 'utf-8', ); + // Write the marketplace manifest so Claude Code can resolve `@aictrl` + // enablement entries (#18). + await writeMarketplaceManifest(marketplaceDir, pluginId, orgSlug); + + // Register the marketplace + install with Claude Code's plugin index files. + await mergeKnownMarketplace(pluginsRoot, marketplaceDir); + await mergeInstalledPlugin(pluginsRoot, pluginDirName, pluginDir); + // Register plugin in settings.json await mergeSettings(settingsFile, pluginDirName); } +async function writeMarketplaceManifest( + marketplaceDir: string, + pluginId: string, + orgSlug: string, +): Promise { + const manifestDir = join(marketplaceDir, '.claude-plugin'); + const manifestPath = join(manifestDir, 'marketplace.json'); + await mkdir(manifestDir, { recursive: true }); + + // Preserve any other plugins that might already be declared in the manifest + // (future-proofing — currently we only ship one plugin per org). + let manifest: { name: string; owner: { name: string }; plugins: unknown[] } = { + name: MARKETPLACE_NAME, + owner: { name: 'aictrl' }, + plugins: [], + }; + try { + const parsed = JSON.parse(await readFile(manifestPath, 'utf-8')); + if (parsed && typeof parsed === 'object') { + manifest = { ...manifest, ...parsed }; + if (!Array.isArray(manifest.plugins)) manifest.plugins = []; + } + } catch { + // No existing manifest — start fresh. + } + + const pluginSpec = { + name: pluginId, + source: `./plugins/${pluginId}`, + description: `aictrl skills for ${orgSlug}`, + version: PLUGIN_VERSION, + }; + const others = manifest.plugins.filter( + (p): p is { name: string } => + typeof p === 'object' && p !== null && (p as { name?: string }).name !== pluginId, + ); + manifest.plugins = [...others, pluginSpec]; + + await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); +} + +async function mergeKnownMarketplace( + pluginsRoot: string, + marketplaceDir: string, +): Promise { + const file = join(pluginsRoot, 'known_marketplaces.json'); + let data: Record = {}; + try { + const parsed = JSON.parse(await readFile(file, 'utf-8')); + if (parsed && typeof parsed === 'object') data = parsed; + } catch { + // No existing file or malformed — start fresh. + } + + data[MARKETPLACE_NAME] = { + source: { source: 'local', path: marketplaceDir }, + installLocation: marketplaceDir, + lastUpdated: new Date().toISOString(), + }; + + await mkdir(pluginsRoot, { recursive: true }); + await writeFile(file, JSON.stringify(data, null, 2) + '\n', 'utf-8'); +} + +async function mergeInstalledPlugin( + pluginsRoot: string, + pluginKey: string, + installPath: string, +): Promise { + const file = join(pluginsRoot, 'installed_plugins.json'); + let data: InstalledPluginsFile = { version: 2, plugins: {} }; + try { + const parsed = JSON.parse(await readFile(file, 'utf-8')); + if (parsed && typeof parsed === 'object') { + data = { + version: typeof parsed.version === 'number' ? parsed.version : 2, + plugins: + parsed.plugins && typeof parsed.plugins === 'object' ? parsed.plugins : {}, + }; + } + } catch { + // No existing file or malformed — start fresh. + } + + const now = new Date().toISOString(); + const existing = data.plugins[pluginKey]?.[0]; + data.plugins[pluginKey] = [ + { + scope: 'user', + installPath, + version: PLUGIN_VERSION, + installedAt: existing?.installedAt ?? now, + lastUpdated: now, + }, + ]; + + await mkdir(pluginsRoot, { recursive: true }); + await writeFile(file, JSON.stringify(data, null, 2) + '\n', 'utf-8'); +} + async function mergeSettings(settingsFile: string, pluginDirName: string): Promise { let settings: Record = {}; try { diff --git a/test/hooks/claude-slash.test.ts b/test/hooks/claude-slash.test.ts index a297550..64fa948 100644 --- a/test/hooks/claude-slash.test.ts +++ b/test/hooks/claude-slash.test.ts @@ -41,8 +41,11 @@ describe('generateClaudeSlashCommandHook (static assertions)', () => { expect(script).toContain('"$PROJECT_ROOT/.claude/commands/$BARE_NAME.md"'); }); - it('uses depth-bounded find for plugin-cache lookup', () => { - expect(script).toContain('find "$HOME/.claude/plugins/cache" -maxdepth 6'); + it('uses depth-bounded find for plugin-cache and marketplaces lookup', () => { + expect(script).toContain( + 'find "$HOME/.claude/plugins/cache" "$HOME/.claude/plugins/marketplaces"', + ); + expect(script).toContain('-maxdepth 7'); expect(script).toContain('-print -quit'); }); diff --git a/test/writers/claude.test.ts b/test/writers/claude.test.ts index ad1eae1..7cc3600 100644 --- a/test/writers/claude.test.ts +++ b/test/writers/claude.test.ts @@ -8,6 +8,7 @@ import type { WritableSkill } from '../../src/writers/shared.js'; describe('installClaudePlugin', () => { let tempHome: string; + let pluginsRoot: string; let pluginsCache: string; let settingsFile: string; @@ -26,7 +27,8 @@ describe('installClaudePlugin', () => { beforeEach(async () => { tempHome = await mkdtemp(join(tmpdir(), 'aictrl-test-')); - pluginsCache = join(tempHome, '.claude', 'plugins', 'cache'); + pluginsRoot = join(tempHome, '.claude', 'plugins'); + pluginsCache = join(pluginsRoot, 'cache'); settingsFile = join(tempHome, '.claude', 'settings.json'); }); @@ -34,17 +36,23 @@ describe('installClaudePlugin', () => { await rm(tempHome, { recursive: true }); }); + // The canonical Claude Code layout puts plugins under + // ~/.claude/plugins/marketplaces//plugins// so the + // marketplace.json manifest can declare them via a relative "source" field. + const pluginPath = (root: string, plugin = 'aictrl-talentrix') => + join(root, 'marketplaces', 'aictrl', 'plugins', plugin); + it('creates plugin directory with correct structure', async () => { await installClaudePlugin({ orgSlug: 'talentrix', skills, apiKey: 'sk_live_xxx', baseUrl: 'https://aictrl.dev', - pluginsCache, + pluginsRoot, settingsFile, }); - const pluginDir = join(pluginsCache, 'aictrl-talentrix@aictrl'); + const pluginDir = pluginPath(pluginsRoot); expect(existsSync(join(pluginDir, '.claude-plugin', 'plugin.json'))).toBe(true); expect(existsSync(join(pluginDir, 'skills', 'code-review', 'SKILL.md'))).toBe(true); expect(existsSync(join(pluginDir, 'skills', 'tdd', 'SKILL.md'))).toBe(true); @@ -58,12 +66,12 @@ describe('installClaudePlugin', () => { skills, apiKey: 'sk_live_xxx', baseUrl: 'https://aictrl.dev', - pluginsCache, + pluginsRoot, settingsFile, }); const pluginJson = JSON.parse( - await readFile(join(pluginsCache, 'aictrl-talentrix@aictrl', '.claude-plugin', 'plugin.json'), 'utf-8') + await readFile(join(pluginPath(pluginsRoot), '.claude-plugin', 'plugin.json'), 'utf-8'), ); expect(pluginJson.name).toBe('aictrl-talentrix'); expect(pluginJson.mcpServers).toBe('./.mcp.json'); @@ -75,12 +83,12 @@ describe('installClaudePlugin', () => { skills, apiKey: 'sk_live_xxx', baseUrl: 'https://aictrl.dev', - pluginsCache, + pluginsRoot, settingsFile, }); const mcpJson = JSON.parse( - await readFile(join(pluginsCache, 'aictrl-talentrix@aictrl', '.mcp.json'), 'utf-8') + await readFile(join(pluginPath(pluginsRoot), '.mcp.json'), 'utf-8'), ); expect(mcpJson.mcpServers['aictrl-talentrix'].url).toBe('https://aictrl.dev/talentrix/mcp'); expect(mcpJson.mcpServers['aictrl-talentrix'].headers.Authorization).toBe('Bearer sk_live_xxx'); @@ -92,7 +100,7 @@ describe('installClaudePlugin', () => { skills, apiKey: 'sk_live_xxx', baseUrl: 'https://aictrl.dev', - pluginsCache, + pluginsRoot, settingsFile, }); @@ -102,17 +110,20 @@ describe('installClaudePlugin', () => { it('preserves existing settings when merging', async () => { await mkdir(join(tempHome, '.claude'), { recursive: true }); - await writeFile(settingsFile, JSON.stringify({ - theme: 'dark', - enabledPlugins: { 'other-plugin@market': true }, - })); + await writeFile( + settingsFile, + JSON.stringify({ + theme: 'dark', + enabledPlugins: { 'other-plugin@market': true }, + }), + ); await installClaudePlugin({ orgSlug: 'talentrix', skills, apiKey: 'sk_live_xxx', baseUrl: 'https://aictrl.dev', - pluginsCache, + pluginsRoot, settingsFile, }); @@ -128,18 +139,18 @@ describe('installClaudePlugin', () => { skills, apiKey: 'sk_live_xxx', baseUrl: 'https://aictrl.dev', - pluginsCache, + pluginsRoot, settingsFile, }); - const pluginDir = join(pluginsCache, 'aictrl-talentrix@aictrl'); + const pluginDir = pluginPath(pluginsRoot); const pluginJson = JSON.parse( - await readFile(join(pluginDir, '.claude-plugin', 'plugin.json'), 'utf-8') + await readFile(join(pluginDir, '.claude-plugin', 'plugin.json'), 'utf-8'), ); expect(pluginJson.hooks).toBe('./hooks/hooks.json'); const hooksJson = JSON.parse( - await readFile(join(pluginDir, 'hooks', 'hooks.json'), 'utf-8') + await readFile(join(pluginDir, 'hooks', 'hooks.json'), 'utf-8'), ); const postToolUse = hooksJson.hooks.PostToolUse; expect(postToolUse).toHaveLength(1); @@ -157,11 +168,11 @@ describe('installClaudePlugin', () => { skills, apiKey: 'sk_live_xxx', baseUrl: 'https://aictrl.dev', - pluginsCache, + pluginsRoot, settingsFile, }); - const pluginDir = join(pluginsCache, 'aictrl-talentrix@aictrl'); + const pluginDir = pluginPath(pluginsRoot); const hooksJson = JSON.parse( await readFile(join(pluginDir, 'hooks', 'hooks.json'), 'utf-8'), ); @@ -171,7 +182,6 @@ describe('installClaudePlugin', () => { expect(hooksJson.hooks.UserPromptSubmit).toHaveLength(1); const userPromptSubmit = hooksJson.hooks.UserPromptSubmit[0]; - // No matcher field — UserPromptSubmit has no tool name to match. expect(userPromptSubmit.matcher).toBeUndefined(); expect(userPromptSubmit.hooks[0]).toEqual({ type: 'command', @@ -181,7 +191,6 @@ describe('installClaudePlugin', () => { const slashHook = join(pluginDir, 'hooks', 'slash-command-telemetry.sh'); expect(existsSync(slashHook)).toBe(true); - // Verify executable mode (0o755). Mask to permission bits. const info = await stat(slashHook); expect(info.mode & 0o777).toBe(0o755); }); @@ -192,7 +201,7 @@ describe('installClaudePlugin', () => { skills, apiKey: 'sk_live_xxx', baseUrl: 'https://aictrl.dev', - pluginsCache, + pluginsRoot, settingsFile, }); @@ -205,12 +214,188 @@ describe('installClaudePlugin', () => { skills: newSkills, apiKey: 'sk_live_xxx', baseUrl: 'https://aictrl.dev', - pluginsCache, + pluginsRoot, settingsFile, }); - const pluginDir = join(pluginsCache, 'aictrl-talentrix@aictrl'); + const pluginDir = pluginPath(pluginsRoot); expect(existsSync(join(pluginDir, 'skills', 'deploy', 'SKILL.md'))).toBe(true); expect(existsSync(join(pluginDir, 'skills', 'code-review'))).toBe(false); }); + + // --------------------------------------------------------------------------- + // Regression tests for #18 — plugin enablement without marketplace registration + // --------------------------------------------------------------------------- + // Before #18 was fixed, installClaudePlugin only wrote the plugin cache and + // settings.enabledPlugins; it did NOT register the `aictrl` marketplace in + // known_marketplaces.json or record the install in installed_plugins.json. + // Claude Code then printed: + // "Plugin "aictrl-{orgSlug}" not found in marketplace "aictrl"" + // on every session because the marketplace name was unresolvable. + + it('writes marketplace manifest declaring the plugin', async () => { + await installClaudePlugin({ + orgSlug: 'talentrix', + skills, + apiKey: 'sk_live_xxx', + baseUrl: 'https://aictrl.dev', + pluginsRoot, + settingsFile, + }); + + const manifestPath = join( + pluginsRoot, + 'marketplaces', + 'aictrl', + '.claude-plugin', + 'marketplace.json', + ); + expect(existsSync(manifestPath)).toBe(true); + + const manifest = JSON.parse(await readFile(manifestPath, 'utf-8')); + expect(manifest.name).toBe('aictrl'); + expect(manifest.owner).toEqual({ name: 'aictrl' }); + expect(Array.isArray(manifest.plugins)).toBe(true); + + const plugin = manifest.plugins.find( + (p: { name: string }) => p.name === 'aictrl-talentrix', + ); + expect(plugin).toBeDefined(); + expect(plugin.source).toBe('./plugins/aictrl-talentrix'); + expect(plugin.version).toBe('1.0.0'); + }); + + it('registers the marketplace in known_marketplaces.json', async () => { + await installClaudePlugin({ + orgSlug: 'talentrix', + skills, + apiKey: 'sk_live_xxx', + baseUrl: 'https://aictrl.dev', + pluginsRoot, + settingsFile, + }); + + const knownPath = join(pluginsRoot, 'known_marketplaces.json'); + expect(existsSync(knownPath)).toBe(true); + + const known = JSON.parse(await readFile(knownPath, 'utf-8')); + expect(known.aictrl).toBeDefined(); + expect(known.aictrl.source).toEqual({ + source: 'local', + path: join(pluginsRoot, 'marketplaces', 'aictrl'), + }); + expect(known.aictrl.installLocation).toBe( + join(pluginsRoot, 'marketplaces', 'aictrl'), + ); + expect(typeof known.aictrl.lastUpdated).toBe('string'); + }); + + it('preserves existing marketplaces when registering aictrl', async () => { + await mkdir(pluginsRoot, { recursive: true }); + await writeFile( + join(pluginsRoot, 'known_marketplaces.json'), + JSON.stringify({ + 'claude-plugins-official': { + source: { source: 'github', repo: 'anthropics/claude-plugins-official' }, + installLocation: '/somewhere', + lastUpdated: '2026-01-01T00:00:00.000Z', + }, + }), + ); + + await installClaudePlugin({ + orgSlug: 'talentrix', + skills, + apiKey: 'sk_live_xxx', + baseUrl: 'https://aictrl.dev', + pluginsRoot, + settingsFile, + }); + + const known = JSON.parse( + await readFile(join(pluginsRoot, 'known_marketplaces.json'), 'utf-8'), + ); + expect(known['claude-plugins-official']).toBeDefined(); + expect(known.aictrl).toBeDefined(); + }); + + it('records the install in installed_plugins.json', async () => { + await installClaudePlugin({ + orgSlug: 'talentrix', + skills, + apiKey: 'sk_live_xxx', + baseUrl: 'https://aictrl.dev', + pluginsRoot, + settingsFile, + }); + + const installedPath = join(pluginsRoot, 'installed_plugins.json'); + expect(existsSync(installedPath)).toBe(true); + + const installed = JSON.parse(await readFile(installedPath, 'utf-8')); + expect(installed.version).toBe(2); + const entries = installed.plugins['aictrl-talentrix@aictrl']; + expect(Array.isArray(entries)).toBe(true); + expect(entries).toHaveLength(1); + expect(entries[0].scope).toBe('user'); + expect(entries[0].installPath).toBe(pluginPath(pluginsRoot)); + expect(entries[0].version).toBe('1.0.0'); + expect(typeof entries[0].installedAt).toBe('string'); + expect(typeof entries[0].lastUpdated).toBe('string'); + }); + + it('preserves installedAt across re-installs but updates lastUpdated', async () => { + await installClaudePlugin({ + orgSlug: 'talentrix', + skills, + apiKey: 'sk_live_xxx', + baseUrl: 'https://aictrl.dev', + pluginsRoot, + settingsFile, + }); + + const firstInstalled = JSON.parse( + await readFile(join(pluginsRoot, 'installed_plugins.json'), 'utf-8'), + ); + const firstInstalledAt = firstInstalled.plugins['aictrl-talentrix@aictrl'][0].installedAt; + + // Wait long enough for a distinct ISO timestamp on slower CI. + await new Promise((resolve) => setTimeout(resolve, 10)); + + await installClaudePlugin({ + orgSlug: 'talentrix', + skills, + apiKey: 'sk_live_xxx', + baseUrl: 'https://aictrl.dev', + pluginsRoot, + settingsFile, + }); + + const secondInstalled = JSON.parse( + await readFile(join(pluginsRoot, 'installed_plugins.json'), 'utf-8'), + ); + const entry = secondInstalled.plugins['aictrl-talentrix@aictrl'][0]; + expect(entry.installedAt).toBe(firstInstalledAt); + expect(entry.lastUpdated).not.toBe(firstInstalledAt); + }); + + it('cleans up legacy cache/@aictrl/ directory from older installs', async () => { + // Simulate a plugin previously installed by the pre-fix code path. + const legacyDir = join(pluginsCache, 'aictrl-talentrix@aictrl'); + await mkdir(legacyDir, { recursive: true }); + await writeFile(join(legacyDir, 'STALE.md'), 'leftover from old installer'); + + await installClaudePlugin({ + orgSlug: 'talentrix', + skills, + apiKey: 'sk_live_xxx', + baseUrl: 'https://aictrl.dev', + pluginsRoot, + settingsFile, + }); + + expect(existsSync(legacyDir)).toBe(false); + // And the canonical location is populated: + expect(existsSync(join(pluginPath(pluginsRoot), '.claude-plugin', 'plugin.json'))).toBe(true); + }); }); From 1e5273afd16bd96380d5bfb37e3cda4872ebbcb7 Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Wed, 20 May 2026 13:31:01 +0100 Subject: [PATCH 2/3] review(#19): address all 7 aictrl-dev review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [MAJOR] validate orgSlug at entry to installClaudePlugin to reject path-traversal sequences before any filesystem write or recursive rm - [MINOR] drop redundant existsSync guard (rm with force:true already no-ops on missing path) — also removes the sync fs import [NIT] - [MINOR] reject array-shaped JSON when reading marketplace.json, known_marketplaces.json, and installed_plugins.json so a corrupt file does not pollute the rewritten output or silently lose entries - [MINOR] preserve non-user-scope entries in installed_plugins.json across re-install — partition by scope and only replace user row - [MINOR] remove unused CLAUDE_PLUGINS_CACHE export from src/config.ts 5 new regression tests in test/writers/claude.test.ts; 108/108 pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/config.ts | 1 - src/writers/claude.ts | 45 +++++++++--- test/writers/claude.test.ts | 133 ++++++++++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 11 deletions(-) diff --git a/src/config.ts b/src/config.ts index f8850cc..7c80c55 100644 --- a/src/config.ts +++ b/src/config.ts @@ -8,7 +8,6 @@ export const CREDENTIALS_FILE = join(homedir(), '.aictrl', 'credentials.json'); export const PROJECT_CONFIG_FILE = '.aictrl.json'; export const CLAUDE_PLUGINS_ROOT = join(homedir(), '.claude', 'plugins'); -export const CLAUDE_PLUGINS_CACHE = join(CLAUDE_PLUGINS_ROOT, 'cache'); export const CLAUDE_SETTINGS_FILE = join(homedir(), '.claude', 'settings.json'); export const OPENCODE_SKILLS_DIR = '.opencode/skills'; diff --git a/src/writers/claude.ts b/src/writers/claude.ts index 0f9285e..4df2bc2 100644 --- a/src/writers/claude.ts +++ b/src/writers/claude.ts @@ -1,5 +1,4 @@ import { writeFile, mkdir, readFile, chmod, rm } from 'fs/promises'; -import { existsSync } from 'fs'; import { join } from 'path'; import { writeSkill, clearSkillsDir, type WritableSkill } from './shared.js'; import { generateClaudeHook } from '../hooks/claude.sh.js'; @@ -17,6 +16,11 @@ export interface ClaudePluginOptions { const MARKETPLACE_NAME = 'aictrl'; const PLUGIN_VERSION = '1.0.0'; +// orgSlug is interpolated into filesystem paths, URLs, MCP server names and +// shell hooks. Reject anything that could escape the plugin tree or hijack +// path resolution. Mirrors the slug shape published by aictrl.dev. +const ORG_SLUG_REGEX = /^[a-z0-9][a-z0-9-]{0,62}$/; + interface InstalledPluginEntry { scope: string; installPath: string; @@ -32,6 +36,11 @@ interface InstalledPluginsFile { export async function installClaudePlugin(options: ClaudePluginOptions): Promise { const { orgSlug, skills, apiKey, baseUrl, pluginsRoot, settingsFile } = 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).`, + ); + } const pluginId = `aictrl-${orgSlug}`; const pluginDirName = `${pluginId}@${MARKETPLACE_NAME}`; @@ -44,10 +53,9 @@ export async function installClaudePlugin(options: ClaudePluginOptions): Promise // Pre-v2.2 of this installer wrote the plugin to ~/.claude/plugins/cache/@aictrl/ // without registering the `aictrl` marketplace, leaving Claude Code unable to resolve // the enablement entry. Remove that stale directory on upgrade (#18). + // rm with force:true is a no-op when the path is missing, so no existsSync check needed. const legacyCacheDir = join(pluginsRoot, 'cache', pluginDirName); - if (existsSync(legacyCacheDir)) { - await rm(legacyCacheDir, { recursive: true, force: true }); - } + await rm(legacyCacheDir, { recursive: true, force: true }); // Clear and recreate skills directory await clearSkillsDir(skillsDir); @@ -173,7 +181,9 @@ async function writeMarketplaceManifest( }; try { const parsed = JSON.parse(await readFile(manifestPath, 'utf-8')); - if (parsed && typeof parsed === 'object') { + // typeof [] === 'object', so guard against an array-shaped file + // polluting the manifest with numeric-indexed keys. + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { manifest = { ...manifest, ...parsed }; if (!Array.isArray(manifest.plugins)) manifest.plugins = []; } @@ -204,7 +214,11 @@ async function mergeKnownMarketplace( let data: Record = {}; try { const parsed = JSON.parse(await readFile(file, 'utf-8')); - if (parsed && typeof parsed === 'object') data = parsed; + // typeof [] === 'object' — refuse an array-shaped file so that the named + // property we assign below isn't silently dropped by JSON.stringify. + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + data = parsed as Record; + } } catch { // No existing file or malformed — start fresh. } @@ -228,11 +242,17 @@ async function mergeInstalledPlugin( let data: InstalledPluginsFile = { version: 2, plugins: {} }; try { const parsed = JSON.parse(await readFile(file, 'utf-8')); - if (parsed && typeof parsed === 'object') { + if ( + parsed && + typeof parsed === 'object' && + !Array.isArray(parsed) + ) { data = { version: typeof parsed.version === 'number' ? parsed.version : 2, plugins: - parsed.plugins && typeof parsed.plugins === 'object' ? parsed.plugins : {}, + parsed.plugins && typeof parsed.plugins === 'object' && !Array.isArray(parsed.plugins) + ? parsed.plugins + : {}, }; } } catch { @@ -240,13 +260,18 @@ async function mergeInstalledPlugin( } const now = new Date().toISOString(); - const existing = data.plugins[pluginKey]?.[0]; + const existingEntries = Array.isArray(data.plugins[pluginKey]) ? data.plugins[pluginKey] : []; + const existingUser = existingEntries.find((e) => e?.scope === 'user'); + // Replace only the user-scope entry; preserve any other-scope entries that + // a future Claude Code version (or another install path) might have written. + const otherScopes = existingEntries.filter((e) => e?.scope !== 'user'); data.plugins[pluginKey] = [ + ...otherScopes, { scope: 'user', installPath, version: PLUGIN_VERSION, - installedAt: existing?.installedAt ?? now, + installedAt: existingUser?.installedAt ?? now, lastUpdated: now, }, ]; diff --git a/test/writers/claude.test.ts b/test/writers/claude.test.ts index 7cc3600..19c03a9 100644 --- a/test/writers/claude.test.ts +++ b/test/writers/claude.test.ts @@ -398,4 +398,137 @@ describe('installClaudePlugin', () => { // And the canonical location is populated: expect(existsSync(join(pluginPath(pluginsRoot), '.claude-plugin', 'plugin.json'))).toBe(true); }); + + // --------------------------------------------------------------------------- + // Regression tests for PR #19 review feedback + // --------------------------------------------------------------------------- + + it('rejects orgSlug containing path-traversal sequences', async () => { + for (const bad of ['../evil', '../../escape', 'foo/bar', 'foo\\bar', 'foobar', '', 'UPPER']) { + await expect( + installClaudePlugin({ + orgSlug: bad, + skills, + apiKey: 'sk_live_xxx', + baseUrl: 'https://aictrl.dev', + pluginsRoot, + settingsFile, + }), + ).rejects.toThrow(/Invalid orgSlug/); + } + }); + + it('legacy cleanup is a no-op when the cache dir does not exist', async () => { + // Pre-fix state is irrelevant for fresh installs — the rm({force:true}) + // path must not throw when there is nothing to remove. + await expect( + installClaudePlugin({ + orgSlug: 'talentrix', + skills, + apiKey: 'sk_live_xxx', + baseUrl: 'https://aictrl.dev', + pluginsRoot, + settingsFile, + }), + ).resolves.toBeUndefined(); + }); + + it('does not pollute marketplace.json when the existing file is a JSON array', async () => { + // Corrupt manifest (someone hand-edits the file into an array form). + const manifestPath = join( + pluginsRoot, + 'marketplaces', + 'aictrl', + '.claude-plugin', + 'marketplace.json', + ); + await mkdir(join(manifestPath, '..'), { recursive: true }); + await writeFile(manifestPath, JSON.stringify(['junk', 'array', 'content'])); + + await installClaudePlugin({ + orgSlug: 'talentrix', + skills, + apiKey: 'sk_live_xxx', + baseUrl: 'https://aictrl.dev', + pluginsRoot, + settingsFile, + }); + + const manifest = JSON.parse(await readFile(manifestPath, 'utf-8')); + // No numeric-indexed pollution from spreading an array as an object. + expect(manifest['0']).toBeUndefined(); + expect(manifest['1']).toBeUndefined(); + expect(manifest.name).toBe('aictrl'); + expect(Array.isArray(manifest.plugins)).toBe(true); + expect(manifest.plugins.find((p: { name: string }) => p.name === 'aictrl-talentrix')).toBeDefined(); + }); + + it('does not lose the aictrl entry when known_marketplaces.json is a JSON array', async () => { + await mkdir(pluginsRoot, { recursive: true }); + const knownPath = join(pluginsRoot, 'known_marketplaces.json'); + // Corrupt file in array shape — assigning `data[NAME] = {...}` to an + // array would silently drop the named key on the next JSON.stringify. + await writeFile(knownPath, JSON.stringify(['bogus'])); + + await installClaudePlugin({ + orgSlug: 'talentrix', + skills, + apiKey: 'sk_live_xxx', + baseUrl: 'https://aictrl.dev', + pluginsRoot, + settingsFile, + }); + + const known = JSON.parse(await readFile(knownPath, 'utf-8')); + expect(Array.isArray(known)).toBe(false); + expect(known.aictrl).toBeDefined(); + expect(known.aictrl.installLocation).toBe( + join(pluginsRoot, 'marketplaces', 'aictrl'), + ); + }); + + it('preserves non-user-scope entries in installed_plugins.json across re-install', async () => { + // Seed a project-scope install written by some future Claude Code version + // (or another tool). The installer should only replace the user-scope row. + await mkdir(pluginsRoot, { recursive: true }); + const installedPath = join(pluginsRoot, 'installed_plugins.json'); + const seed = { + version: 2, + plugins: { + 'aictrl-talentrix@aictrl': [ + { + scope: 'project', + installPath: '/some/project/path', + version: '0.9.0', + installedAt: '2026-01-01T00:00:00.000Z', + lastUpdated: '2026-01-01T00:00:00.000Z', + }, + ], + }, + }; + await writeFile(installedPath, JSON.stringify(seed)); + + await installClaudePlugin({ + orgSlug: 'talentrix', + skills, + apiKey: 'sk_live_xxx', + baseUrl: 'https://aictrl.dev', + pluginsRoot, + settingsFile, + }); + + const installed = JSON.parse(await readFile(installedPath, 'utf-8')); + const entries = installed.plugins['aictrl-talentrix@aictrl']; + expect(entries).toHaveLength(2); + + const project = entries.find((e: { scope: string }) => e.scope === 'project'); + expect(project).toBeDefined(); + expect(project.installPath).toBe('/some/project/path'); + expect(project.installedAt).toBe('2026-01-01T00:00:00.000Z'); + + const user = entries.find((e: { scope: string }) => e.scope === 'user'); + expect(user).toBeDefined(); + expect(user.installPath).toBe(pluginPath(pluginsRoot)); + expect(user.version).toBe('1.0.0'); + }); }); From c2490cf312a4d6463c79d859960620f8c5aea67d Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Wed, 20 May 2026 15:26:22 +0100 Subject: [PATCH 3/3] test(#18): add end-to-end index-file consistency check + harden timestamp assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-file tests for marketplace.json, known_marketplaces.json, and installed_plugins.json each validate one file in isolation. None of them catches the failure mode where the three files agree file-by-file but disagree with each other (typo in marketplace name, wrong relative source path, mismatched pluginId vs pluginDirName) — which is the exact bug shape #18 was about, just transposed. Adds a single test that walks the full handshake Claude Code performs at load time: settings.enabledPlugins → known_marketplaces.json → marketplace.json plugin spec → installed_plugins.json installPath → plugin.json name. If any link drifts, this test fails. Also tightens the installedAt-preservation test: assert lastUpdated >= installedAt as parsed dates instead of relying on string inequality, which is brittle on coarse-resolution timers. Co-Authored-By: Claude Opus 4.7 (1M context) --- test/writers/claude.test.ts | 85 ++++++++++++++++++++++++++++++++++++- 1 file changed, 83 insertions(+), 2 deletions(-) diff --git a/test/writers/claude.test.ts b/test/writers/claude.test.ts index 19c03a9..487a25b 100644 --- a/test/writers/claude.test.ts +++ b/test/writers/claude.test.ts @@ -359,7 +359,8 @@ describe('installClaudePlugin', () => { ); const firstInstalledAt = firstInstalled.plugins['aictrl-talentrix@aictrl'][0].installedAt; - // Wait long enough for a distinct ISO timestamp on slower CI. + // Force a clock advance so the second lastUpdated is provably later than + // the first installedAt — robust against coarse-resolution timers on CI. await new Promise((resolve) => setTimeout(resolve, 10)); await installClaudePlugin({ @@ -376,7 +377,9 @@ describe('installClaudePlugin', () => { ); const entry = secondInstalled.plugins['aictrl-talentrix@aictrl'][0]; expect(entry.installedAt).toBe(firstInstalledAt); - expect(entry.lastUpdated).not.toBe(firstInstalledAt); + expect(Date.parse(entry.lastUpdated)).toBeGreaterThanOrEqual( + Date.parse(entry.installedAt), + ); }); it('cleans up legacy cache/@aictrl/ directory from older installs', async () => { @@ -487,6 +490,84 @@ describe('installClaudePlugin', () => { ); }); + it('produces a self-consistent set of index files that Claude Code can resolve end-to-end', async () => { + // This is the handshake Claude Code performs at load time: + // 1. settings.enabledPlugins["@"] is true + // 2. known_marketplaces.json[""] points at a marketplace dir + // 3. /.claude-plugin/marketplace.json declares "" + // with a "source" that resolves to a real plugin dir + // 4. installed_plugins.json["@"][user].installPath + // is the same plugin dir, which contains .claude-plugin/plugin.json + // whose "name" matches "" + // If any of these links drift (typo in marketplace name, wrong relative + // source, mismatched pluginId vs pluginDirName), Claude Code prints the + // exact "Plugin not found in marketplace aictrl" error that #18 was + // about — but per-file tests still pass. This test closes that gap. + await installClaudePlugin({ + orgSlug: 'talentrix', + skills, + apiKey: 'sk_live_xxx', + baseUrl: 'https://aictrl.dev', + pluginsRoot, + settingsFile, + }); + + // 1. enabledPlugins entry shape: "@" + const settings = JSON.parse(await readFile(settingsFile, 'utf-8')); + const enabledKeys = Object.keys(settings.enabledPlugins).filter((k) => + k.startsWith('aictrl-talentrix@'), + ); + expect(enabledKeys).toHaveLength(1); + const [pluginAtMarketplace] = enabledKeys; + const [pluginName, marketplaceName] = pluginAtMarketplace.split('@'); + + // 2. known_marketplaces.json declares the same marketplace name + const known = JSON.parse( + await readFile(join(pluginsRoot, 'known_marketplaces.json'), 'utf-8'), + ); + expect(known[marketplaceName]).toBeDefined(); + const marketplaceDir = known[marketplaceName].installLocation; + expect(typeof marketplaceDir).toBe('string'); + expect(existsSync(marketplaceDir)).toBe(true); + + // 3. The marketplace manifest declares the plugin with a source path + // that resolves to a real plugin directory under the marketplace. + const manifest = JSON.parse( + await readFile( + join(marketplaceDir, '.claude-plugin', 'marketplace.json'), + 'utf-8', + ), + ); + expect(manifest.name).toBe(marketplaceName); + const pluginSpec = manifest.plugins.find( + (p: { name: string }) => p.name === pluginName, + ); + expect(pluginSpec).toBeDefined(); + expect(typeof pluginSpec.source).toBe('string'); + const resolvedPluginDir = join(marketplaceDir, pluginSpec.source); + expect(existsSync(resolvedPluginDir)).toBe(true); + + // 4. installed_plugins.json points the user-scope install at the SAME + // directory, and that directory's plugin.json carries the same name. + const installed = JSON.parse( + await readFile(join(pluginsRoot, 'installed_plugins.json'), 'utf-8'), + ); + const entries = installed.plugins[pluginAtMarketplace]; + const userEntry = entries.find((e: { scope: string }) => e.scope === 'user'); + expect(userEntry).toBeDefined(); + // Normalize both paths through `join` so trailing-slash differences don't + // cause spurious failures across OSes. + expect(join(userEntry.installPath)).toBe(join(resolvedPluginDir)); + + const pluginJson = JSON.parse( + await readFile( + join(userEntry.installPath, '.claude-plugin', 'plugin.json'), + 'utf-8', + ), + ); + expect(pluginJson.name).toBe(pluginName); + }); + it('preserves non-user-scope entries in installed_plugins.json across re-install', async () => { // Seed a project-scope install written by some future Claude Code version // (or another tool). The installer should only replace the user-scope row.