Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
32 changes: 31 additions & 1 deletion apps/desktop/src/main/claude-code-settings/reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,36 @@ function isValidSettings(obj: unknown): obj is ClaudeCodeSettings {
}
}

// Validate and sanitize enabledPlugins field
if ('enabledPlugins' in obj) {
if (isPlainObject(obj.enabledPlugins)) {
const plugins: Record<string, boolean> = {};
let hasValidPlugins = false;
for (const [key, value] of Object.entries(obj.enabledPlugins as Record<string, unknown>)) {
if (typeof value === 'boolean') {
plugins[key] = value;
hasValidPlugins = true;
}
}
if (hasValidPlugins) {
sanitized.enabledPlugins = plugins;
hasValidFields = true;
Comment on lines +153 to +163
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This block of code for sanitizing enabledPlugins can be made more concise and declarative by using Object.fromEntries and Array.prototype.filter.

      const plugins = Object.fromEntries(
        Object.entries(obj.enabledPlugins as Record<string, unknown>).filter(
          ([, value]) => typeof value === 'boolean'
        )
      );

      if (Object.keys(plugins).length > 0) {
        sanitized.enabledPlugins = plugins as Record<string, boolean>;
        hasValidFields = true;
      }

}
} else {
debugLog(`${LOG_PREFIX} Skipping invalid enabledPlugins field`);
}
}

// Validate and sanitize mcpServers field (pass through as Record<string, unknown>)
if ('mcpServers' in obj) {
if (isPlainObject(obj.mcpServers)) {
sanitized.mcpServers = obj.mcpServers as Record<string, unknown>;
hasValidFields = true;
} else {
debugLog(`${LOG_PREFIX} Skipping invalid mcpServers field`);
}
}

// If we have at least one valid field, mutate the original object to contain only sanitized fields
if (hasValidFields) {
// Clear the original object and copy sanitized fields
Expand Down Expand Up @@ -195,7 +225,7 @@ function readJsonFile(filePath: string): ClaudeCodeSettings | undefined {
* 2. CLAUDE_CONFIG_DIR environment variable
* 3. Default: ~/.claude
*/
function getUserConfigDir(): string {
export function getUserConfigDir(): string {
// Try to get configDir from the active Claude profile.
// We use a lazy import to avoid circular dependencies and to handle
// the case where ClaudeProfileManager hasn't been initialized yet.
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/main/claude-code-settings/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ export interface ClaudeCodeSettings {
alwaysThinkingEnabled?: boolean;
/** Environment variables to inject into agent processes */
env?: Record<string, string>;
/** Enabled marketplace plugins keyed by pluginKey (e.g. "pluginId@marketplace") */
enabledPlugins?: Record<string, boolean>;
/** Inline MCP server configurations keyed by server ID */
mcpServers?: Record<string, unknown>;
}

/**
Expand Down
125 changes: 125 additions & 0 deletions apps/desktop/src/main/ipc-handlers/claude-agents-handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* Claude Agents Handlers
*
* IPC handlers for reading Claude Code custom agent definitions
* from ~/.claude/agents/ directory structure.
*/

import { ipcMain } from 'electron';
import { existsSync, readdirSync } from 'fs';
import path from 'path';
import { IPC_CHANNELS } from '../../shared/constants/ipc';
import type { IPCResult } from '../../shared/types';
import type { ClaudeAgentsInfo, ClaudeAgentCategory, ClaudeCustomAgent } from '../../shared/types/integrations';
import { getUserConfigDir } from '../claude-code-settings/reader';
import { debugLog } from '../../shared/utils/debug-logger';

const LOG_PREFIX = '[ClaudeAgents]';

/**
* Convert a category directory name to a human-readable name.
* Removes the number prefix (e.g. "01-") and capitalizes words.
*/
function toCategoryName(dirName: string): string {
// Remove number prefix (e.g. "01-" from "01-core-development")
const withoutPrefix = dirName.replace(/^\d+-/, '');
return withoutPrefix
.replace(/[-_]/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
}

/**
* Convert an agent filename to a human-readable name.
* Removes the .md extension, capitalizes words, replaces hyphens with spaces.
*/
function toAgentName(fileName: string): string {
// Remove .md extension
const withoutExt = fileName.replace(/\.md$/, '');
return withoutExt
.replace(/[-_]/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
}

/**
* Get the agents directory path (~/.claude/agents/).
* Respects CLAUDE_CONFIG_DIR environment variable.
*/
function getAgentsDir(): string {
return path.join(getUserConfigDir(), 'agents');
}

/**
* Register Claude Agents IPC handlers.
*/
export function registerClaudeAgentsHandlers(): void {
ipcMain.handle(IPC_CHANNELS.CLAUDE_AGENTS_GET, async (): Promise<IPCResult<ClaudeAgentsInfo>> => {
try {
const agentsDir = getAgentsDir();

if (!existsSync(agentsDir)) {
debugLog(`${LOG_PREFIX} Agents directory not found:`, agentsDir);
return { success: true, data: { categories: [], totalAgents: 0 } };
}

const categories: ClaudeAgentCategory[] = [];
let totalAgents = 0;

const entries = readdirSync(agentsDir, { withFileTypes: true });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using synchronous file system methods like readdirSync in an async handler can block the main process, impacting UI responsiveness. Consider using the asynchronous version fs.promises.readdir along with Promise.all to process directories concurrently.


for (const entry of entries) {
if (!entry.isDirectory()) continue;
const entryPath = path.join(agentsDir, entry.name);

const agents: ClaudeCustomAgent[] = [];

try {
const files = readdirSync(entryPath);
for (const file of files) {
if (!file.endsWith('.md') || file.toLowerCase() === 'readme.md') continue;

const agentId = file.replace(/\.md$/, '');

// Use relative path (categoryDir/file) instead of absolute filePath
// to avoid exposing full filesystem paths to the renderer process
const relativePath = path.join(entry.name, file);

agents.push({
agentId,
agentName: toAgentName(file),
categoryDir: entry.name,
categoryName: toCategoryName(entry.name),
filePath: relativePath,
});
}
} catch {
debugLog(`${LOG_PREFIX} Failed to read category directory:`, entryPath);
continue;
}

if (agents.length > 0) {
// Sort agents by name within category
agents.sort((a, b) => a.agentName.localeCompare(b.agentName));

categories.push({
categoryDir: entry.name,
categoryName: toCategoryName(entry.name),
agents,
});
totalAgents += agents.length;
}
}

// Sort categories by directory name (already numbered)
categories.sort((a, b) => a.categoryDir.localeCompare(b.categoryDir));

debugLog(`${LOG_PREFIX} Found ${totalAgents} agent(s) in ${categories.length} categories`);
return { success: true, data: { categories, totalAgents } };
} catch (error) {
debugLog(`${LOG_PREFIX} Error reading agents:`, error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to read custom agents',
};
}
});
}
Loading
Loading