diff --git a/notepad.md b/notepad.md index e53266f..db1238e 100644 --- a/notepad.md +++ b/notepad.md @@ -225,3 +225,94 @@ All tasks execution STARTED: Thu Dec 4 16:52:57 KST 2025 --- +## [2025-12-09 16:24] - Task 4: Add claude-code-agent-loader feature + +### DISCOVERED ISSUES +- None - straightforward file copy task + +### IMPLEMENTATION DECISIONS +- Copied 3 files from opencode-cc-plugin: `index.ts`, `loader.ts`, `types.ts` +- Import path `../../shared/frontmatter` unchanged - already compatible with oh-my-opencode structure +- No `log()` usage in source files - no logger integration needed + +### PROBLEMS FOR NEXT TASKS +- None identified - agent-loader is self-contained + +### VERIFICATION RESULTS +- Ran: `bun run typecheck` → exit 0, no errors +- Directory structure verified: `claude-code-agent-loader/` created with 3 files +- Functions exported: `loadUserAgents()`, `loadProjectAgents()` + +### LEARNINGS +- Source location: `~/local-workspaces/opencode-cc-plugin/src/features/agent-loader/` +- Agent loader uses `parseFrontmatter` from shared module +- Agent configs loaded from `~/.claude/agents/` (user) and `.claude/agents/` (project) +- Scope is appended to description: `(user)` or `(project)` + +소요 시간: ~1분 + +--- + +## [2025-12-09 16:25] - Task 5: Add claude-code-mcp-loader feature + +### DISCOVERED ISSUES +- None - straightforward file copy task + +### IMPLEMENTATION DECISIONS +- Copied 5 files from opencode-cc-plugin: `index.ts`, `loader.ts`, `transformer.ts`, `env-expander.ts`, `types.ts` +- Import path `../../shared/logger` unchanged - already compatible with oh-my-opencode structure +- Kept `Bun.file()` usage - oh-my-opencode targets Bun runtime +- Environment variable expansion supports `${VAR}` and `${VAR:-default}` syntax + +### PROBLEMS FOR NEXT TASKS +- None identified - mcp-loader is self-contained +- Does NOT conflict with src/mcp/ (builtin MCPs are separate) + +### VERIFICATION RESULTS +- Ran: `bun run typecheck` → exit 0, no errors +- Directory structure verified: `claude-code-mcp-loader/` created with 5 files +- Functions exported: `loadMcpConfigs()`, `formatLoadedServersForToast()`, `transformMcpServer()`, `expandEnvVars()`, `expandEnvVarsInObject()` + +### LEARNINGS +- Source location: `~/local-workspaces/opencode-cc-plugin/src/features/mcp-loader/` +- MCP configs loaded from: + - `~/.claude/.mcp.json` (user scope) + - `.mcp.json` (project scope) + - `.claude/.mcp.json` (local scope) +- Later scope overrides earlier scope for same server name +- Supports stdio, http, and sse server types + +소요 시간: ~1분 + +--- + +## [2025-12-09 16:24] - Task 6: Add claude-code-session-state feature + +### DISCOVERED ISSUES +- None - straightforward file copy task + +### IMPLEMENTATION DECISIONS +- Copied 4 files from opencode-cc-plugin: `types.ts`, `state.ts`, `detector.ts`, `index.ts` +- No import path changes needed - files are completely self-contained +- No external dependencies - types are defined locally + +### PROBLEMS FOR NEXT TASKS +- Task 7 should import from `./features/claude-code-session-state` in src/index.ts +- Task 7 should remove local session variables and use the module's getter/setters + +### VERIFICATION RESULTS +- Directory created: `src/features/claude-code-session-state/` (4 files confirmed) +- Exports available: sessionErrorState, sessionInterruptState, subagentSessions, sessionFirstMessageProcessed (Maps/Sets) +- Exports available: currentSessionID, currentSessionTitle, mainSessionID (state vars) +- Exports available: setCurrentSession(), setMainSession(), getCurrentSessionID(), getCurrentSessionTitle(), getMainSessionID() (getters/setters) +- Exports available: detectInterrupt() function + +### LEARNINGS +- Session state module is completely self-contained - no external dependencies +- Uses barrel export pattern: index.ts re-exports everything from types, state, detector +- Source directory: `~/local-workspaces/opencode-cc-plugin/src/features/session-state/` + +소요 시간: ~1분 + +--- + diff --git a/src/features/claude-code-agent-loader/index.ts b/src/features/claude-code-agent-loader/index.ts new file mode 100644 index 0000000..644158c --- /dev/null +++ b/src/features/claude-code-agent-loader/index.ts @@ -0,0 +1,2 @@ +export * from "./types" +export * from "./loader" diff --git a/src/features/claude-code-agent-loader/loader.ts b/src/features/claude-code-agent-loader/loader.ts new file mode 100644 index 0000000..27425d5 --- /dev/null +++ b/src/features/claude-code-agent-loader/loader.ts @@ -0,0 +1,93 @@ +import { existsSync, readdirSync, readFileSync } from "fs" +import { homedir } from "os" +import { join, basename } from "path" +import type { AgentConfig } from "@opencode-ai/sdk" +import { parseFrontmatter } from "../../shared/frontmatter" +import type { AgentScope, AgentFrontmatter, LoadedAgent } from "./types" + +function parseToolsConfig(toolsStr?: string): Record | undefined { + if (!toolsStr) return undefined + + const tools = toolsStr.split(",").map((t) => t.trim()).filter(Boolean) + if (tools.length === 0) return undefined + + const result: Record = {} + for (const tool of tools) { + result[tool.toLowerCase()] = true + } + return result +} + +function isMarkdownFile(entry: { name: string; isFile: () => boolean }): boolean { + return !entry.name.startsWith(".") && entry.name.endsWith(".md") && entry.isFile() +} + +function loadAgentsFromDir(agentsDir: string, scope: AgentScope): LoadedAgent[] { + if (!existsSync(agentsDir)) { + return [] + } + + const entries = readdirSync(agentsDir, { withFileTypes: true }) + const agents: LoadedAgent[] = [] + + for (const entry of entries) { + if (!isMarkdownFile(entry)) continue + + const agentPath = join(agentsDir, entry.name) + const agentName = basename(entry.name, ".md") + + try { + const content = readFileSync(agentPath, "utf-8") + const { data, body } = parseFrontmatter(content) + + const name = data.name || agentName + const originalDescription = data.description || "" + + const formattedDescription = `(${scope}) ${originalDescription}` + + const config: AgentConfig = { + description: formattedDescription, + mode: "subagent", + prompt: body.trim(), + } + + const toolsConfig = parseToolsConfig(data.tools) + if (toolsConfig) { + config.tools = toolsConfig + } + + agents.push({ + name, + path: agentPath, + config, + scope, + }) + } catch { + continue + } + } + + return agents +} + +export function loadUserAgents(): Record { + const userAgentsDir = join(homedir(), ".claude", "agents") + const agents = loadAgentsFromDir(userAgentsDir, "user") + + const result: Record = {} + for (const agent of agents) { + result[agent.name] = agent.config + } + return result +} + +export function loadProjectAgents(): Record { + const projectAgentsDir = join(process.cwd(), ".claude", "agents") + const agents = loadAgentsFromDir(projectAgentsDir, "project") + + const result: Record = {} + for (const agent of agents) { + result[agent.name] = agent.config + } + return result +} diff --git a/src/features/claude-code-agent-loader/types.ts b/src/features/claude-code-agent-loader/types.ts new file mode 100644 index 0000000..4ffd9de --- /dev/null +++ b/src/features/claude-code-agent-loader/types.ts @@ -0,0 +1,17 @@ +import type { AgentConfig } from "@opencode-ai/sdk" + +export type AgentScope = "user" | "project" + +export interface AgentFrontmatter { + name?: string + description?: string + model?: string + tools?: string +} + +export interface LoadedAgent { + name: string + path: string + config: AgentConfig + scope: AgentScope +} diff --git a/src/features/claude-code-mcp-loader/env-expander.ts b/src/features/claude-code-mcp-loader/env-expander.ts new file mode 100644 index 0000000..b3edf89 --- /dev/null +++ b/src/features/claude-code-mcp-loader/env-expander.ts @@ -0,0 +1,27 @@ +export function expandEnvVars(value: string): string { + return value.replace( + /\$\{([^}:]+)(?::-([^}]*))?\}/g, + (_, varName: string, defaultValue?: string) => { + const envValue = process.env[varName] + if (envValue !== undefined) return envValue + if (defaultValue !== undefined) return defaultValue + return "" + } + ) +} + +export function expandEnvVarsInObject(obj: T): T { + if (obj === null || obj === undefined) return obj + if (typeof obj === "string") return expandEnvVars(obj) as T + if (Array.isArray(obj)) { + return obj.map((item) => expandEnvVarsInObject(item)) as T + } + if (typeof obj === "object") { + const result: Record = {} + for (const [key, value] of Object.entries(obj)) { + result[key] = expandEnvVarsInObject(value) + } + return result as T + } + return obj +} diff --git a/src/features/claude-code-mcp-loader/index.ts b/src/features/claude-code-mcp-loader/index.ts new file mode 100644 index 0000000..20f4972 --- /dev/null +++ b/src/features/claude-code-mcp-loader/index.ts @@ -0,0 +1,11 @@ +/** + * MCP Configuration Loader + * + * Loads Claude Code .mcp.json format configurations from multiple scopes + * and transforms them to OpenCode SDK format + */ + +export * from "./types" +export * from "./loader" +export * from "./transformer" +export * from "./env-expander" diff --git a/src/features/claude-code-mcp-loader/loader.ts b/src/features/claude-code-mcp-loader/loader.ts new file mode 100644 index 0000000..d75cb57 --- /dev/null +++ b/src/features/claude-code-mcp-loader/loader.ts @@ -0,0 +1,89 @@ +import { existsSync } from "fs" +import { homedir } from "os" +import { join } from "path" +import type { + ClaudeCodeMcpConfig, + LoadedMcpServer, + McpLoadResult, + McpScope, +} from "./types" +import { transformMcpServer } from "./transformer" +import { log } from "../../shared/logger" + +interface McpConfigPath { + path: string + scope: McpScope +} + +function getMcpConfigPaths(): McpConfigPath[] { + const home = homedir() + const cwd = process.cwd() + + return [ + { path: join(home, ".claude", ".mcp.json"), scope: "user" }, + { path: join(cwd, ".mcp.json"), scope: "project" }, + { path: join(cwd, ".claude", ".mcp.json"), scope: "local" }, + ] +} + +async function loadMcpConfigFile( + filePath: string +): Promise { + if (!existsSync(filePath)) { + return null + } + + try { + const content = await Bun.file(filePath).text() + return JSON.parse(content) as ClaudeCodeMcpConfig + } catch (error) { + log(`Failed to load MCP config from ${filePath}`, error) + return null + } +} + +export async function loadMcpConfigs(): Promise { + const servers: McpLoadResult["servers"] = {} + const loadedServers: LoadedMcpServer[] = [] + const paths = getMcpConfigPaths() + + for (const { path, scope } of paths) { + const config = await loadMcpConfigFile(path) + if (!config?.mcpServers) continue + + for (const [name, serverConfig] of Object.entries(config.mcpServers)) { + if (serverConfig.disabled) { + log(`Skipping disabled MCP server "${name}"`, { path }) + continue + } + + try { + const transformed = transformMcpServer(name, serverConfig) + servers[name] = transformed + + const existingIndex = loadedServers.findIndex((s) => s.name === name) + if (existingIndex !== -1) { + loadedServers.splice(existingIndex, 1) + } + + loadedServers.push({ name, scope, config: transformed }) + + log(`Loaded MCP server "${name}" from ${scope}`, { path }) + } catch (error) { + log(`Failed to transform MCP server "${name}"`, error) + } + } + } + + return { servers, loadedServers } +} + +export function formatLoadedServersForToast( + loadedServers: LoadedMcpServer[] +): string { + if (loadedServers.length === 0) return "" + + return loadedServers + .map((server) => `${server.name} (${server.scope})`) + .join(", ") +} diff --git a/src/features/claude-code-mcp-loader/transformer.ts b/src/features/claude-code-mcp-loader/transformer.ts new file mode 100644 index 0000000..f94e504 --- /dev/null +++ b/src/features/claude-code-mcp-loader/transformer.ts @@ -0,0 +1,53 @@ +import type { + ClaudeCodeMcpServer, + McpLocalConfig, + McpRemoteConfig, + McpServerConfig, +} from "./types" +import { expandEnvVarsInObject } from "./env-expander" + +export function transformMcpServer( + name: string, + server: ClaudeCodeMcpServer +): McpServerConfig { + const expanded = expandEnvVarsInObject(server) + const serverType = expanded.type ?? "stdio" + + if (serverType === "http" || serverType === "sse") { + if (!expanded.url) { + throw new Error( + `MCP server "${name}" requires url for type "${serverType}"` + ) + } + + const config: McpRemoteConfig = { + type: "remote", + url: expanded.url, + enabled: true, + } + + if (expanded.headers && Object.keys(expanded.headers).length > 0) { + config.headers = expanded.headers + } + + return config + } + + if (!expanded.command) { + throw new Error(`MCP server "${name}" requires command for stdio type`) + } + + const commandArray = [expanded.command, ...(expanded.args ?? [])] + + const config: McpLocalConfig = { + type: "local", + command: commandArray, + enabled: true, + } + + if (expanded.env && Object.keys(expanded.env).length > 0) { + config.environment = expanded.env + } + + return config +} diff --git a/src/features/claude-code-mcp-loader/types.ts b/src/features/claude-code-mcp-loader/types.ts new file mode 100644 index 0000000..838ff61 --- /dev/null +++ b/src/features/claude-code-mcp-loader/types.ts @@ -0,0 +1,42 @@ +export type McpScope = "user" | "project" | "local" + +export interface ClaudeCodeMcpServer { + type?: "http" | "sse" | "stdio" + url?: string + command?: string + args?: string[] + env?: Record + headers?: Record + disabled?: boolean +} + +export interface ClaudeCodeMcpConfig { + mcpServers?: Record +} + +export interface McpLocalConfig { + type: "local" + command: string[] + environment?: Record + enabled?: boolean +} + +export interface McpRemoteConfig { + type: "remote" + url: string + headers?: Record + enabled?: boolean +} + +export type McpServerConfig = McpLocalConfig | McpRemoteConfig + +export interface LoadedMcpServer { + name: string + scope: McpScope + config: McpServerConfig +} + +export interface McpLoadResult { + servers: Record + loadedServers: LoadedMcpServer[] +} diff --git a/src/features/claude-code-session-state/detector.ts b/src/features/claude-code-session-state/detector.ts new file mode 100644 index 0000000..9cb8178 --- /dev/null +++ b/src/features/claude-code-session-state/detector.ts @@ -0,0 +1,21 @@ +export function detectInterrupt(error: unknown): boolean { + if (!error) return false + + if (typeof error === "object") { + const errObj = error as Record + const name = errObj.name as string | undefined + const message = errObj.message as string | undefined + + if (name === "MessageAbortedError" || name === "AbortError") return true + if (name === "DOMException" && message?.includes("abort")) return true + const msgLower = message?.toLowerCase() + if (msgLower?.includes("aborted") || msgLower?.includes("cancelled") || msgLower?.includes("interrupted")) return true + } + + if (typeof error === "string") { + const lower = error.toLowerCase() + return lower.includes("abort") || lower.includes("cancel") || lower.includes("interrupt") + } + + return false +} diff --git a/src/features/claude-code-session-state/index.ts b/src/features/claude-code-session-state/index.ts new file mode 100644 index 0000000..665b542 --- /dev/null +++ b/src/features/claude-code-session-state/index.ts @@ -0,0 +1,3 @@ +export * from "./types" +export * from "./state" +export * from "./detector" diff --git a/src/features/claude-code-session-state/state.ts b/src/features/claude-code-session-state/state.ts new file mode 100644 index 0000000..d0a57b9 --- /dev/null +++ b/src/features/claude-code-session-state/state.ts @@ -0,0 +1,31 @@ +import type { SessionErrorState, SessionInterruptState } from "./types" + +export const sessionErrorState = new Map() +export const sessionInterruptState = new Map() +export const subagentSessions = new Set() +export const sessionFirstMessageProcessed = new Set() + +export let currentSessionID: string | undefined +export let currentSessionTitle: string | undefined +export let mainSessionID: string | undefined + +export function setCurrentSession(id: string | undefined, title: string | undefined) { + currentSessionID = id + currentSessionTitle = title +} + +export function setMainSession(id: string | undefined) { + mainSessionID = id +} + +export function getCurrentSessionID(): string | undefined { + return currentSessionID +} + +export function getCurrentSessionTitle(): string | undefined { + return currentSessionTitle +} + +export function getMainSessionID(): string | undefined { + return mainSessionID +} diff --git a/src/features/claude-code-session-state/types.ts b/src/features/claude-code-session-state/types.ts new file mode 100644 index 0000000..4939871 --- /dev/null +++ b/src/features/claude-code-session-state/types.ts @@ -0,0 +1,8 @@ +export interface SessionErrorState { + hasError: boolean + errorMessage?: string +} + +export interface SessionInterruptState { + interrupted: boolean +}