Skip to content
Open
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
49 changes: 49 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,55 @@ const projectChats = db.select().from(chats).where(eq(chats.projectId, id)).all(
- Session resume via `sessionId` stored in SubChat
- Message streaming via tRPC subscription (`claude.onMessage`)

### Plugin Support

1Code loads commands, skills, agents, and MCP servers from three sources (in priority order):

| Source | Location | Priority |
|--------|----------|----------|
| Project | `.claude/commands/`, `.claude/skills/`, `.claude/agents/` | Highest |
| User | `~/.claude/commands/`, `~/.claude/skills/`, `~/.claude/agents/` | Medium |
| Plugin | `~/.claude/plugins/marketplaces/*/` | Lowest |

**Plugin Components:**

| Component | Plugin Location |
|-----------|-----------------|
| Commands | `commands/` |
| Skills | `skills/` |
| Agents | `agents/` |
| MCP Servers | `.mcp.json` |

**Plugin Discovery:** Plugins installed via `claude /plugin install <name>` are automatically discovered from `~/.claude/plugins/marketplaces/`. Each plugin can provide commands, skills, agents, and MCP servers.

**MCP Servers:** Plugins can define MCP servers in a `.mcp.json` file at the plugin root:
```json
{
"mcpServers": {
"context7": { "command": "npx", "args": ["-y", "@upstash/context7-mcp@latest"] },
"sequential-thinking": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-sequential-thinking"] }
}
}
```

Plugin MCP servers appear in Settings → MCP as a separate group and are automatically available to Claude during chat sessions.

**Disabling Plugins:** Add plugin identifiers to `~/.claude/settings.json`:
```json
{
"disabledPlugins": ["ccsetup:ccsetup"]
}
```

Disabled plugins' commands, skills, agents, and MCP servers will not appear in the UI or be loaded.

**Related Files:**
- `src/main/lib/plugins/index.ts` - Plugin discovery utility (including MCP discovery)
- `src/main/lib/trpc/routers/commands.ts` - Commands loading with plugin support
- `src/main/lib/trpc/routers/skills.ts` - Skills loading with plugin support
- `src/main/lib/trpc/routers/agents.ts` - Agents loading with plugin support
- `src/main/lib/trpc/routers/claude.ts` - MCP server integration with plugin support

## Tech Stack

| Layer | Tech |
Expand Down
32 changes: 32 additions & 0 deletions src/main/lib/claude-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,38 @@ export function updateMcpServerConfig(
return config
}

/**
* Remove an MCP server from config
* Use projectPath = GLOBAL_MCP_PATH (or null) for global MCP servers
* Automatically resolves worktree paths to original project paths
*/
export function removeMcpServerConfig(
config: ClaudeConfig,
projectPath: string | null,
serverName: string
): ClaudeConfig {
// Global MCP servers
if (!projectPath || projectPath === GLOBAL_MCP_PATH) {
if (config.mcpServers?.[serverName]) {
delete config.mcpServers[serverName]
}
return config
}
// Project-specific MCP servers
const resolvedPath = resolveProjectPathFromWorktree(projectPath) || projectPath
if (config.projects?.[resolvedPath]?.mcpServers?.[serverName]) {
delete config.projects[resolvedPath].mcpServers[serverName]
// Clean up empty objects
if (Object.keys(config.projects[resolvedPath].mcpServers).length === 0) {
delete config.projects[resolvedPath].mcpServers
}
if (Object.keys(config.projects[resolvedPath]).length === 0) {
delete config.projects[resolvedPath]
}
}
return config
}

/**
* Resolve original project path from a worktree path.
* Supports legacy (~/.21st/worktrees/{projectId}/{chatId}/) and
Expand Down
193 changes: 193 additions & 0 deletions src/main/lib/plugins/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import * as fs from "fs/promises"
import * as path from "path"
import * as os from "os"
import type { McpServerConfig } from "../claude-config"

export interface PluginInfo {
name: string
version: string
path: string
source: string // e.g., "marketplace:plugin-name"
}

interface MarketplacePlugin {
name: string
version: string
source: string
}

interface MarketplaceJson {
name: string
plugins: MarketplacePlugin[]
}

export interface PluginMcpConfig {
pluginSource: string // e.g., "ccsetup:ccsetup"
mcpServers: Record<string, McpServerConfig>
}

// Cache for plugin discovery results
let pluginCache: { plugins: PluginInfo[]; timestamp: number } | null = null
let mcpCache: { configs: PluginMcpConfig[]; timestamp: number } | null = null
const CACHE_TTL_MS = 30000 // 30 seconds - plugins don't change often during a session

/**
* Clear plugin caches (for testing/manual invalidation)
*/
export function clearPluginCache() {
pluginCache = null
mcpCache = null
}

/**
* Discover all installed plugins from ~/.claude/plugins/marketplaces/
* Returns array of plugin info with paths to their component directories
* Results are cached for 30 seconds to avoid repeated filesystem scans
*/
export async function discoverInstalledPlugins(): Promise<PluginInfo[]> {
// Return cached result if still valid
if (pluginCache && Date.now() - pluginCache.timestamp < CACHE_TTL_MS) {
return pluginCache.plugins
}

const plugins: PluginInfo[] = []
const marketplacesDir = path.join(os.homedir(), ".claude", "plugins", "marketplaces")

try {
await fs.access(marketplacesDir)
} catch {
// No plugins directory exists
pluginCache = { plugins, timestamp: Date.now() }
return plugins
}

let marketplaces: fs.Dirent[]
try {
marketplaces = await fs.readdir(marketplacesDir, { withFileTypes: true })
} catch {
pluginCache = { plugins, timestamp: Date.now() }
return plugins
}

for (const marketplace of marketplaces) {
if (!marketplace.isDirectory() || marketplace.name.startsWith(".")) continue

const marketplacePath = path.join(marketplacesDir, marketplace.name)
const marketplaceJsonPath = path.join(marketplacePath, ".claude-plugin", "marketplace.json")

try {
const content = await fs.readFile(marketplaceJsonPath, "utf-8")

let marketplaceJson: MarketplaceJson
try {
marketplaceJson = JSON.parse(content)
} catch {
console.warn(`[plugins] Invalid JSON in ${marketplaceJsonPath}`)
continue
}

// Validate plugins array exists
if (!Array.isArray(marketplaceJson.plugins)) {
console.warn(`[plugins] Missing plugins array in ${marketplaceJsonPath}`)
continue
}

for (const plugin of marketplaceJson.plugins) {
// Validate plugin.source exists
if (!plugin.source) {
console.debug(`[plugins] Skipped plugin without source in ${marketplaceJsonPath}`)
continue
}

const pluginPath = path.resolve(marketplacePath, plugin.source)
try {
await fs.access(pluginPath)
plugins.push({
name: plugin.name,
version: plugin.version,
path: pluginPath,
source: `${marketplaceJson.name}:${plugin.name}`,
})
} catch {
console.debug(`[plugins] Skipped plugin "${plugin.name}" - directory not found: ${pluginPath}`)
}
}
} catch {
// No marketplace.json, skip silently (expected for non-plugin directories)
}
}

// Cache the result
pluginCache = { plugins, timestamp: Date.now() }
return plugins
}

/**
* Get component paths for a plugin (commands, skills, agents directories)
*/
export function getPluginComponentPaths(plugin: PluginInfo) {
return {
commands: path.join(plugin.path, "commands"),
skills: path.join(plugin.path, "skills"),
agents: path.join(plugin.path, "agents"),
}
}

/**
* Discover MCP server configs from all installed plugins
* Reads .mcp.json from each plugin directory
* Results are cached for 30 seconds to avoid repeated filesystem scans
*/
export async function discoverPluginMcpServers(): Promise<PluginMcpConfig[]> {
// Return cached result if still valid
if (mcpCache && Date.now() - mcpCache.timestamp < CACHE_TTL_MS) {
return mcpCache.configs
}

const plugins = await discoverInstalledPlugins()
const configs: PluginMcpConfig[] = []

for (const plugin of plugins) {
const mcpJsonPath = path.join(plugin.path, ".mcp.json")
try {
const content = await fs.readFile(mcpJsonPath, "utf-8")
let mcpConfig: { mcpServers?: unknown }
try {
mcpConfig = JSON.parse(content)
} catch {
console.warn(`[plugins] Invalid JSON in ${mcpJsonPath}`)
continue
}

// Validate mcpServers is an object with valid server configs
if (
mcpConfig.mcpServers &&
typeof mcpConfig.mcpServers === "object" &&
!Array.isArray(mcpConfig.mcpServers)
) {
// Filter to only include valid server config objects
const validServers: Record<string, McpServerConfig> = {}
for (const [name, config] of Object.entries(mcpConfig.mcpServers)) {
if (config && typeof config === "object" && !Array.isArray(config)) {
validServers[name] = config as McpServerConfig
} else {
console.debug(`[plugins] Skipped invalid MCP server config "${name}" in ${mcpJsonPath}`)
}
}

if (Object.keys(validServers).length > 0) {
configs.push({
pluginSource: plugin.source,
mcpServers: validServers,
})
}
}
} catch {
// No .mcp.json file, skip silently (this is expected for most plugins)
}
}

// Cache the result
mcpCache = { configs, timestamp: Date.now() }
return configs
}
49 changes: 45 additions & 4 deletions src/main/lib/trpc/routers/agent-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import * as fs from "fs/promises"
import * as path from "path"
import * as os from "os"
import matter from "gray-matter"
import { discoverInstalledPlugins, getPluginComponentPaths } from "../../plugins"
import { getDisabledPlugins } from "./claude-settings"

// Valid model values for agents
export const VALID_AGENT_MODELS = ["sonnet", "opus", "haiku", "inherit"] as const
Expand All @@ -19,7 +21,8 @@ export interface ParsedAgent {

// Agent with source/path metadata
export interface FileAgent extends ParsedAgent {
source: "user" | "project"
source: "user" | "project" | "plugin"
pluginName?: string
path: string
}

Expand Down Expand Up @@ -114,12 +117,13 @@ export function generateAgentMd(agent: {

/**
* Load agent definition from filesystem by name
* Searches in user (~/.claude/agents/) and project (.claude/agents/) directories
* Searches in user (~/.claude/agents/), project (.claude/agents/), and plugin directories
*/
export async function loadAgent(
name: string,
cwd?: string
): Promise<ParsedAgent | null> {
// Search user and project directories first
const locations = [
path.join(os.homedir(), ".claude", "agents"),
...(cwd ? [path.join(cwd, ".claude", "agents")] : []),
Expand All @@ -146,7 +150,44 @@ export async function loadAgent(
}
}

return null
// Search in plugin directories in parallel (respecting disabled plugins)
const [disabledPlugins, installedPlugins] = await Promise.all([
getDisabledPlugins(),
discoverInstalledPlugins(),
])

// Filter out disabled plugins
const enabledPlugins = installedPlugins.filter(
(p) => !disabledPlugins.includes(p.source)
)

// Search all plugins in parallel and return first valid match
const pluginResults = await Promise.all(
enabledPlugins.map(async (plugin) => {
const paths = getPluginComponentPaths(plugin)
const agentPath = path.join(paths.agents, `${name}.md`)
try {
const content = await fs.readFile(agentPath, "utf-8")
const parsed = parseAgentMd(content, `${name}.md`)

if (parsed.description && parsed.prompt) {
return {
name: parsed.name || name,
description: parsed.description,
prompt: parsed.prompt,
tools: parsed.tools,
disallowedTools: parsed.disallowedTools,
model: parsed.model,
}
}
} catch {
// Agent not found in this plugin
}
return null
})
)

return pluginResults.find((r) => r !== null) ?? null
}

/**
Expand All @@ -155,7 +196,7 @@ export async function loadAgent(
*/
export async function scanAgentsDirectory(
dir: string,
source: "user" | "project",
source: "user" | "project" | "plugin",
basePath?: string // For project agents, the cwd to make paths relative to
): Promise<FileAgent[]> {
const agents: FileAgent[] = []
Expand Down
Loading