diff --git a/CLAUDE.md b/CLAUDE.md index 928ca1ef..0ef5b023 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,7 @@ # CLAUDE.md +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + ## Project Overview Claudian - An Obsidian plugin that embeds Claude Code as a sidebar chat interface. The vault directory becomes Claude's working directory, giving it full agentic capabilities: file read/write, bash commands, and multi-step workflows. @@ -14,19 +16,26 @@ npm run lint # Lint code npm run lint:fix # Lint and auto-fix npm run test # Run tests npm run test:watch # Run tests in watch mode + +# Run a single test file +npm run test -- --selectProjects unit --testPathPattern 'path/to/test' ``` ## Architecture +Entry point: `src/main.ts` → `ClaudianPlugin` (Obsidian `Plugin` subclass). Registers the sidebar view, settings tab, commands (open view, inline edit, tab management). + +Key external dependencies: `@anthropic-ai/claude-agent-sdk` (Claude Agent SDK — all LLM interaction), `@modelcontextprotocol/sdk` (MCP server connections). + | Layer | Purpose | Details | |-------|---------|---------| | **core** | Infrastructure (no feature deps) | See [`src/core/CLAUDE.md`](src/core/CLAUDE.md) | | **features/chat** | Main sidebar interface | See [`src/features/chat/CLAUDE.md`](src/features/chat/CLAUDE.md) | | **features/inline-edit** | Inline edit modal | `InlineEditService`, read-only tools | | **features/settings** | Settings tab | UI components for all settings | -| **shared** | Reusable UI | Dropdowns, instruction modal, fork target modal, @-mention, icons | +| **shared** | Reusable UI | Dropdowns, modals, @-mention, icons | | **i18n** | Internationalization | 10 locales | -| **utils** | Utility functions | date, path, env, editor, session, markdown, diff, context, sdkSession, frontmatter, slashCommand, mcp, claudeCli, externalContext, externalContextScanner, fileLink, imageEmbed, inlineEdit | +| **utils** | Stateless utility functions | One file per concern (e.g., `env.ts`, `sdkSession.ts`, `diff.ts`) | | **style** | Modular CSS | See [`src/style/CLAUDE.md`](src/style/CLAUDE.md) | ## Tests @@ -37,7 +46,7 @@ npm run test -- --selectProjects integration # Run integration tests npm run test:coverage -- --selectProjects unit # Unit coverage ``` -Tests mirror `src/` structure in `tests/unit/` and `tests/integration/`. +Tests mirror `src/` structure in `tests/unit/` and `tests/integration/`. Path aliases: `@/` → `src/`, `@test/` → `tests/`. ## Storage diff --git a/README.md b/README.md index 140160f5..697d140e 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ An Obsidian plugin that embeds Claude Code as an AI collaborator in your vault. - **Custom Agents**: Define custom subagents that Claude can invoke, with support for tool restrictions and model overrides. - **Claude Code Plugins**: Enable Claude Code plugins installed via the CLI, with automatic discovery from `~/.claude/plugins` and per-vault configuration. Plugin skills, agents, and slash commands integrate seamlessly. - **MCP Support**: Connect external tools and data sources via Model Context Protocol servers (stdio, SSE, HTTP) with context-saving mode and `@`-mention activation. -- **Advanced Model Control**: Select between Haiku, Sonnet, and Opus, configure custom models via environment variables, fine-tune thinking budget, and enable Sonnet with 1M context window (requires Max subscription). +- **Advanced Model Control**: Auto-detects available models from the SDK at runtime (supports custom CLIs like `claude-internal`), configure custom models via environment variables, fine-tune thinking budget, and enable Sonnet with 1M context window (requires Max subscription). - **Plan Mode**: Toggle plan mode via Shift+Tab in the chat input. Claudian explores and designs before implementing, presenting a plan for approval with options to approve in a new session, continue in the current session, or provide feedback. - **Security**: Permission modes (YOLO/Safe/Plan), safety blocklist, and vault confinement with symlink-safe checks. - **Claude in Chrome**: Allow Claude to interact with Chrome through the `claude-in-chrome` extension. diff --git a/README_CN.md b/README_CN.md new file mode 100644 index 00000000..2459920a --- /dev/null +++ b/README_CN.md @@ -0,0 +1,271 @@ +# Claudian + +![GitHub stars](https://img.shields.io/github/stars/YishenTu/claudian?style=social) +![GitHub release](https://img.shields.io/github/v/release/YishenTu/claudian) +![License](https://img.shields.io/github/license/YishenTu/claudian) + +![Preview](Preview.png) + +一个将 Claude Code 嵌入 Obsidian 的插件,让 Claude 成为你的 AI 协作伙伴。你的 Vault 即为 Claude 的工作目录,拥有完整的 Agent 能力:文件读写、搜索、Bash 命令执行和多步骤工作流。 + +## 功能特性 + +- **完整的 Agent 能力**:利用 Claude Code 的能力在 Obsidian Vault 中读取、写入、编辑文件,搜索内容以及执行 Bash 命令。 +- **上下文感知**:自动附加当前聚焦的笔记,通过 `@` 提及其他文件,按标签排除笔记,包含编辑器选中内容(高亮),以及访问外部目录获取额外上下文。 +- **视觉支持**:通过拖放、粘贴或输入文件路径发送图片进行分析。 +- **内联编辑**:选中文本 + 快捷键,直接在笔记中编辑,支持词级 diff 预览和只读工具访问以获取上下文。 +- **指令模式(`#`)**:在聊天输入框中直接添加精炼的自定义指令到系统提示词,支持在弹窗中审查和编辑。 +- **斜杠命令**:创建可复用的提示模板,通过 `/command` 触发,支持参数占位符、`@file` 引用和可选的内联 Bash 替换。 +- **技能(Skills)**:通过可复用的能力模块扩展 Claudian,基于上下文自动调用,兼容 Claude Code 的技能格式。 +- **自定义 Agent**:定义 Claude 可调用的自定义子 Agent,支持工具限制和模型覆盖。 +- **Claude Code 插件**:启用通过 CLI 安装的 Claude Code 插件,自动从 `~/.claude/plugins` 发现,支持按 Vault 配置。插件的技能、Agent 和斜杠命令可无缝集成。 +- **MCP 支持**:通过 Model Context Protocol 服务器(stdio、SSE、HTTP)连接外部工具和数据源,支持上下文保存模式和 `@` 提及激活。 +- **高级模型控制**:运行时自动从 SDK 检测可用模型(支持 `claude-internal` 等定制 CLI),通过环境变量配置自定义模型,精细调节思考预算,以及启用 Sonnet 的 1M 上下文窗口(需要 Max 订阅)。 +- **计划模式**:在聊天输入框中通过 Shift+Tab 切换计划模式。Claudian 先探索和设计方案,再提交计划供审批,支持在新会话中批准、在当前会话继续或提供反馈。 +- **安全机制**:权限模式(YOLO/Safe/Plan)、安全黑名单以及 Vault 隔离(支持符号链接安全检查)。 +- **Chrome 中的 Claude**:通过 `claude-in-chrome` 扩展让 Claude 与 Chrome 交互。 + +## 环境要求 + +- 已安装 [Claude Code CLI](https://code.claude.com/docs/en/overview)(强烈推荐通过原生安装方式安装) +- Obsidian v1.8.9+ +- Claude 订阅 / API 或支持 Anthropic API 格式的自定义模型提供商([Openrouter](https://openrouter.ai/docs/guides/guides/claude-code-integration)、[Kimi](https://platform.moonshot.ai/docs/guide/agent-support)、[GLM](https://docs.z.ai/devpack/tool/claude)、[DeepSeek](https://api-docs.deepseek.com/guides/anthropic_api) 等) +- 仅支持桌面端(macOS、Linux、Windows) + +## 安装 + +### 从 GitHub Release 安装(推荐) + +1. 从[最新 Release](https://github.com/YishenTu/claudian/releases/latest) 下载 `main.js`、`manifest.json` 和 `styles.css` +2. 在 Vault 的插件文件夹中创建 `claudian` 文件夹: + ``` + /path/to/vault/.obsidian/plugins/claudian/ + ``` +3. 将下载的文件复制到 `claudian` 文件夹中 +4. 在 Obsidian 中启用插件: + - 设置 → 第三方插件 → 启用 "Claudian" + +### 使用 BRAT 安装 + +[BRAT](https://github.com/TfTHacker/obsidian42-brat)(Beta Reviewers Auto-update Tester)允许你直接从 GitHub 安装并自动更新插件。 + +1. 从 Obsidian 社区插件安装 BRAT 插件 +2. 在设置 → 第三方插件中启用 BRAT +3. 打开 BRAT 设置,点击 "Add Beta plugin" +4. 输入仓库 URL:`https://github.com/YishenTu/claudian` +5. 点击 "Add Plugin",BRAT 将自动安装 Claudian +6. 在设置 → 第三方插件中启用 Claudian + +> **提示**:BRAT 会自动检查更新,并在新版本可用时通知你。 + +### 从源码安装(开发) + +1. 将此仓库克隆到 Vault 的插件文件夹: + ```bash + cd /path/to/vault/.obsidian/plugins + git clone https://github.com/YishenTu/claudian.git + cd claudian + ``` + +2. 安装依赖并构建: + ```bash + npm install + npm run build + ``` + +3. 在 Obsidian 中启用插件: + - 设置 → 第三方插件 → 启用 "Claudian" + +### 开发 + +```bash +# 监听模式 +npm run dev + +# 生产构建 +npm run build +``` + +> **提示**:复制 `.env.local.example` 为 `.env.local` 或执行 `npm install` 并设置 Vault 路径,以便在开发期间自动复制文件。 + +## 使用方法 + +**两种模式:** +1. 点击功能区的机器人图标或使用命令面板打开聊天 +2. 选中文本 + 快捷键进行内联编辑 + +像使用 Claude Code 一样使用——在 Vault 中读取、写入、编辑和搜索文件。 + +### 上下文 + +- **文件**:自动附加当前聚焦的笔记;输入 `@` 附加其他文件 +- **@-提及下拉框**:输入 `@` 查看 MCP 服务器、Agent、外部上下文和 Vault 文件 + - `@Agents/` 显示可选的自定义 Agent + - `@mcp-server` 启用上下文保存的 MCP 服务器 + - `@folder/` 过滤来自特定外部上下文的文件(如 `@workspace/`) + - 默认显示 Vault 文件 +- **选中内容**:在编辑器中选中文本后聊天——选中内容会自动包含 +- **图片**:拖放、粘贴或输入路径;配置媒体文件夹以支持 `![[image]]` 嵌入 +- **外部上下文**:点击工具栏的文件夹图标访问 Vault 外部目录 + +### 功能详情 + +- **内联编辑**:选中文本 + 快捷键,直接在笔记中编辑,支持词级 diff 预览 +- **指令模式**:输入 `#` 添加精炼的指令到系统提示词 +- **斜杠命令**:输入 `/` 使用自定义提示模板或技能 +- **技能**:将 `skill/SKILL.md` 文件添加到 `~/.claude/skills/` 或 `{vault}/.claude/skills/`,建议使用 Claude Code 管理技能 +- **自定义 Agent**:将 `agent.md` 文件添加到 `~/.claude/agents/`(全局)或 `{vault}/.claude/agents/`(Vault 级);在聊天中通过 `@Agents/` 选择,或提示 Claudian 调用 Agent +- **Claude Code 插件**:通过设置 → Claude Code 插件启用,建议使用 Claude Code 管理插件 +- **MCP**:通过设置 → MCP 服务器添加外部工具;在聊天中使用 `@mcp-server` 激活 + +## 配置 + +### 设置项 + +**个性化** +- **用户名**:你的名字,用于个性化问候 +- **排除标签**:阻止笔记自动加载的标签(如 `sensitive`、`private`) +- **媒体文件夹**:配置 Vault 存储附件的位置以支持嵌入图片(如 `attachments`) +- **自定义系统提示词**:附加到默认系统提示词的额外指令(指令模式 `#` 保存在此处) +- **启用自动滚动**:切换流式输出时是否自动滚动到底部(默认:开启) +- **自动生成对话标题**:切换在发送第一条消息后是否自动生成 AI 标题 +- **标题生成模型**:用于自动生成对话标题的模型(默认:Auto/Haiku) +- **Vim 风格导航映射**:配置按键绑定,如 `map w scrollUp`、`map s scrollDown`、`map i focusInput` + +**快捷键** +- **内联编辑快捷键**:触发选中文本内联编辑的快捷键 +- **打开聊天快捷键**:打开聊天侧边栏的快捷键 + +**斜杠命令** +- 创建/编辑/导入/导出自定义 `/commands`(可选覆盖模型和允许的工具) + +**MCP 服务器** +- 添加/编辑/验证/删除 MCP 服务器配置,支持上下文保存模式 + +**Claude Code 插件** +- 启用/禁用从 `~/.claude/plugins` 发现的 Claude Code 插件 +- 用户级插件在所有 Vault 中可用;项目级插件仅在匹配的 Vault 中可用 + +**安全** +- **加载用户 Claude 设置**:加载 `~/.claude/settings.json`(用户的 Claude Code 权限规则可能绕过 Safe 模式) +- **启用命令黑名单**:阻止危险的 Bash 命令(默认:开启) +- **阻止的命令**:要阻止的模式(支持正则、平台特定) +- **允许的导出路径**:Vault 外部可导出文件的路径(默认:`~/Desktop`、`~/Downloads`)。支持 `~`、`$VAR`、`${VAR}` 和 `%VAR%`(Windows)。 + +**环境变量** +- **自定义变量**:Claude SDK 的环境变量(KEY=VALUE 格式,支持 `export ` 前缀) +- **环境片段**:保存和恢复环境变量配置 + +**高级** +- **Claude CLI 路径**:Claude Code CLI 的自定义路径(留空自动检测) + +## 安全与权限 + +| 范围 | 访问权限 | +|------|----------| +| **Vault** | 完全读写(通过 `realpath` 的符号链接安全检查) | +| **导出路径** | 仅写入(如 `~/Desktop`、`~/Downloads`) | +| **外部上下文** | 完全读写(仅限会话,通过文件夹图标添加) | + +- **YOLO 模式**:无需审批提示;所有工具调用自动执行(默认) +- **Safe 模式**:每次工具调用需审批;Bash 需精确匹配,文件工具允许前缀匹配 +- **Plan 模式**:先探索和设计方案再实施。在聊天输入框中通过 Shift+Tab 切换 + +## 隐私与数据使用 + +- **发送到 API 的内容**:你的输入、附加文件、图片和工具调用输出。默认:Anthropic;可通过 `ANTHROPIC_BASE_URL` 自定义端点。 +- **本地存储**:设置、会话元数据和命令存储在 `vault/.claude/` 中;会话消息存储在 `~/.claude/projects/`(SDK 原生);旧版会话在 `vault/.claude/sessions/` 中。 +- **无遥测**:除你配置的 API 提供商外无任何追踪。 + +## 故障排除 + +### Claude CLI 未找到 + +如果遇到 `spawn claude ENOENT` 或 `Claude CLI not found`,说明插件无法自动检测你的 Claude 安装位置。使用 Node 版本管理器(nvm、fnm、volta)时常见。 + +**解决方案**:找到 CLI 路径并在设置 → 高级 → Claude CLI 路径中设置。 + +| 平台 | 命令 | 示例路径 | +|------|------|----------| +| macOS/Linux | `which claude` | `/Users/you/.volta/bin/claude` | +| Windows(原生) | `where.exe claude` | `C:\Users\you\AppData\Local\Claude\claude.exe` | +| Windows(npm) | `npm root -g` | `{root}\@anthropic-ai\claude-code\cli.js` | + +> **注意**:在 Windows 上,避免使用 `.cmd` 包装器。使用 `claude.exe` 或 `cli.js`。 + +**替代方案**:在设置 → 环境变量 → 自定义变量中将 Node.js bin 目录添加到 PATH。 + +### npm CLI 和 Node.js 不在同一目录 + +如果使用 npm 安装的 CLI,检查 `claude` 和 `node` 是否在同一目录: +```bash +dirname $(which claude) +dirname $(which node) +``` + +如果不同,Obsidian 等 GUI 应用可能找不到 Node.js。 + +**解决方案**: +1. 安装原生二进制文件(推荐) +2. 在设置 → 环境变量中添加 Node.js 路径:`PATH=/path/to/node/bin` + +**仍有问题?** [提交 GitHub Issue](https://github.com/YishenTu/claudian/issues),附上你的平台、CLI 路径和错误信息。 + +## 架构 + +``` +src/ +├── main.ts # 插件入口 +├── core/ # 核心基础设施 +│ ├── agent/ # Claude Agent SDK 封装(ClaudianService) +│ ├── agents/ # 自定义 Agent 管理(AgentManager) +│ ├── commands/ # 斜杠命令管理(SlashCommandManager) +│ ├── hooks/ # PreToolUse/PostToolUse 钩子 +│ ├── images/ # 图片缓存和加载 +│ ├── mcp/ # MCP 服务器配置、服务和测试 +│ ├── plugins/ # Claude Code 插件发现和管理 +│ ├── prompts/ # Agent 系统提示词 +│ ├── sdk/ # SDK 消息转换 +│ ├── security/ # 审批、黑名单、路径验证 +│ ├── storage/ # 分布式存储系统 +│ ├── tools/ # 工具常量和工具函数 +│ └── types/ # 类型定义 +├── features/ # 功能模块 +│ ├── chat/ # 主聊天视图 + UI、渲染、控制器、标签页 +│ ├── inline-edit/ # 内联编辑服务 + UI +│ └── settings/ # 设置标签页 UI +├── shared/ # 共享 UI 组件和弹窗 +│ ├── components/ # 输入工具栏组件、下拉框、选区高亮 +│ ├── mention/ # @-提及下拉框控制器 +│ ├── modals/ # 指令弹窗 +│ └── icons.ts # 共享 SVG 图标 +├── i18n/ # 国际化(10 种语言) +├── utils/ # 模块化工具函数 +└── style/ # 模块化 CSS(→ styles.css) +``` + +## 路线图 + +- [x] Claude Code 插件支持 +- [x] 自定义 Agent(子 Agent)支持 +- [x] Chrome 中的 Claude 支持 +- [x] `/compact` 命令 +- [x] 计划模式 +- [x] `rewind` 和 `fork` 支持(包括 `/fork` 命令) +- [x] `!command` 支持 +- [ ] 工具渲染器优化 +- [ ] Hooks 和其他高级功能 +- [ ] 更多功能即将推出! + +## 许可证 + +基于 [MIT 许可证](LICENSE) 授权。 + +## Star 历史 + +[![Star History Chart](https://api.star-history.com/svg?repos=YishenTu/claudian&type=date&legend=top-left)](https://www.star-history.com/#YishenTu/claudian&type=date&legend=top-left) + +## 致谢 + +- [Obsidian](https://obsidian.md) 提供的插件 API +- [Anthropic](https://anthropic.com) 的 Claude 和 [Claude Agent SDK](https://platform.claude.com/docs/en/agent-sdk/overview) diff --git a/src/core/agent/ClaudianService.ts b/src/core/agent/ClaudianService.ts index b166ae85..7eb53ad0 100644 --- a/src/core/agent/ClaudianService.ts +++ b/src/core/agent/ClaudianService.ts @@ -14,6 +14,7 @@ import type { CanUseTool, McpServerConfig, + ModelInfo, Options, PermissionMode as SDKPermissionMode, PermissionResult, @@ -1414,6 +1415,23 @@ export class ClaudianService { } } + async getSupportedModels(): Promise<{ value: string; label: string; description: string }[]> { + if (!this.persistentQuery) { + return []; + } + + try { + const sdkModels: ModelInfo[] = await this.persistentQuery.supportedModels(); + return sdkModels.map((m) => ({ + value: m.value, + label: m.displayName, + description: m.description, + })); + } catch { + return []; + } + } + /** * Set the session ID (for restoring from saved conversation). * Closes persistent query synchronously if session is changing, then ensures query is ready. diff --git a/src/core/agents/AgentManager.ts b/src/core/agents/AgentManager.ts index fbedc429..d40c4c36 100644 --- a/src/core/agents/AgentManager.ts +++ b/src/core/agents/AgentManager.ts @@ -7,15 +7,20 @@ */ import * as fs from 'fs'; -import * as os from 'os'; import * as path from 'path'; +import { getGlobalClaudePath, getVaultClaudePath } from '../../utils/claudePaths'; import type { PluginManager } from '../plugins'; import type { AgentDefinition } from '../types'; import { buildAgentFromFrontmatter, parseAgentFile } from './AgentStorage'; -const GLOBAL_AGENTS_DIR = path.join(os.homedir(), '.claude', 'agents'); -const VAULT_AGENTS_DIR = '.claude/agents'; +function getGlobalAgentsDir(): string { + return getGlobalClaudePath('agents'); +} + +function getVaultAgentsDir(): string { + return getVaultClaudePath('agents'); +} const PLUGIN_AGENTS_DIR = 'agents'; // Fallback built-in agent names for before the init message arrives. @@ -112,11 +117,11 @@ export class AgentManager { } private async loadVaultAgents(): Promise { - await this.loadAgentsFromDirectory(path.join(this.vaultPath, VAULT_AGENTS_DIR), 'vault'); + await this.loadAgentsFromDirectory(path.join(this.vaultPath, getVaultAgentsDir()), 'vault'); } private async loadGlobalAgents(): Promise { - await this.loadAgentsFromDirectory(GLOBAL_AGENTS_DIR, 'global'); + await this.loadAgentsFromDirectory(getGlobalAgentsDir(), 'global'); } private async loadAgentsFromDirectory( diff --git a/src/core/plugins/PluginManager.ts b/src/core/plugins/PluginManager.ts index aa7e2a8a..33e615d2 100644 --- a/src/core/plugins/PluginManager.ts +++ b/src/core/plugins/PluginManager.ts @@ -8,14 +8,19 @@ import * as fs from 'fs'; import { Notice } from 'obsidian'; -import * as os from 'os'; import * as path from 'path'; +import { getGlobalClaudePath, getVaultClaudePath } from '../../utils/claudePaths'; import type { CCSettingsStorage } from '../storage/CCSettingsStorage'; import type { ClaudianPlugin, InstalledPluginEntry, InstalledPluginsFile, PluginScope } from '../types'; -const INSTALLED_PLUGINS_PATH = path.join(os.homedir(), '.claude', 'plugins', 'installed_plugins.json'); -const GLOBAL_SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json'); +function getInstalledPluginsPath(): string { + return getGlobalClaudePath('plugins', 'installed_plugins.json'); +} + +function getGlobalSettingsPath(): string { + return getGlobalClaudePath('settings.json'); +} interface SettingsFile { enabledPlugins?: Record; @@ -80,8 +85,8 @@ export class PluginManager { } async loadPlugins(): Promise { - const installedPlugins = readJsonFile(INSTALLED_PLUGINS_PATH); - const globalSettings = readJsonFile(GLOBAL_SETTINGS_PATH); + const installedPlugins = readJsonFile(getInstalledPluginsPath()); + const globalSettings = readJsonFile(getGlobalSettingsPath()); const projectSettings = await this.loadProjectSettings(); const globalEnabled = globalSettings?.enabledPlugins ?? {}; @@ -125,7 +130,7 @@ export class PluginManager { } private async loadProjectSettings(): Promise { - const projectSettingsPath = path.join(this.vaultPath, '.claude', 'settings.json'); + const projectSettingsPath = path.join(this.vaultPath, getVaultClaudePath('settings.json')); return readJsonFile(projectSettingsPath); } diff --git a/src/core/storage/AgentVaultStorage.ts b/src/core/storage/AgentVaultStorage.ts index f91011b2..9c097d95 100644 --- a/src/core/storage/AgentVaultStorage.ts +++ b/src/core/storage/AgentVaultStorage.ts @@ -1,8 +1,14 @@ import { serializeAgent } from '../../utils/agent'; +import { getVaultClaudePath } from '../../utils/claudePaths'; import { buildAgentFromFrontmatter, parseAgentFile } from '../agents/AgentStorage'; import type { AgentDefinition } from '../types'; import type { VaultFileAdapter } from './VaultFileAdapter'; +export function getAgentsPath(): string { + return getVaultClaudePath('agents'); +} + +/** @deprecated Use getAgentsPath() instead */ export const AGENTS_PATH = '.claude/agents'; export class AgentVaultStorage { @@ -12,7 +18,7 @@ export class AgentVaultStorage { const agents: AgentDefinition[] = []; try { - const files = await this.adapter.listFiles(AGENTS_PATH); + const files = await this.adapter.listFiles(getAgentsPath()); for (const filePath of files) { if (!filePath.endsWith('.md')) continue; @@ -66,15 +72,15 @@ export class AgentVaultStorage { private resolvePath(agent: AgentDefinition): string { if (!agent.filePath) { - return `${AGENTS_PATH}/${agent.name}.md`; + return `${getAgentsPath()}/${agent.name}.md`; } const normalized = agent.filePath.replace(/\\/g, '/'); - const idx = normalized.lastIndexOf(`${AGENTS_PATH}/`); + const idx = normalized.lastIndexOf(`${getAgentsPath()}/`); if (idx !== -1) { return normalized.slice(idx); } - return `${AGENTS_PATH}/${agent.name}.md`; + return `${getAgentsPath()}/${agent.name}.md`; } private isFileNotFoundError(error: unknown): boolean { diff --git a/src/core/storage/CCSettingsStorage.ts b/src/core/storage/CCSettingsStorage.ts index 21c35b7e..24a7d3c1 100644 --- a/src/core/storage/CCSettingsStorage.ts +++ b/src/core/storage/CCSettingsStorage.ts @@ -12,6 +12,7 @@ * Claudian-specific settings go in claudian-settings.json. */ +import { getVaultClaudePath } from '../../utils/claudePaths'; import type { CCPermissions, CCSettings, @@ -27,6 +28,11 @@ import { CLAUDIAN_ONLY_FIELDS } from './migrationConstants'; import type { VaultFileAdapter } from './VaultFileAdapter'; /** Path to CC settings file relative to vault root. */ +export function getCCSettingsPath(): string { + return getVaultClaudePath('settings.json'); +} + +/** @deprecated Use getCCSettingsPath() instead */ export const CC_SETTINGS_PATH = '.claude/settings.json'; /** Schema URL for CC settings. */ @@ -95,11 +101,12 @@ export class CCSettingsStorage { * Throws if file exists but cannot be read or parsed. */ async load(): Promise { - if (!(await this.adapter.exists(CC_SETTINGS_PATH))) { + const settingsPath = getCCSettingsPath(); + if (!(await this.adapter.exists(settingsPath))) { return { ...DEFAULT_CC_SETTINGS }; } - const content = await this.adapter.read(CC_SETTINGS_PATH); + const content = await this.adapter.read(settingsPath); const stored = JSON.parse(content) as Record; // Check for legacy format and migrate if needed @@ -131,9 +138,10 @@ export class CCSettingsStorage { async save(settings: CCSettings, stripClaudianFields: boolean = false): Promise { // Load existing to preserve CC-specific fields we don't manage let existing: Record = {}; - if (await this.adapter.exists(CC_SETTINGS_PATH)) { + const settingsPath = getCCSettingsPath(); + if (await this.adapter.exists(settingsPath)) { try { - const content = await this.adapter.read(CC_SETTINGS_PATH); + const content = await this.adapter.read(settingsPath); const parsed = JSON.parse(content) as Record; // Only strip Claudian-only fields during explicit migration @@ -168,11 +176,11 @@ export class CCSettingsStorage { } const content = JSON.stringify(merged, null, 2); - await this.adapter.write(CC_SETTINGS_PATH, content); + await this.adapter.write(getCCSettingsPath(), content); } async exists(): Promise { - return this.adapter.exists(CC_SETTINGS_PATH); + return this.adapter.exists(getCCSettingsPath()); } async getPermissions(): Promise { diff --git a/src/core/storage/ClaudianSettingsStorage.ts b/src/core/storage/ClaudianSettingsStorage.ts index f1d8c30e..76025c81 100644 --- a/src/core/storage/ClaudianSettingsStorage.ts +++ b/src/core/storage/ClaudianSettingsStorage.ts @@ -15,11 +15,17 @@ * - State (merged from data.json) */ +import { getVaultClaudePath } from '../../utils/claudePaths'; import type { ClaudeModel, ClaudianSettings, PlatformBlockedCommands } from '../types'; import { DEFAULT_SETTINGS, getDefaultBlockedCommands } from '../types'; import type { VaultFileAdapter } from './VaultFileAdapter'; /** Path to Claudian settings file relative to vault root. */ +export function getClaudianSettingsPath(): string { + return getVaultClaudePath('claudian-settings.json'); +} + +/** @deprecated Use getClaudianSettingsPath() instead */ export const CLAUDIAN_SETTINGS_PATH = '.claude/claudian-settings.json'; /** Fields that are loaded separately (slash commands from .claude/commands/). */ @@ -84,11 +90,12 @@ export class ClaudianSettingsStorage { * Throws if file exists but cannot be read or parsed. */ async load(): Promise { - if (!(await this.adapter.exists(CLAUDIAN_SETTINGS_PATH))) { + const settingsPath = getClaudianSettingsPath(); + if (!(await this.adapter.exists(settingsPath))) { return this.getDefaults(); } - const content = await this.adapter.read(CLAUDIAN_SETTINGS_PATH); + const content = await this.adapter.read(settingsPath); const stored = JSON.parse(content) as Record; const { activeConversationId: _activeConversationId, ...storedWithoutLegacy } = stored; @@ -107,11 +114,11 @@ export class ClaudianSettingsStorage { async save(settings: StoredClaudianSettings): Promise { const content = JSON.stringify(settings, null, 2); - await this.adapter.write(CLAUDIAN_SETTINGS_PATH, content); + await this.adapter.write(getClaudianSettingsPath(), content); } async exists(): Promise { - return this.adapter.exists(CLAUDIAN_SETTINGS_PATH); + return this.adapter.exists(getClaudianSettingsPath()); } async update(updates: Partial): Promise { @@ -124,11 +131,12 @@ export class ClaudianSettingsStorage { * Used only for one-time migration to tabManagerState. */ async getLegacyActiveConversationId(): Promise { - if (!(await this.adapter.exists(CLAUDIAN_SETTINGS_PATH))) { + const settingsPath = getClaudianSettingsPath(); + if (!(await this.adapter.exists(settingsPath))) { return null; } - const content = await this.adapter.read(CLAUDIAN_SETTINGS_PATH); + const content = await this.adapter.read(settingsPath); const stored = JSON.parse(content) as Record; const value = stored.activeConversationId; @@ -143,11 +151,12 @@ export class ClaudianSettingsStorage { * Remove legacy activeConversationId from claudian-settings.json. */ async clearLegacyActiveConversationId(): Promise { - if (!(await this.adapter.exists(CLAUDIAN_SETTINGS_PATH))) { + const settingsPath = getClaudianSettingsPath(); + if (!(await this.adapter.exists(settingsPath))) { return; } - const content = await this.adapter.read(CLAUDIAN_SETTINGS_PATH); + const content = await this.adapter.read(settingsPath); const stored = JSON.parse(content) as Record; if (!('activeConversationId' in stored)) { @@ -156,7 +165,7 @@ export class ClaudianSettingsStorage { delete stored.activeConversationId; const nextContent = JSON.stringify(stored, null, 2); - await this.adapter.write(CLAUDIAN_SETTINGS_PATH, nextContent); + await this.adapter.write(getClaudianSettingsPath(), nextContent); } async setLastModel(model: ClaudeModel, isCustom: boolean): Promise { diff --git a/src/core/storage/McpStorage.ts b/src/core/storage/McpStorage.ts index 053bf501..fef1a0c8 100644 --- a/src/core/storage/McpStorage.ts +++ b/src/core/storage/McpStorage.ts @@ -17,6 +17,7 @@ * } */ +import { getVaultClaudePath } from '../../utils/claudePaths'; import type { ClaudianMcpConfigFile, ClaudianMcpServer, @@ -27,6 +28,11 @@ import { DEFAULT_MCP_SERVER, isValidMcpServerConfig } from '../types'; import type { VaultFileAdapter } from './VaultFileAdapter'; /** Path to MCP config file relative to vault root. */ +export function getMcpConfigPath(): string { + return getVaultClaudePath('mcp.json'); +} + +/** @deprecated Use getMcpConfigPath() instead */ export const MCP_CONFIG_PATH = '.claude/mcp.json'; export class McpStorage { @@ -34,11 +40,11 @@ export class McpStorage { async load(): Promise { try { - if (!(await this.adapter.exists(MCP_CONFIG_PATH))) { + if (!(await this.adapter.exists(getMcpConfigPath()))) { return []; } - const content = await this.adapter.read(MCP_CONFIG_PATH); + const content = await this.adapter.read(getMcpConfigPath()); const file = JSON.parse(content) as ClaudianMcpConfigFile; if (!file.mcpServers || typeof file.mcpServers !== 'object') { @@ -116,9 +122,9 @@ export class McpStorage { } let existing: Record | null = null; - if (await this.adapter.exists(MCP_CONFIG_PATH)) { + if (await this.adapter.exists(getMcpConfigPath())) { try { - const raw = await this.adapter.read(MCP_CONFIG_PATH); + const raw = await this.adapter.read(getMcpConfigPath()); const parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object') { existing = parsed as Record; @@ -150,11 +156,11 @@ export class McpStorage { } const content = JSON.stringify(file, null, 2); - await this.adapter.write(MCP_CONFIG_PATH, content); + await this.adapter.write(getMcpConfigPath(), content); } async exists(): Promise { - return this.adapter.exists(MCP_CONFIG_PATH); + return this.adapter.exists(getMcpConfigPath()); } /** diff --git a/src/core/storage/SessionStorage.ts b/src/core/storage/SessionStorage.ts index 76d98173..2ada1080 100644 --- a/src/core/storage/SessionStorage.ts +++ b/src/core/storage/SessionStorage.ts @@ -12,6 +12,7 @@ * ``` */ +import { getVaultClaudePath } from '../../utils/claudePaths'; import { isSubagentToolName } from '../tools/toolNames'; import type { ChatMessage, @@ -24,6 +25,11 @@ import type { import type { VaultFileAdapter } from './VaultFileAdapter'; /** Path to sessions folder relative to vault root. */ +export function getSessionsPath(): string { + return getVaultClaudePath('sessions'); +} + +/** @deprecated Use getSessionsPath() instead */ export const SESSIONS_PATH = '.claude/sessions'; /** Metadata record stored as first line of JSONL. */ @@ -83,7 +89,7 @@ export class SessionStorage { const metas: ConversationMeta[] = []; try { - const files = await this.adapter.listFiles(SESSIONS_PATH); + const files = await this.adapter.listFiles(getSessionsPath()); for (const filePath of files) { if (!filePath.endsWith('.jsonl')) continue; @@ -112,7 +118,7 @@ export class SessionStorage { let failedCount = 0; try { - const files = await this.adapter.listFiles(SESSIONS_PATH); + const files = await this.adapter.listFiles(getSessionsPath()); for (const filePath of files) { if (!filePath.endsWith('.jsonl')) continue; @@ -139,12 +145,12 @@ export class SessionStorage { } async hasSessions(): Promise { - const files = await this.adapter.listFiles(SESSIONS_PATH); + const files = await this.adapter.listFiles(getSessionsPath()); return files.some(f => f.endsWith('.jsonl')); } getFilePath(id: string): string { - return `${SESSIONS_PATH}/${id}.jsonl`; + return `${getSessionsPath()}/${id}.jsonl`; } private async loadMetaOnly(filePath: string): Promise { @@ -268,14 +274,14 @@ export class SessionStorage { * Native sessions have only id.meta.json or no files yet (SDK stores messages). */ async isNativeSession(id: string): Promise { - const legacyPath = `${SESSIONS_PATH}/${id}.jsonl`; + const legacyPath = `${getSessionsPath()}/${id}.jsonl`; const legacyExists = await this.adapter.exists(legacyPath); // Native if no legacy JSONL exists (new conversation or meta-only) return !legacyExists; } getMetadataPath(id: string): string { - return `${SESSIONS_PATH}/${id}.meta.json`; + return `${getSessionsPath()}/${id}.meta.json`; } async saveMetadata(metadata: SessionMetadata): Promise { @@ -309,7 +315,7 @@ export class SessionStorage { const metas: SessionMetadata[] = []; try { - const files = await this.adapter.listFiles(SESSIONS_PATH); + const files = await this.adapter.listFiles(getSessionsPath()); const metaFiles = files.filter(f => f.endsWith('.meta.json')); @@ -319,7 +325,7 @@ export class SessionStorage { const id = fileName.replace('.meta.json', ''); // Check if this is truly native (no legacy .jsonl exists) - const legacyPath = `${SESSIONS_PATH}/${id}.jsonl`; + const legacyPath = `${getSessionsPath()}/${id}.jsonl`; const legacyExists = await this.adapter.exists(legacyPath); if (legacyExists) { diff --git a/src/core/storage/SkillStorage.ts b/src/core/storage/SkillStorage.ts index 689a783e..0e8434c3 100644 --- a/src/core/storage/SkillStorage.ts +++ b/src/core/storage/SkillStorage.ts @@ -1,7 +1,13 @@ +import { getVaultClaudePath } from '../../utils/claudePaths'; import { parsedToSlashCommand, parseSlashCommandContent, serializeCommand } from '../../utils/slashCommand'; import type { SlashCommand } from '../types'; import type { VaultFileAdapter } from './VaultFileAdapter'; +export function getSkillsPath(): string { + return getVaultClaudePath('skills'); +} + +/** @deprecated Use getSkillsPath() instead */ export const SKILLS_PATH = '.claude/skills'; export class SkillStorage { @@ -11,11 +17,11 @@ export class SkillStorage { const skills: SlashCommand[] = []; try { - const folders = await this.adapter.listFolders(SKILLS_PATH); + const folders = await this.adapter.listFolders(getSkillsPath()); for (const folder of folders) { const skillName = folder.split('/').pop()!; - const skillPath = `${SKILLS_PATH}/${skillName}/SKILL.md`; + const skillPath = `${getSkillsPath()}/${skillName}/SKILL.md`; try { if (!(await this.adapter.exists(skillPath))) continue; @@ -41,7 +47,7 @@ export class SkillStorage { async save(skill: SlashCommand): Promise { const name = skill.name; - const dirPath = `${SKILLS_PATH}/${name}`; + const dirPath = `${getSkillsPath()}/${name}`; const filePath = `${dirPath}/SKILL.md`; await this.adapter.ensureFolder(dirPath); @@ -50,7 +56,7 @@ export class SkillStorage { async delete(skillId: string): Promise { const name = skillId.replace(/^skill-/, ''); - const dirPath = `${SKILLS_PATH}/${name}`; + const dirPath = `${getSkillsPath()}/${name}`; const filePath = `${dirPath}/SKILL.md`; await this.adapter.delete(filePath); await this.adapter.deleteFolder(dirPath); diff --git a/src/core/storage/SlashCommandStorage.ts b/src/core/storage/SlashCommandStorage.ts index dfc1863e..166ae96c 100644 --- a/src/core/storage/SlashCommandStorage.ts +++ b/src/core/storage/SlashCommandStorage.ts @@ -1,8 +1,11 @@ +import { getVaultClaudePath } from '../../utils/claudePaths'; import { parsedToSlashCommand, parseSlashCommandContent, serializeCommand } from '../../utils/slashCommand'; import type { SlashCommand } from '../types'; import type { VaultFileAdapter } from './VaultFileAdapter'; -export const COMMANDS_PATH = '.claude/commands'; +export function getCommandsPath(): string { + return getVaultClaudePath('commands'); +} export class SlashCommandStorage { constructor(private adapter: VaultFileAdapter) {} @@ -11,7 +14,7 @@ export class SlashCommandStorage { const commands: SlashCommand[] = []; try { - const files = await this.adapter.listFilesRecursive(COMMANDS_PATH); + const files = await this.adapter.listFilesRecursive(getCommandsPath()); for (const filePath of files) { if (!filePath.endsWith('.md')) continue; @@ -43,7 +46,7 @@ export class SlashCommandStorage { } async delete(commandId: string): Promise { - const files = await this.adapter.listFilesRecursive(COMMANDS_PATH); + const files = await this.adapter.listFilesRecursive(getCommandsPath()); for (const filePath of files) { if (!filePath.endsWith('.md')) continue; @@ -58,7 +61,7 @@ export class SlashCommandStorage { getFilePath(command: SlashCommand): string { const safeName = command.name.replace(/[^a-zA-Z0-9_/-]/g, '-'); - return `${COMMANDS_PATH}/${safeName}.md`; + return `${getCommandsPath()}/${safeName}.md`; } private parseFile(content: string, filePath: string): SlashCommand { @@ -77,7 +80,7 @@ export class SlashCommandStorage { // a--b.md -> cmd-a-_-_b // a/b-c.md -> cmd-a--b-_c const relativePath = filePath - .replace(`${COMMANDS_PATH}/`, '') + .replace(`${getCommandsPath()}/`, '') .replace(/\.md$/, ''); const escaped = relativePath .replace(/-/g, '-_') // Escape dashes first @@ -87,7 +90,7 @@ export class SlashCommandStorage { private filePathToName(filePath: string): string { return filePath - .replace(`${COMMANDS_PATH}/`, '') + .replace(`${getCommandsPath()}/`, '') .replace(/\.md$/, ''); } } diff --git a/src/core/storage/StorageService.ts b/src/core/storage/StorageService.ts index 65d34522..a4a0d5b3 100644 --- a/src/core/storage/StorageService.ts +++ b/src/core/storage/StorageService.ts @@ -17,6 +17,7 @@ import type { App, Plugin } from 'obsidian'; import { Notice } from 'obsidian'; +import { getVaultClaudeDir } from '../../utils/claudePaths'; import type { CCPermissions, CCSettings, @@ -31,8 +32,8 @@ import { DEFAULT_SETTINGS, legacyPermissionsToCCPermissions, } from '../types'; -import { AGENTS_PATH, AgentVaultStorage } from './AgentVaultStorage'; -import { CC_SETTINGS_PATH, CCSettingsStorage, isLegacyPermissionsFormat } from './CCSettingsStorage'; +import { AgentVaultStorage, getAgentsPath } from './AgentVaultStorage'; +import { CCSettingsStorage, getCCSettingsPath, isLegacyPermissionsFormat } from './CCSettingsStorage'; import { ClaudianSettingsStorage, normalizeBlockedCommands, @@ -44,16 +45,16 @@ import { convertEnvObjectToString, mergeEnvironmentVariables, } from './migrationConstants'; -import { SESSIONS_PATH, SessionStorage } from './SessionStorage'; -import { SKILLS_PATH, SkillStorage } from './SkillStorage'; -import { COMMANDS_PATH, SlashCommandStorage } from './SlashCommandStorage'; +import { getSessionsPath, SessionStorage } from './SessionStorage'; +import { getSkillsPath, SkillStorage } from './SkillStorage'; +import { getCommandsPath, SlashCommandStorage } from './SlashCommandStorage'; import { VaultFileAdapter } from './VaultFileAdapter'; -/** Base path for all Claudian storage. */ +/** @deprecated Use getVaultClaudeDir() from claudePaths instead */ export const CLAUDE_PATH = '.claude'; -/** Legacy settings path (now CC settings). */ -export const SETTINGS_PATH = CC_SETTINGS_PATH; +/** @deprecated Use getCCSettingsPath() instead */ +export const SETTINGS_PATH = '.claude/settings.json'; /** * Combined settings for the application. @@ -138,7 +139,11 @@ export class StorageService { async initialize(): Promise { await this.ensureDirectories(); - await this.runMigrations(); + try { + await this.runMigrations(); + } catch { + // Migration failures are non-fatal; settings load will fall back to defaults + } const cc = await this.ccSettings.load(); const claudian = await this.claudianSettings.load(); @@ -204,7 +209,7 @@ export class StorageService { * - Preserves existing CC permissions if already in CC format */ private async migrateFromOldSettingsJson(): Promise { - const content = await this.adapter.read(CC_SETTINGS_PATH); + const content = await this.adapter.read(getCCSettingsPath()); const oldSettings = JSON.parse(content) as LegacySettingsJson; const hasClaudianFields = Array.from(CLAUDIAN_ONLY_FIELDS).some( @@ -252,10 +257,11 @@ export class StorageService { // Save Claudian settings FIRST (before stripping from settings.json) await this.claudianSettings.save(claudianFields as StoredClaudianSettings); - // Verify Claudian settings were saved + // Verify Claudian settings were saved — abort migration gracefully if not const savedClaudian = await this.claudianSettings.load(); if (!savedClaudian || savedClaudian.userName === undefined) { - throw new Error('Failed to verify claudian-settings.json was saved correctly'); + new Notice('Settings migration incomplete. Will retry on next launch.'); + return; } // Handle permissions: convert legacy format OR preserve existing CC format @@ -370,11 +376,11 @@ export class StorageService { } async ensureDirectories(): Promise { - await this.adapter.ensureFolder(CLAUDE_PATH); - await this.adapter.ensureFolder(COMMANDS_PATH); - await this.adapter.ensureFolder(SKILLS_PATH); - await this.adapter.ensureFolder(SESSIONS_PATH); - await this.adapter.ensureFolder(AGENTS_PATH); + await this.adapter.ensureFolder(getVaultClaudeDir()); + await this.adapter.ensureFolder(getCommandsPath()); + await this.adapter.ensureFolder(getSkillsPath()); + await this.adapter.ensureFolder(getSessionsPath()); + await this.adapter.ensureFolder(getAgentsPath()); } async loadAllSlashCommands(): Promise { diff --git a/src/core/storage/index.ts b/src/core/storage/index.ts index 4d23e17f..f9e844e0 100644 --- a/src/core/storage/index.ts +++ b/src/core/storage/index.ts @@ -1,14 +1,15 @@ -export { AGENTS_PATH, AgentVaultStorage } from './AgentVaultStorage'; -export { CC_SETTINGS_PATH, CCSettingsStorage, isLegacyPermissionsFormat } from './CCSettingsStorage'; +export { AGENTS_PATH, AgentVaultStorage, getAgentsPath } from './AgentVaultStorage'; +export { CC_SETTINGS_PATH, CCSettingsStorage, getCCSettingsPath, isLegacyPermissionsFormat } from './CCSettingsStorage'; export { CLAUDIAN_SETTINGS_PATH, ClaudianSettingsStorage, + getClaudianSettingsPath, type StoredClaudianSettings, } from './ClaudianSettingsStorage'; -export { MCP_CONFIG_PATH, McpStorage } from './McpStorage'; -export { SESSIONS_PATH, SessionStorage } from './SessionStorage'; -export { SKILLS_PATH, SkillStorage } from './SkillStorage'; -export { COMMANDS_PATH, SlashCommandStorage } from './SlashCommandStorage'; +export { getMcpConfigPath,MCP_CONFIG_PATH, McpStorage } from './McpStorage'; +export { getSessionsPath,SESSIONS_PATH, SessionStorage } from './SessionStorage'; +export { getSkillsPath,SKILLS_PATH, SkillStorage } from './SkillStorage'; +export { getCommandsPath, SlashCommandStorage } from './SlashCommandStorage'; export { CLAUDE_PATH, type CombinedSettings, diff --git a/src/features/chat/ClaudianView.ts b/src/features/chat/ClaudianView.ts index 8075ecb0..5b987145 100644 --- a/src/features/chat/ClaudianView.ts +++ b/src/features/chat/ClaudianView.ts @@ -101,6 +101,20 @@ export class ClaudianView extends ItemView { return; } + try { + await this.initializeView(); + } catch { + // Ensure user sees something rather than a broken view + const container = this.contentEl ?? this.containerEl; + container.empty(); + container.createDiv({ + cls: 'claudian-container', + text: 'Claudian failed to initialize. Try closing and reopening the panel.', + }); + } + } + + private async initializeView(): Promise { // Use contentEl (standard Obsidian API) as primary target. // Hover Editor and other plugins may modify the DOM structure, // so we need fallbacks to handle non-standard scenarios. diff --git a/src/features/chat/controllers/StreamController.ts b/src/features/chat/controllers/StreamController.ts index 18429573..fa363f85 100644 --- a/src/features/chat/controllers/StreamController.ts +++ b/src/features/chat/controllers/StreamController.ts @@ -13,6 +13,7 @@ import { import type { ChatMessage, StreamChunk, SubagentInfo, ToolCallInfo } from '../../../core/types'; import type { SDKToolUseResult } from '../../../core/types/diff'; import type ClaudianPlugin from '../../../main'; +import { getClaudeHomeDirName } from '../../../utils/claudePaths'; import { formatDurationMmSs } from '../../../utils/date'; import { extractDiffData } from '../../../utils/diff'; import { getVaultPath } from '../../../utils/path'; @@ -273,7 +274,7 @@ export class StreamController { private capturePlanFilePath(input: Record): void { const filePath = input.file_path as string | undefined; - if (filePath && filePath.replace(/\\/g, '/').includes('/.claude/plans/')) { + if (filePath && filePath.replace(/\\/g, '/').includes(`/${getClaudeHomeDirName()}/plans/`)) { this.deps.state.planFilePath = filePath; } } diff --git a/src/features/chat/rendering/InlineExitPlanMode.ts b/src/features/chat/rendering/InlineExitPlanMode.ts index 09978a7f..5f79f814 100644 --- a/src/features/chat/rendering/InlineExitPlanMode.ts +++ b/src/features/chat/rendering/InlineExitPlanMode.ts @@ -1,6 +1,7 @@ import * as nodePath from 'path'; import type { ExitPlanModeDecision } from '../../../core/types/tools'; +import { getClaudeHomeDirName } from '../../../utils/claudePaths'; import type { RenderContentFn } from './MessageRenderer'; const HINTS_TEXT = 'Arrow keys to navigate \u00B7 Enter to select \u00B7 Esc to cancel'; @@ -138,7 +139,7 @@ export class InlineExitPlanMode { if (!planFilePath) return null; const resolved = nodePath.resolve(planFilePath).replace(/\\/g, '/'); - if (!resolved.includes('/.claude/plans/')) { + if (!resolved.includes(`/${getClaudeHomeDirName()}/plans/`)) { this.planReadError = 'path outside allowed plan directory'; return null; } diff --git a/src/features/chat/tabs/Tab.ts b/src/features/chat/tabs/Tab.ts index 6511cdba..5e2d1ef3 100644 --- a/src/features/chat/tabs/Tab.ts +++ b/src/features/chat/tabs/Tab.ts @@ -252,6 +252,9 @@ export async function initializeTabService( service = new ClaudianService(plugin, mcpManager); unsubscribeReadyState = service.onReadyStateChange((ready) => { tab.ui.modelSelector?.setReady(ready); + if (ready) { + tab.ui.modelSelector?.refreshSdkModels(); + } }); tab.dom.eventCleanups.push(() => unsubscribeReadyState?.()); @@ -421,7 +424,7 @@ function initializeInstructionAndTodo(tab: TabData, plugin: ClaudianPlugin): voi /** * Creates and wires the input toolbar for a tab. */ -function initializeInputToolbar(tab: TabData, plugin: ClaudianPlugin): void { +function initializeInputToolbar(tab: TabData, plugin: ClaudianPlugin, options?: InitializeTabUIOptions): void { const { dom } = tab; const inputToolbar = dom.inputWrapper.createDiv({ cls: 'claudian-input-toolbar' }); @@ -433,6 +436,7 @@ function initializeInputToolbar(tab: TabData, plugin: ClaudianPlugin): void { show1MModel: plugin.settings.show1MModel, }), getEnvironmentVariables: () => plugin.getActiveEnvironmentVariables(), + getSdkModels: options?.getSdkModels, onModelChange: async (model: ClaudeModel) => { plugin.settings.model = model; const isDefaultModel = DEFAULT_CLAUDE_MODELS.find((m) => m.value === model); @@ -506,6 +510,7 @@ function initializeInputToolbar(tab: TabData, plugin: ClaudianPlugin): void { export interface InitializeTabUIOptions { getSdkCommands?: () => Promise; + getSdkModels?: () => Promise<{ value: string; label: string; description: string }[]>; } /** @@ -553,7 +558,7 @@ export function initializeTabUI( initializeInstructionAndTodo(tab, plugin); // Initialize input toolbar - initializeInputToolbar(tab, plugin); + initializeInputToolbar(tab, plugin, options); // Update ChatState callbacks for UI updates state.callbacks = { diff --git a/src/features/chat/tabs/TabManager.ts b/src/features/chat/tabs/TabManager.ts index db9e3bee..efa5bf92 100644 --- a/src/features/chat/tabs/TabManager.ts +++ b/src/features/chat/tabs/TabManager.ts @@ -117,6 +117,7 @@ export class TabManager implements TabManagerInterface { // Initialize UI components with shared SDK commands callback initializeTabUI(tab, this.plugin, { getSdkCommands: () => this.getSdkCommands(), + getSdkModels: () => this.getSdkModels(), }); // Initialize controllers (pass mcpManager for lazy service initialization) @@ -566,6 +567,15 @@ export class TabManager implements TabManagerInterface { return []; } + async getSdkModels(): Promise<{ value: string; label: string; description: string }[]> { + for (const tab of this.tabs.values()) { + if (tab.service?.isReady()) { + return tab.service.getSupportedModels(); + } + } + return []; + } + // ============================================ // Broadcast // ============================================ diff --git a/src/features/chat/ui/InputToolbar.ts b/src/features/chat/ui/InputToolbar.ts index af0c3fad..c33974f0 100644 --- a/src/features/chat/ui/InputToolbar.ts +++ b/src/features/chat/ui/InputToolbar.ts @@ -31,6 +31,7 @@ export interface ToolbarCallbacks { onPermissionModeChange: (mode: PermissionMode) => Promise; getSettings: () => ToolbarSettings; getEnvironmentVariables?: () => string; + getSdkModels?: () => Promise<{ value: string; label: string; description: string }[]>; } export class ModelSelector { @@ -39,6 +40,8 @@ export class ModelSelector { private dropdownEl: HTMLElement | null = null; private callbacks: ToolbarCallbacks; private isReady = false; + private cachedSdkModels: { value: string; label: string; description: string }[] = []; + private sdkModelsFetched = false; constructor(parentEl: HTMLElement, callbacks: ToolbarCallbacks) { this.callbacks = callbacks; @@ -47,6 +50,12 @@ export class ModelSelector { } private getAvailableModels() { + // Priority 1: SDK models (when service is ready and models fetched) + if (this.sdkModelsFetched && this.cachedSdkModels.length > 0) { + return this.cachedSdkModels; + } + + // Priority 2: Env-var models / Priority 3: DEFAULT_CLAUDE_MODELS let models: { value: string; label: string; description: string }[] = []; if (this.callbacks.getEnvironmentVariables) { @@ -104,6 +113,21 @@ export class ModelSelector { this.buttonEl?.toggleClass('ready', ready); } + async refreshSdkModels(): Promise { + if (!this.callbacks.getSdkModels) return; + try { + const models = await this.callbacks.getSdkModels(); + if (models.length > 0) { + this.cachedSdkModels = models; + this.sdkModelsFetched = true; + this.updateDisplay(); + this.renderOptions(); + } + } catch { + // Keep previous state on error + } + } + renderOptions() { if (!this.dropdownEl) return; this.dropdownEl.empty(); diff --git a/src/features/settings/ClaudianSettings.ts b/src/features/settings/ClaudianSettings.ts index 380f4142..cb3b5d0a 100644 --- a/src/features/settings/ClaudianSettings.ts +++ b/src/features/settings/ClaudianSettings.ts @@ -7,6 +7,7 @@ import { DEFAULT_CLAUDE_MODELS } from '../../core/types/models'; import { getAvailableLocales, getLocaleDisplayName, setLocale, t } from '../../i18n'; import type { Locale, TranslationKey } from '../../i18n/types'; import type ClaudianPlugin from '../../main'; +import { getClaudeHomeDirName, isValidClaudeHomeDirName } from '../../utils/claudePaths'; import { findNodeExecutable, formatContextLimit, getCustomModelIds, getEnhancedPath, getModelsFromEnvironment, parseContextLimit, parseEnvironmentVariables } from '../../utils/env'; import { expandHomePath } from '../../utils/path'; import { ClaudianView } from '../chat/ClaudianView'; @@ -602,6 +603,49 @@ export class ClaudianSettingTab extends PluginSettingTab { const hostnameKey = getHostnameKey(); + // Claude Home Directory Name setting + new Setting(containerEl) + .setName(t('settings.claudeHomeDirName.name')) + .setDesc(t('settings.claudeHomeDirName.desc')) + .addText((text) => { + text + .setPlaceholder('.claude') + .setValue(getClaudeHomeDirName()) + .onChange(async (value) => { + const trimmed = value.trim(); + + // Validate: reuse the shared validator (rejects empty-after-default, '.', '..', separators, non-dot-prefixed) + if (trimmed && !isValidClaudeHomeDirName(trimmed)) { + claudeHomeDirValidationEl.setText(t('settings.claudeHomeDirName.validation')); + claudeHomeDirValidationEl.style.display = 'block'; + return; + } + + claudeHomeDirValidationEl.style.display = 'none'; + const dirName = trimmed || '.claude'; + + // Only persist to data.json — do NOT call setClaudeHomeDirName() here. + // The global path resolution must not change mid-session; a restart is required. + const pluginData = (await this.plugin.loadData()) || {}; + if (dirName === '.claude') { + delete pluginData.claudeHomeDirName; + } else { + pluginData.claudeHomeDirName = dirName; + } + await this.plugin.saveData(pluginData); + + new Notice(t('settings.claudeHomeDirName.restartNotice')); + }); + text.inputEl.addClass('claudian-settings-claude-home-input'); + }); + + const claudeHomeDirValidationEl = containerEl.createDiv({ cls: 'claudian-claude-home-validation' }); + claudeHomeDirValidationEl.style.color = 'var(--text-error)'; + claudeHomeDirValidationEl.style.fontSize = '0.85em'; + claudeHomeDirValidationEl.style.marginTop = '-0.5em'; + claudeHomeDirValidationEl.style.marginBottom = '0.5em'; + claudeHomeDirValidationEl.style.display = 'none'; + const platformDesc = process.platform === 'win32' ? t('settings.cliPath.descWindows') : t('settings.cliPath.descUnix'); diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 2802f784..a86d0a2e 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -270,6 +270,12 @@ "name": "Im Haupteditorbereich öffnen", "desc": "Chat-Panel als Haupttab im zentralen Editorbereich statt in der rechten Seitenleiste öffnen" }, + "claudeHomeDirName": { + "name": "Claude home directory name", + "desc": "Name of the Claude configuration directory (e.g., '.claude' or '.claude-internal'). Controls both the global home (~/.claude/) and vault-level config directory. Requires restart to take effect.", + "validation": "Must start with '.' and contain no path separators", + "restartNotice": "Claude home directory changed. Restart Obsidian to apply." + }, "cliPath": { "name": "Claude CLI-Pfad", "desc": "Benutzerdefinierter Pfad zum Claude Code CLI. Leer lassen für automatische Erkennung.", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 7d7676eb..20fa8ba6 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -270,6 +270,12 @@ "name": "Open in main editor area", "desc": "Open chat panel as a main tab in the center editor area instead of the right sidebar" }, + "claudeHomeDirName": { + "name": "Claude home directory name", + "desc": "Name of the Claude configuration directory (e.g., '.claude' or '.claude-internal'). Controls both the global home (~/.claude/) and vault-level config directory. Requires restart to take effect.", + "validation": "Must start with '.' and contain no path separators", + "restartNotice": "Claude home directory changed. Restart Obsidian to apply." + }, "cliPath": { "name": "Claude CLI path", "desc": "Custom path to Claude Code CLI. Leave empty for auto-detection.", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 7ad007be..267a1886 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -270,6 +270,12 @@ "name": "Abrir en área de editor principal", "desc": "Abrir el panel de chat como una pestaña principal en el área de editor central en lugar de la barra lateral derecha" }, + "claudeHomeDirName": { + "name": "Claude home directory name", + "desc": "Name of the Claude configuration directory (e.g., '.claude' or '.claude-internal'). Controls both the global home (~/.claude/) and vault-level config directory. Requires restart to take effect.", + "validation": "Must start with '.' and contain no path separators", + "restartNotice": "Claude home directory changed. Restart Obsidian to apply." + }, "cliPath": { "name": "Ruta CLI Claude", "desc": "Ruta personalizada a Claude Code CLI. Dejar vacío para detección automática.", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index e32e1f02..f2a22d40 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -270,6 +270,12 @@ "name": "Ouvrir dans la zone d'éditeur principale", "desc": "Ouvrir le panneau de chat comme un onglet principal dans la zone d'éditeur centrale au lieu de la barre latérale droite" }, + "claudeHomeDirName": { + "name": "Claude home directory name", + "desc": "Name of the Claude configuration directory (e.g., '.claude' or '.claude-internal'). Controls both the global home (~/.claude/) and vault-level config directory. Requires restart to take effect.", + "validation": "Must start with '.' and contain no path separators", + "restartNotice": "Claude home directory changed. Restart Obsidian to apply." + }, "cliPath": { "name": "Chemin CLI Claude", "desc": "Chemin personnalisé vers Claude Code CLI. Laisser vide pour la détection automatique.", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index de2b9fa6..33b74cb9 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -270,6 +270,12 @@ "name": "メインエディタ領域で開く", "desc": "チャットパネルを右サイドバーではなく、中央エディタ領域のメインタブとして開きます" }, + "claudeHomeDirName": { + "name": "Claude home directory name", + "desc": "Name of the Claude configuration directory (e.g., '.claude' or '.claude-internal'). Controls both the global home (~/.claude/) and vault-level config directory. Requires restart to take effect.", + "validation": "Must start with '.' and contain no path separators", + "restartNotice": "Claude home directory changed. Restart Obsidian to apply." + }, "cliPath": { "name": "Claude CLI パス", "desc": "Claude Code CLI のカスタムパス。空欄で自動検出を使用。", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 22bf77c8..700c3e43 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -270,6 +270,12 @@ "name": "메인 편집기 영역에서 열기", "desc": "채팅 패널을 오른쪽 사이드바가 아닌 중앙 편집기 영역의 메인 탭으로 엽니다" }, + "claudeHomeDirName": { + "name": "Claude home directory name", + "desc": "Name of the Claude configuration directory (e.g., '.claude' or '.claude-internal'). Controls both the global home (~/.claude/) and vault-level config directory. Requires restart to take effect.", + "validation": "Must start with '.' and contain no path separators", + "restartNotice": "Claude home directory changed. Restart Obsidian to apply." + }, "cliPath": { "name": "Claude CLI 경로", "desc": "Claude Code CLI의 사용자 정의 경로. 비워두면 자동 감지 사용.", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index e041f86d..2289b633 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -270,6 +270,12 @@ "name": "Abrir na área do editor principal", "desc": "Abrir o painel de chat como uma aba principal na área do editor central em vez da barra lateral direita" }, + "claudeHomeDirName": { + "name": "Claude home directory name", + "desc": "Name of the Claude configuration directory (e.g., '.claude' or '.claude-internal'). Controls both the global home (~/.claude/) and vault-level config directory. Requires restart to take effect.", + "validation": "Must start with '.' and contain no path separators", + "restartNotice": "Claude home directory changed. Restart Obsidian to apply." + }, "cliPath": { "name": "Caminho CLI Claude", "desc": "Caminho personalizado para Claude Code CLI. Deixe vazio para detecção automática.", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 6c45bf8e..d53bf1a8 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -270,6 +270,12 @@ "name": "Открывать в основной области редактора", "desc": "Открывать панель чата в виде основной вкладки в центральной области редактора вместо правой боковой панели" }, + "claudeHomeDirName": { + "name": "Claude home directory name", + "desc": "Name of the Claude configuration directory (e.g., '.claude' or '.claude-internal'). Controls both the global home (~/.claude/) and vault-level config directory. Requires restart to take effect.", + "validation": "Must start with '.' and contain no path separators", + "restartNotice": "Claude home directory changed. Restart Obsidian to apply." + }, "cliPath": { "name": "Путь к CLI Claude", "desc": "Пользовательский путь к Claude Code CLI. Оставьте пустым для автоматического определения.", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 40e0e5e2..b84c1391 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -270,6 +270,12 @@ "name": "在主编辑器区域打开", "desc": "在中央编辑器区域以主标签页形式打开聊天面板,而不是在右侧边栏" }, + "claudeHomeDirName": { + "name": "Claude 主目录名称", + "desc": "Claude 配置目录名称(如 '.claude' 或 '.claude-internal')。同时控制全局主目录(~/.claude/)和 Vault 内配置目录。需要重启生效。", + "validation": "必须以 '.' 开头,且不能包含路径分隔符", + "restartNotice": "Claude 主目录已更改。请重启 Obsidian 以生效。" + }, "cliPath": { "name": "Claude CLI 路径", "desc": "Claude Code CLI 的自定义路径。留空使用自动检测。", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 4588fb14..9e80ac49 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -270,6 +270,12 @@ "name": "在主編輯器區域開啟", "desc": "在中央編輯器區域以主分頁形式開啟聊天面板,而不是在右側邊欄" }, + "claudeHomeDirName": { + "name": "Claude home directory name", + "desc": "Name of the Claude configuration directory (e.g., '.claude' or '.claude-internal'). Controls both the global home (~/.claude/) and vault-level config directory. Requires restart to take effect.", + "validation": "Must start with '.' and contain no path separators", + "restartNotice": "Claude home directory changed. Restart Obsidian to apply." + }, "cliPath": { "name": "Claude CLI 路徑", "desc": "Claude Code CLI 的自訂路徑。留空使用自動檢測。", diff --git a/src/i18n/types.ts b/src/i18n/types.ts index 73ff7ab4..bef00e5e 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -226,6 +226,10 @@ export type TranslationKey = | 'settings.enableAutoScroll.desc' | 'settings.openInMainTab.name' | 'settings.openInMainTab.desc' + | 'settings.claudeHomeDirName.name' + | 'settings.claudeHomeDirName.desc' + | 'settings.claudeHomeDirName.validation' + | 'settings.claudeHomeDirName.restartNotice' | 'settings.cliPath.name' | 'settings.cliPath.desc' | 'settings.cliPath.descWindows' diff --git a/src/main.ts b/src/main.ts index c3de2c02..d1a092ed 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,6 +5,9 @@ * Manages conversation persistence and environment variable configuration. */ +// Must be first: patches events.setMaxListeners for Electron AbortSignal compatibility +import './utils/electronCompat'; + import type { Editor, MarkdownView } from 'obsidian'; import { Notice, Plugin } from 'obsidian'; @@ -33,6 +36,7 @@ import { type InlineEditContext, InlineEditModal } from './features/inline-edit/ import { ClaudianSettingTab } from './features/settings/ClaudianSettings'; import { setLocale } from './i18n'; import { ClaudeCliResolver } from './utils/claudeCli'; +import { setClaudeHomeDirName } from './utils/claudePaths'; import { buildCursorContext } from './utils/editor'; import { getCurrentModelFromEnvironment, getModelsFromEnvironment, parseEnvironmentVariables } from './utils/env'; import { getVaultPath } from './utils/path'; @@ -59,23 +63,61 @@ export default class ClaudianPlugin extends Plugin { private runtimeEnvironmentVariables = ''; async onload() { - await this.loadSettings(); + // Phase 1: Settings initialization (with fallback to defaults on failure) + let initError = false; + try { + const data = await this.loadData(); + const claudeHomeDirName = data?.claudeHomeDirName || '.claude'; + setClaudeHomeDirName(claudeHomeDirName); + await this.loadSettings(); + } catch { + initError = true; + // Ensure minimum viable state so the plugin can still register views/commands + if (!this.storage) { + this.storage = new StorageService(this); + try { await this.storage.initialize(); } catch { /* best effort */ } + } + if (!this.settings) { + this.settings = { ...DEFAULT_SETTINGS, slashCommands: [] }; + } + } - this.cliResolver = new ClaudeCliResolver(); + // Phase 2: Non-critical manager initialization (each independently resilient) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const vaultPath = (this.app.vault.adapter as any).basePath; - // Initialize MCP manager (shared for agent + UI) - this.mcpManager = new McpServerManager(this.storage.mcp); - await this.mcpManager.loadServers(); + try { + this.cliResolver = new ClaudeCliResolver(); + } catch { /* CLI resolution deferred to first use */ } - // Initialize plugin manager (reads from installed_plugins.json + settings.json) - const vaultPath = (this.app.vault.adapter as any).basePath; - this.pluginManager = new PluginManager(vaultPath, this.storage.ccSettings); - await this.pluginManager.loadPlugins(); + try { + this.mcpManager = new McpServerManager(this.storage.mcp); + await this.mcpManager.loadServers(); + } catch { + if (!this.mcpManager) { + this.mcpManager = new McpServerManager(this.storage.mcp); + } + } + + try { + this.pluginManager = new PluginManager(vaultPath, this.storage.ccSettings); + await this.pluginManager.loadPlugins(); + } catch { + if (!this.pluginManager) { + this.pluginManager = new PluginManager(vaultPath, this.storage.ccSettings); + } + } - // Initialize agent manager (loads plugin agents from plugin install paths) - this.agentManager = new AgentManager(vaultPath, this.pluginManager); - await this.agentManager.loadAgents(); + try { + this.agentManager = new AgentManager(vaultPath, this.pluginManager); + await this.agentManager.loadAgents(); + } catch { + if (!this.agentManager) { + this.agentManager = new AgentManager(vaultPath, this.pluginManager); + } + } + // Phase 3: Critical registrations — must always execute this.registerView( VIEW_TYPE_CLAUDIAN, (leaf) => new ClaudianView(leaf, this) @@ -197,6 +239,10 @@ export default class ClaudianPlugin extends Plugin { }); this.addSettingTab(new ClaudianSettingTab(this.app, this)); + + if (initError) { + new Notice('Claudian loaded with errors. Some features may need reloading.'); + } } async onunload() { @@ -272,7 +318,38 @@ export default class ClaudianPlugin extends Plugin { // eslint-disable-next-line @typescript-eslint/no-explicit-any delete (this.settings as any).claudeCliPaths; - // Load all conversations from session files (legacy JSONL + native metadata) + // Conversation loading is non-critical for plugin registration + try { + await this.loadConversationsFromStorage(); + } catch { + new Notice('Failed to load conversation history.'); + } + + setLocale(this.settings.locale); + + this.runtimeEnvironmentVariables = this.settings.environmentVariables || ''; + const { changed, invalidatedConversations } = this.reconcileModelWithEnvironment(this.runtimeEnvironmentVariables); + + if (changed || didMigrateCliPath) { + await this.saveSettings(); + } + + // Persist backfilled and invalidated conversations to their session files + const backfilledConversations = this.backfillConversationResponseTimestamps(); + const conversationsToSave = new Set([...backfilledConversations, ...invalidatedConversations]); + for (const conv of conversationsToSave) { + if (conv.isNative) { + await this.storage.sessions.saveMetadata( + this.storage.sessions.toSessionMetadata(conv) + ); + } else { + await this.storage.sessions.saveConversation(conv); + } + } + } + + /** Loads all conversations from session files (legacy JSONL + native metadata). */ + private async loadConversationsFromStorage(): Promise { const { conversations: legacyConversations, failedCount } = await this.storage.sessions.loadAllConversations(); const legacyIds = new Set(legacyConversations.map(c => c.id)); @@ -346,30 +423,6 @@ export default class ClaudianPlugin extends Plugin { if (failedCount > 0) { new Notice(`Failed to load ${failedCount} conversation${failedCount > 1 ? 's' : ''}`); } - setLocale(this.settings.locale); - - const backfilledConversations = this.backfillConversationResponseTimestamps(); - - this.runtimeEnvironmentVariables = this.settings.environmentVariables || ''; - const { changed, invalidatedConversations } = this.reconcileModelWithEnvironment(this.runtimeEnvironmentVariables); - - if (changed || didMigrateCliPath) { - await this.saveSettings(); - } - - // Persist backfilled and invalidated conversations to their session files - const conversationsToSave = new Set([...backfilledConversations, ...invalidatedConversations]); - for (const conv of conversationsToSave) { - if (conv.isNative) { - // Native session: save metadata only - await this.storage.sessions.saveMetadata( - this.storage.sessions.toSessionMetadata(conv) - ); - } else { - // Legacy session: save full JSONL - await this.storage.sessions.saveConversation(conv); - } - } } private backfillConversationResponseTimestamps(): Conversation[] { diff --git a/src/utils/claudePaths.ts b/src/utils/claudePaths.ts new file mode 100644 index 00000000..d46d8b6c --- /dev/null +++ b/src/utils/claudePaths.ts @@ -0,0 +1,41 @@ +import * as os from 'os'; +import * as path from 'path'; + +let _dirName = '.claude'; + +/** Returns true if the given directory name is valid for use as a Claude home directory. */ +export function isValidClaudeHomeDirName(dirName: string): boolean { + if (!dirName || dirName === '.' || dirName === '..') return false; + if (!dirName.startsWith('.')) return false; + if (dirName.includes('/') || dirName.includes('\\')) return false; + return true; +} + +export function setClaudeHomeDirName(dirName: string): void { + if (!isValidClaudeHomeDirName(dirName)) return; + _dirName = dirName; +} + +export function getClaudeHomeDirName(): string { + return _dirName; +} + +/** Global Claude home directory, e.g. ~/.claude/ or ~/.claude-internal/ */ +export function getGlobalClaudeHome(): string { + return path.join(os.homedir(), _dirName); +} + +/** Path under the global Claude home, e.g. ~/.claude/agents */ +export function getGlobalClaudePath(...segments: string[]): string { + return path.join(os.homedir(), _dirName, ...segments); +} + +/** Vault-relative Claude directory name, e.g. '.claude' or '.claude-internal' */ +export function getVaultClaudeDir(): string { + return _dirName; +} + +/** Vault-relative path under the Claude directory, e.g. '.claude/settings.json' */ +export function getVaultClaudePath(...segments: string[]): string { + return [_dirName, ...segments].join('/'); +} diff --git a/src/utils/electronCompat.ts b/src/utils/electronCompat.ts new file mode 100644 index 00000000..15426e64 --- /dev/null +++ b/src/utils/electronCompat.ts @@ -0,0 +1,35 @@ +/** + * Electron compatibility patches. + * + * Must be imported before any module that uses `events.setMaxListeners` + * with AbortSignal (e.g., @anthropic-ai/claude-agent-sdk). + * + * In Electron's Node.js runtime, AbortSignal is not recognized as an + * EventTarget by the `events` module, causing: + * "The 'eventTargets' argument must be an instance of EventEmitter or EventTarget" + * + * This patches setMaxListeners to silently ignore the error for AbortSignal. + */ + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const eventsModule = require('events'); + +const originalSetMaxListeners = eventsModule.setMaxListeners; +if (typeof originalSetMaxListeners === 'function') { + eventsModule.setMaxListeners = function patchedSetMaxListeners(n: number, ...eventTargets: unknown[]) { + try { + return originalSetMaxListeners.call(eventsModule, n, ...eventTargets); + } catch (error) { + if ( + error instanceof TypeError && + typeof error.message === 'string' && + error.message.includes('eventTargets') + ) { + // Electron's AbortSignal doesn't extend EventTarget for the events module. + // Silently skip — the max listener warning is non-critical. + return; + } + throw error; + } + }; +} diff --git a/src/utils/path.ts b/src/utils/path.ts index 5ecb1abf..eeb58950 100644 --- a/src/utils/path.ts +++ b/src/utils/path.ts @@ -9,6 +9,8 @@ import type { App } from 'obsidian'; import * as os from 'os'; import * as path from 'path'; +import { getGlobalClaudeHome, getGlobalClaudePath } from './claudePaths'; + // ============================================ // Vault Path // ============================================ @@ -378,7 +380,7 @@ export function findClaudeCLIPath(pathValue?: string): string | null { // because it requires shell: true and breaks SDK stdio streaming. if (isWindows) { const exePaths: string[] = [ - path.join(homeDir, '.claude', 'local', 'claude.exe'), + getGlobalClaudePath('local', 'claude.exe'), path.join(homeDir, 'AppData', 'Local', 'Claude', 'claude.exe'), path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Claude', 'claude.exe'), path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Claude', 'claude.exe'), @@ -401,7 +403,7 @@ export function findClaudeCLIPath(pathValue?: string): string | null { } const commonPaths: string[] = [ - path.join(homeDir, '.claude', 'local', 'claude'), + getGlobalClaudePath('local', 'claude'), path.join(homeDir, '.local', 'bin', 'claude'), path.join(homeDir, '.volta', 'bin', 'claude'), path.join(homeDir, '.asdf', 'shims', 'claude'), @@ -695,7 +697,7 @@ export function getPathAccessType( } // Allow access to specific safe subdirectories under ~/.claude/ - const claudeDir = normalizePathForComparison(resolveRealPath(path.join(os.homedir(), '.claude'))); + const claudeDir = normalizePathForComparison(resolveRealPath(getGlobalClaudeHome())); if (resolvedCandidate === claudeDir || resolvedCandidate.startsWith(claudeDir + '/')) { const safeSubdirs = ['sessions', 'projects', 'commands', 'agents', 'skills', 'plans']; const safeFiles = ['mcp.json', 'settings.json', 'settings.local.json', 'claudian-settings.json']; diff --git a/src/utils/sdkSession.ts b/src/utils/sdkSession.ts index 989c533b..b26ab528 100644 --- a/src/utils/sdkSession.ts +++ b/src/utils/sdkSession.ts @@ -10,12 +10,12 @@ import { existsSync } from 'fs'; import * as fs from 'fs/promises'; -import * as os from 'os'; import * as path from 'path'; import { extractResolvedAnswers, extractResolvedAnswersFromResultText } from '../core/tools'; import { isSubagentToolName, TOOL_ASK_USER_QUESTION } from '../core/tools/toolNames'; import type { ChatMessage, ContentBlock, ImageAttachment, ImageMediaType, SubagentInfo, ToolCallInfo } from '../core/types'; +import { getGlobalClaudePath } from './claudePaths'; import { extractContentBeforeXmlContext } from './context'; import { extractDiffData } from './diff'; import { isCompactionCanceledStderr, isInterruptSignalText } from './interrupt'; @@ -88,7 +88,7 @@ export function encodeVaultPathForSDK(vaultPath: string): string { } export function getSDKProjectsPath(): string { - return path.join(os.homedir(), '.claude', 'projects'); + return getGlobalClaudePath('projects'); } /** Validates a subagent agent ID to prevent path traversal attacks. */ diff --git a/tests/helpers/sdkMessages.ts b/tests/helpers/sdkMessages.ts index bc0da8dc..debf2b3c 100644 --- a/tests/helpers/sdkMessages.ts +++ b/tests/helpers/sdkMessages.ts @@ -190,7 +190,6 @@ export function buildResultSuccessMessage( is_error: false, num_turns: 1, result: 'completed', - stop_reason: null, total_cost_usd: 0, usage: DEFAULT_RESULT_USAGE, modelUsage: DEFAULT_MODEL_USAGE, @@ -213,7 +212,6 @@ export function buildResultErrorMessage( duration_api_ms: 0, is_error: true, num_turns: 1, - stop_reason: null, total_cost_usd: 0, usage: DEFAULT_RESULT_USAGE, modelUsage: DEFAULT_MODEL_USAGE, diff --git a/tests/unit/core/agent/ClaudianService.test.ts b/tests/unit/core/agent/ClaudianService.test.ts index 94ba2160..e515e856 100644 --- a/tests/unit/core/agent/ClaudianService.test.ts +++ b/tests/unit/core/agent/ClaudianService.test.ts @@ -681,6 +681,51 @@ describe('ClaudianService', () => { }); }); + describe('SDK Supported Models', () => { + it('should return empty array when no persistent query', async () => { + const models = await service.getSupportedModels(); + expect(models).toEqual([]); + }); + + it('should convert SDK ModelInfo to internal format', async () => { + const mockSdkModels = [ + { value: 'claude-sonnet-4-5', displayName: 'Claude 4.5 Sonnet', description: 'Fast and capable' }, + { value: 'claude-opus-4-6', displayName: 'Opus 4.6', description: 'Most powerful' }, + ]; + + const mockQuery = { + supportedModels: jest.fn().mockResolvedValue(mockSdkModels), + }; + (service as any).persistentQuery = mockQuery; + + const models = await service.getSupportedModels(); + + expect(mockQuery.supportedModels).toHaveBeenCalled(); + expect(models).toHaveLength(2); + expect(models[0]).toEqual({ + value: 'claude-sonnet-4-5', + label: 'Claude 4.5 Sonnet', + description: 'Fast and capable', + }); + expect(models[1]).toEqual({ + value: 'claude-opus-4-6', + label: 'Opus 4.6', + description: 'Most powerful', + }); + }); + + it('should return empty array on SDK error', async () => { + const mockQuery = { + supportedModels: jest.fn().mockRejectedValue(new Error('SDK error')), + }; + (service as any).persistentQuery = mockQuery; + + const models = await service.getSupportedModels(); + + expect(models).toEqual([]); + }); + }); + describe('isPipeError', () => { it('should return true for EPIPE code', () => { const error = { code: 'EPIPE' }; diff --git a/tests/unit/features/chat/tabs/Tab.test.ts b/tests/unit/features/chat/tabs/Tab.test.ts index 3fefebc0..1fd557c6 100644 --- a/tests/unit/features/chat/tabs/Tab.test.ts +++ b/tests/unit/features/chat/tabs/Tab.test.ts @@ -103,6 +103,7 @@ const createMockModelSelector = () => ({ updateDisplay: jest.fn(), renderOptions: jest.fn(), setReady: jest.fn(), + refreshSdkModels: jest.fn().mockResolvedValue(undefined), }); const createMockClaudianService = (overrides?: { diff --git a/tests/unit/features/chat/ui/InputToolbar.test.ts b/tests/unit/features/chat/ui/InputToolbar.test.ts index ca727c63..7cc02639 100644 --- a/tests/unit/features/chat/ui/InputToolbar.test.ts +++ b/tests/unit/features/chat/ui/InputToolbar.test.ts @@ -144,6 +144,82 @@ describe('ModelSelector', () => { const label = parentEl.querySelector('.claudian-model-label'); expect(label?.textContent).toBeDefined(); }); + + it('should use SDK models when fetched', async () => { + const sdkModels = [ + { value: 'claude-sonnet-4-5', label: 'Claude 4.5 Sonnet', description: 'Fast' }, + { value: 'claude-opus-4-6', label: 'Opus 4.6', description: 'Powerful' }, + ]; + const freshParent = createMockEl(); + const cbWithSdk = createMockCallbacks({ getSdkModels: jest.fn().mockResolvedValue(sdkModels) }); + + const sel = new ModelSelector(freshParent, cbWithSdk); + await sel.refreshSdkModels(); + + // Should display SDK models, not defaults + const dropdown = freshParent.querySelector('.claudian-model-dropdown'); + const options = dropdown?.children || []; + expect(options.length).toBe(2); + // Reversed order: Opus 4.6, Claude 4.5 Sonnet + expect(options[0]?.children[0]?.textContent).toBe('Opus 4.6'); + expect(options[1]?.children[0]?.textContent).toBe('Claude 4.5 Sonnet'); + }); + + it('should prioritize SDK models over env-var models', async () => { + const sdkModels = [ + { value: 'sdk-model', label: 'SDK Model', description: 'From SDK' }, + ]; + const freshParent = createMockEl(); + const cbWithSdk = createMockCallbacks({ + getEnvironmentVariables: jest.fn().mockReturnValue('ANTHROPIC_MODEL=custom-model'), + getSdkModels: jest.fn().mockResolvedValue(sdkModels), + }); + + const sel = new ModelSelector(freshParent, cbWithSdk); + await sel.refreshSdkModels(); + + const dropdown = freshParent.querySelector('.claudian-model-dropdown'); + const options = dropdown?.children || []; + expect(options.length).toBe(1); + expect(options[0]?.children[0]?.textContent).toBe('SDK Model'); + }); + + it('should fall back to defaults when SDK returns empty', async () => { + const freshParent = createMockEl(); + const cbWithSdk = createMockCallbacks({ getSdkModels: jest.fn().mockResolvedValue([]) }); + + const sel = new ModelSelector(freshParent, cbWithSdk); + await sel.refreshSdkModels(); + + // Should still show defaults + const dropdown = freshParent.querySelector('.claudian-model-dropdown'); + const options = dropdown?.children || []; + expect(options.length).toBe(3); // haiku, sonnet, opus + }); + + it('should keep previous state on SDK error', async () => { + const freshParent = createMockEl(); + const cbWithSdk = createMockCallbacks({ getSdkModels: jest.fn().mockRejectedValue(new Error('Network error')) }); + + const sel = new ModelSelector(freshParent, cbWithSdk); + await sel.refreshSdkModels(); + + // Should still show defaults + const dropdown = freshParent.querySelector('.claudian-model-dropdown'); + const options = dropdown?.children || []; + expect(options.length).toBe(3); // haiku, sonnet, opus + }); + + it('should not attempt refresh when getSdkModels callback is not provided', async () => { + const freshParent = createMockEl(); + const sel = new ModelSelector(freshParent, callbacks); + await sel.refreshSdkModels(); + + // Should still show defaults + const dropdown = freshParent.querySelector('.claudian-model-dropdown'); + const options = dropdown?.children || []; + expect(options.length).toBe(3); + }); }); describe('ThinkingBudgetSelector', () => { diff --git a/tests/unit/utils/claudePaths.test.ts b/tests/unit/utils/claudePaths.test.ts new file mode 100644 index 00000000..65def790 --- /dev/null +++ b/tests/unit/utils/claudePaths.test.ts @@ -0,0 +1,159 @@ +import * as os from 'os'; +import * as path from 'path'; + +import { + getClaudeHomeDirName, + getGlobalClaudeHome, + getGlobalClaudePath, + getVaultClaudeDir, + getVaultClaudePath, + isValidClaudeHomeDirName, + setClaudeHomeDirName, +} from '@/utils/claudePaths'; + +describe('claudePaths', () => { + afterEach(() => { + // Reset to default after each test + setClaudeHomeDirName('.claude'); + }); + + describe('isValidClaudeHomeDirName', () => { + it('accepts valid dir names', () => { + expect(isValidClaudeHomeDirName('.claude')).toBe(true); + expect(isValidClaudeHomeDirName('.claude-internal')).toBe(true); + expect(isValidClaudeHomeDirName('.my-config')).toBe(true); + }); + + it('rejects empty string', () => { + expect(isValidClaudeHomeDirName('')).toBe(false); + }); + + it('rejects "." and ".."', () => { + expect(isValidClaudeHomeDirName('.')).toBe(false); + expect(isValidClaudeHomeDirName('..')).toBe(false); + }); + + it('rejects names without leading dot', () => { + expect(isValidClaudeHomeDirName('claude')).toBe(false); + expect(isValidClaudeHomeDirName('config')).toBe(false); + }); + + it('rejects names with path separators', () => { + expect(isValidClaudeHomeDirName('.claude/foo')).toBe(false); + expect(isValidClaudeHomeDirName('.claude\\foo')).toBe(false); + }); + }); + + describe('setClaudeHomeDirName / getClaudeHomeDirName', () => { + it('defaults to .claude', () => { + expect(getClaudeHomeDirName()).toBe('.claude'); + }); + + it('can be set to a custom value', () => { + setClaudeHomeDirName('.claude-internal'); + expect(getClaudeHomeDirName()).toBe('.claude-internal'); + }); + + it('rejects invalid values and keeps current', () => { + setClaudeHomeDirName('.custom'); + setClaudeHomeDirName(''); + expect(getClaudeHomeDirName()).toBe('.custom'); + + setClaudeHomeDirName('.'); + expect(getClaudeHomeDirName()).toBe('.custom'); + + setClaudeHomeDirName('..'); + expect(getClaudeHomeDirName()).toBe('.custom'); + + setClaudeHomeDirName('no-dot'); + expect(getClaudeHomeDirName()).toBe('.custom'); + + setClaudeHomeDirName('.has/slash'); + expect(getClaudeHomeDirName()).toBe('.custom'); + }); + }); + + describe('getGlobalClaudeHome', () => { + it('returns ~/.claude by default', () => { + expect(getGlobalClaudeHome()).toBe(path.join(os.homedir(), '.claude')); + }); + + it('returns ~/.claude-internal when configured', () => { + setClaudeHomeDirName('.claude-internal'); + expect(getGlobalClaudeHome()).toBe(path.join(os.homedir(), '.claude-internal')); + }); + }); + + describe('getGlobalClaudePath', () => { + it('joins segments under global home', () => { + expect(getGlobalClaudePath('projects')).toBe( + path.join(os.homedir(), '.claude', 'projects') + ); + }); + + it('handles multiple segments', () => { + expect(getGlobalClaudePath('plugins', 'installed_plugins.json')).toBe( + path.join(os.homedir(), '.claude', 'plugins', 'installed_plugins.json') + ); + }); + + it('uses custom dir name', () => { + setClaudeHomeDirName('.claude-internal'); + expect(getGlobalClaudePath('agents')).toBe( + path.join(os.homedir(), '.claude-internal', 'agents') + ); + }); + }); + + describe('getVaultClaudeDir', () => { + it('returns .claude by default', () => { + expect(getVaultClaudeDir()).toBe('.claude'); + }); + + it('returns custom name when configured', () => { + setClaudeHomeDirName('.claude-internal'); + expect(getVaultClaudeDir()).toBe('.claude-internal'); + }); + }); + + describe('getVaultClaudePath', () => { + it('returns vault-relative path', () => { + expect(getVaultClaudePath('settings.json')).toBe('.claude/settings.json'); + }); + + it('handles multiple segments', () => { + expect(getVaultClaudePath('sessions', 'abc.jsonl')).toBe('.claude/sessions/abc.jsonl'); + }); + + it('uses custom dir name', () => { + setClaudeHomeDirName('.claude-internal'); + expect(getVaultClaudePath('settings.json')).toBe('.claude-internal/settings.json'); + }); + }); + + describe('dynamic path functions in storage modules', () => { + it('getCCSettingsPath responds to dir name changes', async () => { + const { getCCSettingsPath } = await import('@/core/storage/CCSettingsStorage'); + expect(getCCSettingsPath()).toBe('.claude/settings.json'); + + setClaudeHomeDirName('.claude-internal'); + expect(getCCSettingsPath()).toBe('.claude-internal/settings.json'); + }); + + it('getSessionsPath responds to dir name changes', async () => { + const { getSessionsPath } = await import('@/core/storage/SessionStorage'); + expect(getSessionsPath()).toBe('.claude/sessions'); + + setClaudeHomeDirName('.claude-internal'); + expect(getSessionsPath()).toBe('.claude-internal/sessions'); + }); + + it('getSDKProjectsPath responds to dir name changes', async () => { + const { getSDKProjectsPath } = await import('@/utils/sdkSession'); + expect(getSDKProjectsPath()).toBe(path.join(os.homedir(), '.claude', 'projects')); + + setClaudeHomeDirName('.claude-internal'); + expect(getSDKProjectsPath()).toBe(path.join(os.homedir(), '.claude-internal', 'projects')); + }); + }); +});