Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
node_modules/
dist/
*.tgz
.worktrees/
4 changes: 2 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -183,7 +183,7 @@ async function main(): Promise<void> {
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)`);
Expand Down
2 changes: 1 addition & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ 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_SETTINGS_FILE = join(homedir(), '.claude', 'settings.json');

export const OPENCODE_SKILLS_DIR = '.opencode/skills';
Expand Down
10 changes: 6 additions & 4 deletions src/hooks/claude-slash.sh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<marketplace>/<plugin>/<version>/skills/<name>/SKILL.md).
# Plugin paths can be 4+ levels deep under either:
# ~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/skills/<name>/SKILL.md
# ~/.claude/plugins/marketplaces/<marketplace>/plugins/<plugin>/skills/<name>/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
Expand Down
174 changes: 168 additions & 6 deletions src/writers/claude.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { writeFile, mkdir, readFile, chmod } from 'fs/promises';
import { writeFile, mkdir, readFile, chmod, rm } from 'fs/promises';
import { join } from 'path';
import { writeSkill, clearSkillsDir, type WritableSkill } from './shared.js';
import { generateClaudeHook } from '../hooks/claude.sh.js';
Expand All @@ -9,17 +9,54 @@ 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';

// 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;
version: string;
installedAt: string;
lastUpdated: string;
}

interface InstalledPluginsFile {
version: number;
plugins: Record<string, InstalledPluginEntry[]>;
}

export async function installClaudePlugin(options: ClaudePluginOptions): Promise<void> {
const { orgSlug, skills, apiKey, baseUrl, pluginsCache, settingsFile } = options;
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}@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/<plugin>@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);
await rm(legacyCacheDir, { recursive: true, force: true });

// Clear and recreate skills directory
await clearSkillsDir(skillsDir);

Expand All @@ -32,7 +69,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',
Expand Down Expand Up @@ -114,10 +151,135 @@ export async function installClaudePlugin(options: ClaudePluginOptions): Promise
'utf-8',
);

// Write the marketplace manifest so Claude Code can resolve `<plugin>@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<void> {
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'));
// 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 = [];
}
} 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<void> {
const file = join(pluginsRoot, 'known_marketplaces.json');
let data: Record<string, unknown> = {};
try {
const parsed = JSON.parse(await readFile(file, 'utf-8'));
// 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<string, unknown>;
}
} 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<void> {
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' &&
!Array.isArray(parsed)
) {
data = {
version: typeof parsed.version === 'number' ? parsed.version : 2,
plugins:
parsed.plugins && typeof parsed.plugins === 'object' && !Array.isArray(parsed.plugins)
? parsed.plugins
: {},
};
}
} catch {
// No existing file or malformed — start fresh.
}

const now = new Date().toISOString();
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: existingUser?.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<void> {
let settings: Record<string, unknown> = {};
try {
Expand Down
7 changes: 5 additions & 2 deletions test/hooks/claude-slash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});

Expand Down
Loading
Loading