chore(vault): backup 2026-07-03 14:50:02

This commit is contained in:
2026-07-03 14:50:02 +08:00
parent cf18879901
commit 806d02aafe
42 changed files with 58 additions and 3030 deletions
@@ -1,54 +0,0 @@
name: CI
on:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Cache Bun dependencies
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Cache OpenCode binary
id: cache-opencode
uses: actions/cache@v4
with:
path: ~/.bun/bin/opencode
key: ${{ runner.os }}-opencode-${{ hashFiles('.github/workflows/ci.yml') }}
restore-keys: |
${{ runner.os }}-opencode-
- name: Install dependencies
run: bun install
- name: Install OpenCode CLI
if: steps.cache-opencode.outputs.cache-hit != 'true'
run: bun install -g opencode-ai
- name: Verify OpenCode installation
run: opencode --version
- name: Type check
run: bun run tsc -noEmit -skipLibCheck
- name: Build plugin
run: bun run build
- name: Run tests
run: bun test
@@ -1,3 +0,0 @@
node_modules
data.json
@@ -1,24 +0,0 @@
---
description: Implement an approved OpenSpec change and keep tasks in sync.
---
The user has requested to implement the following change proposal. Find the change proposal and follow the instructions below. If you're not sure or if ambiguous, ask for clarification from the user.
<UserRequest>
$ARGUMENTS
</UserRequest>
<!-- OPENSPEC:START -->
**Guardrails**
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
- Keep changes tightly scoped to the requested outcome.
- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications.
**Steps**
Track these steps as TODOs and complete them one by one.
1. Read `changes/<id>/proposal.md`, `design.md` (if present), and `tasks.md` to confirm scope and acceptance criteria.
2. Work through tasks sequentially, keeping edits minimal and focused on the requested change.
3. Confirm completion before updating statuses—make sure every item in `tasks.md` is finished.
4. Update the checklist after all work is done so each task is marked `- [x]` and reflects reality.
5. Reference `openspec list` or `openspec show <item>` when additional context is required.
**Reference**
- Use `openspec show <id> --json --deltas-only` if you need additional context from the proposal while implementing.
<!-- OPENSPEC:END -->
@@ -1,27 +0,0 @@
---
description: Archive a deployed OpenSpec change and update specs.
---
<ChangeId>
$ARGUMENTS
</ChangeId>
<!-- OPENSPEC:START -->
**Guardrails**
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
- Keep changes tightly scoped to the requested outcome.
- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications.
**Steps**
1. Determine the change ID to archive:
- If this prompt already includes a specific change ID (for example inside a `<ChangeId>` block populated by slash-command arguments), use that value after trimming whitespace.
- If the conversation references a change loosely (for example by title or summary), run `openspec list` to surface likely IDs, share the relevant candidates, and confirm which one the user intends.
- Otherwise, review the conversation, run `openspec list`, and ask the user which change to archive; wait for a confirmed change ID before proceeding.
- If you still cannot identify a single change ID, stop and tell the user you cannot archive anything yet.
2. Validate the change ID by running `openspec list` (or `openspec show <id>`) and stop if the change is missing, already archived, or otherwise not ready to archive.
3. Run `openspec archive <id> --yes` so the CLI moves the change and applies spec updates without prompts (use `--skip-specs` only for tooling-only work).
4. Review the command output to confirm the target specs were updated and the change landed in `changes/archive/`.
5. Validate with `openspec validate --strict` and inspect with `openspec show <id>` if anything looks off.
**Reference**
- Use `openspec list` to confirm change IDs before archiving.
- Inspect refreshed specs with `openspec list --specs` and address any validation issues before handing off.
<!-- OPENSPEC:END -->
@@ -1,29 +0,0 @@
---
description: Scaffold a new OpenSpec change and validate strictly.
---
The user has requested the following change proposal. Use the openspec instructions to create their change proposal.
<UserRequest>
$ARGUMENTS
</UserRequest>
<!-- OPENSPEC:START -->
**Guardrails**
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
- Keep changes tightly scoped to the requested outcome.
- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications.
- Identify any vague or ambiguous details and ask the necessary follow-up questions before editing files.
- Do not write any code during the proposal stage. Only create design documents (proposal.md, tasks.md, design.md, and spec deltas). Implementation happens in the apply stage after approval.
**Steps**
1. Review `openspec/project.md`, run `openspec list` and `openspec list --specs`, and inspect related code or docs (e.g., via `rg`/`ls`) to ground the proposal in current behaviour; note any gaps that require clarification.
2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, and `design.md` (when needed) under `openspec/changes/<id>/`.
3. Map the change into concrete capabilities or requirements, breaking multi-scope efforts into distinct spec deltas with clear relationships and sequencing.
4. Capture architectural reasoning in `design.md` when the solution spans multiple systems, introduces new patterns, or demands trade-off discussion before committing to specs.
5. Draft spec deltas in `changes/<id>/specs/<capability>/spec.md` (one folder per capability) using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement and cross-reference related capabilities when relevant.
6. Draft `tasks.md` as an ordered list of small, verifiable work items that deliver user-visible progress, include validation (tests, tooling), and highlight dependencies or parallelizable work.
7. Validate with `openspec validate <id> --strict` and resolve every issue before sharing the proposal.
**Reference**
- Use `openspec show <id> --json --deltas-only` or `openspec show <spec> --type spec` to inspect details when validation fails.
- Search existing requirements with `rg -n "Requirement:|Scenario:" openspec/specs` before writing new ones.
- Explore the codebase with `rg <keyword>`, `ls`, or direct file reads so proposals align with current implementation realities.
<!-- OPENSPEC:END -->
-111
View File
@@ -1,111 +0,0 @@
# AGENTS.md - Obsidian OpenCode Plugin
Guidelines for AI coding agents working on the obsidian-opencode plugin.
## Project Overview
Obsidian plugin that embeds the OpenCode AI assistant via an iframe. Spawns a local server process and displays its web UI in the Obsidian sidebar.
**Tech Stack:** TypeScript, Obsidian Plugin API, esbuild, Node.js child processes
## Build Commands
```bash
bun install # Install dependencies
bun run build # Production (type-check + bundle)
```
Output: `main.js` (CommonJS bundle)
## Project Structure
```
src/
├── main.ts # Plugin entry, extends Plugin
├── types.ts # Types and constants
├── OpenCodeView.ts # Sidebar view (ItemView) with iframe
├── ProcessManager.ts # Server process lifecycle
└── SettingsTab.ts # Settings UI (PluginSettingTab)
```
## Coding guidelines
### Naming Conventions
| Type | Convention | Example |
|------|------------|---------|
| Classes | PascalCase | `OpenCodePlugin`, `ProcessManager` |
| Interfaces/Types | PascalCase | `OpenCodeSettings`, `ProcessState` |
| Constants | UPPER_CASE or camelCase | `DEFAULT_SETTINGS`, `OPENCODE_VIEW_TYPE` |
| Variables/functions | camelCase | `getVaultPath`, `startServer` |
| Private members | camelCase (no prefix) | `private processManager` |
| Files | PascalCase (classes), lowercase (entry) | `ProcessManager.ts`, `main.ts` |
### TypeScript Patterns
- `strictNullChecks` enabled - handle null/undefined
- Union types for state: `"stopped" | "starting" | "running" | "error"`
- `async/await` over Promises
- Explicit return types on public methods
```typescript
getProcessState(): ProcessState {
return this.processManager?.getState() ?? "stopped";
}
```
### Obsidian API Patterns
- Extend `Plugin` with `onload()`/`onunload()` lifecycle
- Extend `ItemView` for views: `getViewType()`, `onOpen()`, `onClose()`
- Extend `PluginSettingTab` for settings: `display()`
- DOM helpers: `createEl()`, `createDiv()`, `setIcon()`
- Register in `onload()`, clean up in `onunload()`
```typescript
this.registerView(OPENCODE_VIEW_TYPE, (leaf) => new OpenCodeView(leaf, this));
this.addCommand({ id: "toggle-view", name: "Toggle panel", callback: () => this.toggleView() });
```
### DOM Creation
```typescript
const container = this.contentEl.createDiv({ cls: "opencode-container" });
container.createEl("h3", { text: "Title" });
container.createEl("button", { text: "Click", cls: "mod-cta" });
```
### State Management
- Callback-based subscriptions
- Centralized state in manager classes
- Immediate notification on state change
## Config Summary
**tsconfig.json:** ES6 target, ESNext modules, strictNullChecks, noImplicitAny
**esbuild:** CJS format, es2018 target, node platform. Externals: obsidian, electron, CodeMirror, Node builtins
## Desktop-Only
Uses Node.js APIs unavailable on mobile:
- `child_process.spawn()` for server process
- File system via vault adapter
Check for desktop environment before adding mobile-incompatible features.
<!-- OPENSPEC:START -->
# OpenSpec Instructions
These instructions are for AI assistants working in this project.
Always open `@/openspec/AGENTS.md` when the request:
- Mentions planning or proposals (words like proposal, spec, change, plan)
- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work
- Sounds ambiguous and you need the authoritative spec before coding
Use `@/openspec/AGENTS.md` to learn:
- How to create and apply change proposals
- Spec format and conventions
- Project structure and guidelines
Keep this managed block so 'openspec update' can refresh the instructions.
<!-- OPENSPEC:END -->
-70
View File
@@ -1,70 +0,0 @@
# Contributing
Thank you for your interest in contributing to the OpenCode Obsidian plugin!
## What We Accept
We welcome the following types of contributions:
- Bug fixes
- Performance improvements
- Documentation improvements
**Note:** Core product features and major changes require prior discussion. Please [open an issue](https://github.com/mtymek/opencode-obsidian/issues) first to discuss your proposal before starting work. PRs for new features submitted without prior approval may be closed.
I'm also interested in collecting a set of Agent Skills useful in the context of Obsidian vaults. Feel free to submit an issue or PR with your ideas.
## Development Setup
### Requirements
- [Bun](https://bun.sh/) 1.3+
- Obsidian (for testing the plugin)
### Getting Started
```bash
git clone https://github.com/mtymek/opencode-obsidian.git
cd opencode-obsidian
bun install
```
### Development Workflow
To work on the plugin during development:
1. Build the plugin:
```bash
bun run build # One-time production build
```
2. The plugin will be built to `main.js` in the project root
3. For testing in Obsidian:
- Enable Community Plugins in Obsidian settings
- Copy the built plugin to your vault's `.obsidian/plugins/opencode-obsidian/` directory
- Reload Obsidian or use the "Reload app without saving" command
### Commands
```bash
bun install # Install dependencies
bun run build # Production build with type checking
bun test # Run tests
```
## Before Submitting a PR
1. Run `bun run build` to ensure type checking passes
2. Run `bun test` to ensure all tests pass
3. Test the plugin in Obsidian to verify functionality
## Pull Requests
1. Keep PRs small and focused on a single concern
2. Explain what your PR is attempting to fix or improve
3. Link any relevant issues in the PR description
## License
By contributing to the OpenCode Obsidian plugin, you agree to license your contribution under the [MIT License](LICENSE).
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025 Mateusz Tymek
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-46
View File
@@ -1,46 +0,0 @@
# OpenCode plugin for Obsidian
Give your notes AI capability by embedding Opencode [OpenCode](https://opencode.ai) AI assistant directly in Obsidian:
<img src="./assets/opencode_in_obsidian.png" alt="OpenCode embeded in Obsidian" />
**Use cases:**
- Summarize and distill long-form content
- Draft, edit, and refine your writing
- Query and explore your knowledge base
- Generate outlines and structured notes
_Note: plugin author is not afiliated with OpenCode or Obsidian - this is a 3rd party software._
## Requirements
- Desktop only (uses Node.js child processes)
- [OpenCode CLI](https://opencode.ai) installed
- [Bun](https://bun.sh) installed
## Installation
1. Clone to `.obsidian/plugins/obsidian-opencode` subdirectory under your vault's root
2. Run `bun install && bun run build`
3. Enable in Obsidian Settings > Community plugins
4. Add AGENTS.md to the workspace root, use it to explain the structure
## Usage
- Click the terminal icon in the ribbon, or
- `Cmd/Ctrl+Shift+O` to toggle the panel
- Server starts automatically when you open the panel
### Commands
| Command | Description |
|---------|-------------|
| Toggle OpenCode panel | Show/hide sidebar |
| Start OpenCode server | Manual start |
| Stop OpenCode server | Manual stop |
## Settings
<img src="./assets/plugin_settings.png" alt="Available plugin settings" />
@@ -1,142 +0,0 @@
# Markdown Translator 测试报告
## 测试日期
2026-01-18
## 测试环境
- Windows 11
- Python 3.8
- 测试模式(模拟翻译)
## 测试结果
### ✅ 通过的测试
#### 1. 基础 Markdown 元素
**测试内容**:标题、段落、无序列表、编号列表、引用、代码块
**结果**:所有元素都正确分离和保留
- 标题标记(`#`, `##`, `###`)正确保留
- 文本内容正确识别为需要翻译
- 列表标记(`-`, `1.`)正确保留
- 引用标记(`>`)正确保留
- 代码块(```python ... ```)完全不变
#### 2. 链接和图片
**测试内容**Markdown 链接和图片链接
**结果**URL 完整保留
```markdown
[GitHub](https://github.com)
![Alt text](image.png)
```
#### 3. 代码块
**测试内容**fenced 代码块
**结果**:完全保持不变
```python
def hello():
print("Hello, World!")
```
#### 4. 嵌套列表
**测试内容**:多层嵌套列表
**结果**:缩进和层级保持正确
### ⚠️ 已知限制
#### 1. 内联代码
**限制**:内联代码内容会被翻译,但反引号保留
**示例**
输入:`inline code`
输出:`[翻译] inline code`
**影响**:小到中等,大部分 Markdown 渲染器会正确处理
**改进建议**:可以添加更精细的内联代码检测,在段落级别处理
#### 2. HTML 标签
**限制**HTML 标签内的内容会被翻译
**示例**
输入:`<span>HTML</span>`
输出:`<span>[翻译] HTML</span>`
**影响**:小到中等,大部分 Obsidian 用户使用标准 Markdown
**改进建议**:可以添加 HTML 标签检测和保留
## 性能
- 解析速度:快速(< 1秒处理 1KB 文件)
- 内存占用:低(< 10MB
- 文件大小支持:理论上无限制(逐行处理)
## 使用建议
### 1. 最佳实践
- 对于包含大量代码的文档,翻译后检查代码块
- 对于包含内联代码的段落,可能需要手动修正
- 建议在翻译后检查格式完整性
### 2. 工作流
1. 创建 Markdown 文档
2. 运行翻译脚本
3. 检查输出文件
4. 如有需要,手动微调
### 3. 批量处理
```bash
# 翻译整个目录
for file in docs/*.md; do
python .../translate.py --file "$file" --output "translated/${file##*/}"
done
```
## 总结
Markdown Translator 技能成功实现了:
✅ 核心功能:Markdown 格式保持
✅ 易用性:简单的命令行界面
✅ 学术风格:内置学术翻译指南
✅ 测试模式:无需 API 即可测试解析逻辑
✅ 错误处理:重试机制和详细错误信息
适合翻译学术和技术文档,对于大多数使用场景已足够。
## 下一步
1. **安装 LibreTranslate**
```bash
pip install libretranslate
libretranslate --host 127.0.0.1 --port 5000
```
2. **使用真实翻译**
```bash
python .../translate.py --file doc.md --output translated.md
```
3. **集成到工作流**
- 创建快捷脚本
- 在 Obsidian 中通过命令调用
- 建立批量处理流程
## 结论
✅ Markdown Translator 技能已通过测试,可以投入使用。
对于学术和技术文档翻译,该技能提供了良好的格式保持和学术风格支持。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 653 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 781 KiB

-103
View File
@@ -1,103 +0,0 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "obsidian-opencode",
"devDependencies": {
"@types/bun": "^1.3.5",
"@types/node": "^20.11.0",
"builtin-modules": "^3.3.0",
"esbuild": "^0.21.5",
"obsidian": "latest",
"tslib": "^2.6.2",
"typescript": "^5.4.5",
},
},
},
"packages": {
"@codemirror/state": ["@codemirror/state@6.5.0", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw=="],
"@codemirror/view": ["@codemirror/view@6.38.6", "", { "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
"@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="],
"@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
"@types/codemirror": ["@types/codemirror@5.60.8", "", { "dependencies": { "@types/tern": "*" } }, "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/node": ["@types/node@20.19.27", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug=="],
"@types/tern": ["@types/tern@0.23.9", "", { "dependencies": { "@types/estree": "*" } }, "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw=="],
"builtin-modules": ["builtin-modules@3.3.0", "", {}, "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw=="],
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
"crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="],
"esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": "bin/esbuild" }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
"moment": ["moment@2.29.4", "", {}, "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w=="],
"obsidian": ["obsidian@1.11.0", "", { "dependencies": { "@types/codemirror": "5.60.8", "moment": "2.29.4" }, "peerDependencies": { "@codemirror/state": "6.5.0", "@codemirror/view": "6.38.6" } }, "sha512-lVqN9AmDWHzhNATi2tDnjqVgI6WUYKeT+lIsAycAyLt4XCC6zRsWzb+tFCiB7Rn3PpttefjoovilhYwvS4Iqxw=="],
"style-mod": ["style-mod@4.1.3", "", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
}
}
-49
View File
@@ -1,49 +0,0 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
const banner = `/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
If you want to view the source, please visit the GitHub repository.
*/
`;
const prod = process.argv[2] === "production";
const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ["src/main.ts"],
bundle: true,
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins,
],
format: "cjs",
target: "es2018",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "main.js",
platform: "node",
});
if (prod) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}
-9
View File
@@ -1,9 +0,0 @@
{
"id": "obsidian-opencode",
"name": "OpenCode",
"version": "0.1.0",
"minAppVersion": "1.4.0",
"description": "Embed OpenCode AI assistant in Obsidian for AI-powered note management",
"author": "mat",
"isDesktopOnly": true
}
-456
View File
@@ -1,456 +0,0 @@
# OpenSpec Instructions
Instructions for AI coding assistants using OpenSpec for spec-driven development.
## TL;DR Quick Checklist
- Search existing work: `openspec spec list --long`, `openspec list` (use `rg` only for full-text search)
- Decide scope: new capability vs modify existing capability
- Pick a unique `change-id`: kebab-case, verb-led (`add-`, `update-`, `remove-`, `refactor-`)
- Scaffold: `proposal.md`, `tasks.md`, `design.md` (only if needed), and delta specs per affected capability
- Write deltas: use `## ADDED|MODIFIED|REMOVED|RENAMED Requirements`; include at least one `#### Scenario:` per requirement
- Validate: `openspec validate [change-id] --strict` and fix issues
- Request approval: Do not start implementation until proposal is approved
## Three-Stage Workflow
### Stage 1: Creating Changes
Create proposal when you need to:
- Add features or functionality
- Make breaking changes (API, schema)
- Change architecture or patterns
- Optimize performance (changes behavior)
- Update security patterns
Triggers (examples):
- "Help me create a change proposal"
- "Help me plan a change"
- "Help me create a proposal"
- "I want to create a spec proposal"
- "I want to create a spec"
Loose matching guidance:
- Contains one of: `proposal`, `change`, `spec`
- With one of: `create`, `plan`, `make`, `start`, `help`
Skip proposal for:
- Bug fixes (restore intended behavior)
- Typos, formatting, comments
- Dependency updates (non-breaking)
- Configuration changes
- Tests for existing behavior
**Workflow**
1. Review `openspec/project.md`, `openspec list`, and `openspec list --specs` to understand current context.
2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, optional `design.md`, and spec deltas under `openspec/changes/<id>/`.
3. Draft spec deltas using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement.
4. Run `openspec validate <id> --strict` and resolve any issues before sharing the proposal.
### Stage 2: Implementing Changes
Track these steps as TODOs and complete them one by one.
1. **Read proposal.md** - Understand what's being built
2. **Read design.md** (if exists) - Review technical decisions
3. **Read tasks.md** - Get implementation checklist
4. **Implement tasks sequentially** - Complete in order
5. **Confirm completion** - Ensure every item in `tasks.md` is finished before updating statuses
6. **Update checklist** - After all work is done, set every task to `- [x]` so the list reflects reality
7. **Approval gate** - Do not start implementation until the proposal is reviewed and approved
### Stage 3: Archiving Changes
After deployment, create separate PR to:
- Move `changes/[name]/``changes/archive/YYYY-MM-DD-[name]/`
- Update `specs/` if capabilities changed
- Use `openspec archive <change-id> --skip-specs --yes` for tooling-only changes (always pass the change ID explicitly)
- Run `openspec validate --strict` to confirm the archived change passes checks
## Before Any Task
**Context Checklist:**
- [ ] Read relevant specs in `specs/[capability]/spec.md`
- [ ] Check pending changes in `changes/` for conflicts
- [ ] Read `openspec/project.md` for conventions
- [ ] Run `openspec list` to see active changes
- [ ] Run `openspec list --specs` to see existing capabilities
**Before Creating Specs:**
- Always check if capability already exists
- Prefer modifying existing specs over creating duplicates
- Use `openspec show [spec]` to review current state
- If request is ambiguous, ask 12 clarifying questions before scaffolding
### Search Guidance
- Enumerate specs: `openspec spec list --long` (or `--json` for scripts)
- Enumerate changes: `openspec list` (or `openspec change list --json` - deprecated but available)
- Show details:
- Spec: `openspec show <spec-id> --type spec` (use `--json` for filters)
- Change: `openspec show <change-id> --json --deltas-only`
- Full-text search (use ripgrep): `rg -n "Requirement:|Scenario:" openspec/specs`
## Quick Start
### CLI Commands
```bash
# Essential commands
openspec list # List active changes
openspec list --specs # List specifications
openspec show [item] # Display change or spec
openspec validate [item] # Validate changes or specs
openspec archive <change-id> [--yes|-y] # Archive after deployment (add --yes for non-interactive runs)
# Project management
openspec init [path] # Initialize OpenSpec
openspec update [path] # Update instruction files
# Interactive mode
openspec show # Prompts for selection
openspec validate # Bulk validation mode
# Debugging
openspec show [change] --json --deltas-only
openspec validate [change] --strict
```
### Command Flags
- `--json` - Machine-readable output
- `--type change|spec` - Disambiguate items
- `--strict` - Comprehensive validation
- `--no-interactive` - Disable prompts
- `--skip-specs` - Archive without spec updates
- `--yes`/`-y` - Skip confirmation prompts (non-interactive archive)
## Directory Structure
```
openspec/
├── project.md # Project conventions
├── specs/ # Current truth - what IS built
│ └── [capability]/ # Single focused capability
│ ├── spec.md # Requirements and scenarios
│ └── design.md # Technical patterns
├── changes/ # Proposals - what SHOULD change
│ ├── [change-name]/
│ │ ├── proposal.md # Why, what, impact
│ │ ├── tasks.md # Implementation checklist
│ │ ├── design.md # Technical decisions (optional; see criteria)
│ │ └── specs/ # Delta changes
│ │ └── [capability]/
│ │ └── spec.md # ADDED/MODIFIED/REMOVED
│ └── archive/ # Completed changes
```
## Creating Change Proposals
### Decision Tree
```
New request?
├─ Bug fix restoring spec behavior? → Fix directly
├─ Typo/format/comment? → Fix directly
├─ New feature/capability? → Create proposal
├─ Breaking change? → Create proposal
├─ Architecture change? → Create proposal
└─ Unclear? → Create proposal (safer)
```
### Proposal Structure
1. **Create directory:** `changes/[change-id]/` (kebab-case, verb-led, unique)
2. **Write proposal.md:**
```markdown
# Change: [Brief description of change]
## Why
[1-2 sentences on problem/opportunity]
## What Changes
- [Bullet list of changes]
- [Mark breaking changes with **BREAKING**]
## Impact
- Affected specs: [list capabilities]
- Affected code: [key files/systems]
```
3. **Create spec deltas:** `specs/[capability]/spec.md`
```markdown
## ADDED Requirements
### Requirement: New Feature
The system SHALL provide...
#### Scenario: Success case
- **WHEN** user performs action
- **THEN** expected result
## MODIFIED Requirements
### Requirement: Existing Feature
[Complete modified requirement]
## REMOVED Requirements
### Requirement: Old Feature
**Reason**: [Why removing]
**Migration**: [How to handle]
```
If multiple capabilities are affected, create multiple delta files under `changes/[change-id]/specs/<capability>/spec.md`—one per capability.
4. **Create tasks.md:**
```markdown
## 1. Implementation
- [ ] 1.1 Create database schema
- [ ] 1.2 Implement API endpoint
- [ ] 1.3 Add frontend component
- [ ] 1.4 Write tests
```
5. **Create design.md when needed:**
Create `design.md` if any of the following apply; otherwise omit it:
- Cross-cutting change (multiple services/modules) or a new architectural pattern
- New external dependency or significant data model changes
- Security, performance, or migration complexity
- Ambiguity that benefits from technical decisions before coding
Minimal `design.md` skeleton:
```markdown
## Context
[Background, constraints, stakeholders]
## Goals / Non-Goals
- Goals: [...]
- Non-Goals: [...]
## Decisions
- Decision: [What and why]
- Alternatives considered: [Options + rationale]
## Risks / Trade-offs
- [Risk] → Mitigation
## Migration Plan
[Steps, rollback]
## Open Questions
- [...]
```
## Spec File Format
### Critical: Scenario Formatting
**CORRECT** (use #### headers):
```markdown
#### Scenario: User login success
- **WHEN** valid credentials provided
- **THEN** return JWT token
```
**WRONG** (don't use bullets or bold):
```markdown
- **Scenario: User login** ❌
**Scenario**: User login ❌
### Scenario: User login ❌
```
Every requirement MUST have at least one scenario.
### Requirement Wording
- Use SHALL/MUST for normative requirements (avoid should/may unless intentionally non-normative)
### Delta Operations
- `## ADDED Requirements` - New capabilities
- `## MODIFIED Requirements` - Changed behavior
- `## REMOVED Requirements` - Deprecated features
- `## RENAMED Requirements` - Name changes
Headers matched with `trim(header)` - whitespace ignored.
#### When to use ADDED vs MODIFIED
- ADDED: Introduces a new capability or sub-capability that can stand alone as a requirement. Prefer ADDED when the change is orthogonal (e.g., adding "Slash Command Configuration") rather than altering the semantics of an existing requirement.
- MODIFIED: Changes the behavior, scope, or acceptance criteria of an existing requirement. Always paste the full, updated requirement content (header + all scenarios). The archiver will replace the entire requirement with what you provide here; partial deltas will drop previous details.
- RENAMED: Use when only the name changes. If you also change behavior, use RENAMED (name) plus MODIFIED (content) referencing the new name.
Common pitfall: Using MODIFIED to add a new concern without including the previous text. This causes loss of detail at archive time. If you arent explicitly changing the existing requirement, add a new requirement under ADDED instead.
Authoring a MODIFIED requirement correctly:
1) Locate the existing requirement in `openspec/specs/<capability>/spec.md`.
2) Copy the entire requirement block (from `### Requirement: ...` through its scenarios).
3) Paste it under `## MODIFIED Requirements` and edit to reflect the new behavior.
4) Ensure the header text matches exactly (whitespace-insensitive) and keep at least one `#### Scenario:`.
Example for RENAMED:
```markdown
## RENAMED Requirements
- FROM: `### Requirement: Login`
- TO: `### Requirement: User Authentication`
```
## Troubleshooting
### Common Errors
**"Change must have at least one delta"**
- Check `changes/[name]/specs/` exists with .md files
- Verify files have operation prefixes (## ADDED Requirements)
**"Requirement must have at least one scenario"**
- Check scenarios use `#### Scenario:` format (4 hashtags)
- Don't use bullet points or bold for scenario headers
**Silent scenario parsing failures**
- Exact format required: `#### Scenario: Name`
- Debug with: `openspec show [change] --json --deltas-only`
### Validation Tips
```bash
# Always use strict mode for comprehensive checks
openspec validate [change] --strict
# Debug delta parsing
openspec show [change] --json | jq '.deltas'
# Check specific requirement
openspec show [spec] --json -r 1
```
## Happy Path Script
```bash
# 1) Explore current state
openspec spec list --long
openspec list
# Optional full-text search:
# rg -n "Requirement:|Scenario:" openspec/specs
# rg -n "^#|Requirement:" openspec/changes
# 2) Choose change id and scaffold
CHANGE=add-two-factor-auth
mkdir -p openspec/changes/$CHANGE/{specs/auth}
printf "## Why\n...\n\n## What Changes\n- ...\n\n## Impact\n- ...\n" > openspec/changes/$CHANGE/proposal.md
printf "## 1. Implementation\n- [ ] 1.1 ...\n" > openspec/changes/$CHANGE/tasks.md
# 3) Add deltas (example)
cat > openspec/changes/$CHANGE/specs/auth/spec.md << 'EOF'
## ADDED Requirements
### Requirement: Two-Factor Authentication
Users MUST provide a second factor during login.
#### Scenario: OTP required
- **WHEN** valid credentials are provided
- **THEN** an OTP challenge is required
EOF
# 4) Validate
openspec validate $CHANGE --strict
```
## Multi-Capability Example
```
openspec/changes/add-2fa-notify/
├── proposal.md
├── tasks.md
└── specs/
├── auth/
│ └── spec.md # ADDED: Two-Factor Authentication
└── notifications/
└── spec.md # ADDED: OTP email notification
```
auth/spec.md
```markdown
## ADDED Requirements
### Requirement: Two-Factor Authentication
...
```
notifications/spec.md
```markdown
## ADDED Requirements
### Requirement: OTP Email Notification
...
```
## Best Practices
### Simplicity First
- Default to <100 lines of new code
- Single-file implementations until proven insufficient
- Avoid frameworks without clear justification
- Choose boring, proven patterns
### Complexity Triggers
Only add complexity with:
- Performance data showing current solution too slow
- Concrete scale requirements (>1000 users, >100MB data)
- Multiple proven use cases requiring abstraction
### Clear References
- Use `file.ts:42` format for code locations
- Reference specs as `specs/auth/spec.md`
- Link related changes and PRs
### Capability Naming
- Use verb-noun: `user-auth`, `payment-capture`
- Single purpose per capability
- 10-minute understandability rule
- Split if description needs "AND"
### Change ID Naming
- Use kebab-case, short and descriptive: `add-two-factor-auth`
- Prefer verb-led prefixes: `add-`, `update-`, `remove-`, `refactor-`
- Ensure uniqueness; if taken, append `-2`, `-3`, etc.
## Tool Selection Guide
| Task | Tool | Why |
|------|------|-----|
| Find files by pattern | Glob | Fast pattern matching |
| Search code content | Grep | Optimized regex search |
| Read specific files | Read | Direct file access |
| Explore unknown scope | Task | Multi-step investigation |
## Error Recovery
### Change Conflicts
1. Run `openspec list` to see active changes
2. Check for overlapping specs
3. Coordinate with change owners
4. Consider combining proposals
### Validation Failures
1. Run with `--strict` flag
2. Check JSON output for details
3. Verify spec file format
4. Ensure scenarios properly formatted
### Missing Context
1. Read project.md first
2. Check related specs
3. Review recent archives
4. Ask for clarification
## Quick Reference
### Stage Indicators
- `changes/` - Proposed, not yet built
- `specs/` - Built and deployed
- `archive/` - Completed changes
### File Purposes
- `proposal.md` - Why and what
- `tasks.md` - Implementation steps
- `design.md` - Technical decisions
- `spec.md` - Requirements and behavior
### CLI Essentials
```bash
openspec list # What's in progress?
openspec show [item] # View details
openspec validate --strict # Is it correct?
openspec archive <change-id> [--yes|-y] # Mark complete (add --yes for automation)
```
Remember: Specs are truth. Changes are proposals. Keep them in sync.
@@ -1,14 +0,0 @@
# Change: Add Default View Location Setting
## Why
Users may prefer to have the OpenCode panel open in the main editor area instead of the sidebar, depending on their workflow and screen size. Currently, the view always opens in the right sidebar with no option to change this behavior.
## What Changes
- Add a new `defaultViewLocation` setting with options: `"sidebar"` or `"main"`
- Update view activation logic to respect this setting when opening the panel
- Sidebar opens in the right leaf (current behavior)
- Main opens in a new tab in the main editor area
## Impact
- Affected specs: `001-mvp-opencode-embed` (Settings Configuration, Sidebar View Registration, Ribbon Icon)
- Affected code: `src/types.ts`, `src/main.ts`, `src/SettingsTab.ts`
@@ -1,36 +0,0 @@
## MODIFIED Requirements
### Requirement: Settings Configuration
The plugin SHALL provide configurable settings for server port, hostname, executable path, project directory, auto-start behavior, and default view location.
#### Scenario: Settings persistence
- **WHEN** the user modifies settings
- **THEN** changes are persisted to plugin data
- **AND** the process manager is updated with new settings
#### Scenario: Default view location options
- **WHEN** the user configures default view location
- **THEN** the options available are "sidebar" and "main"
- **AND** the default value is "sidebar"
### Requirement: Sidebar View Registration
The plugin SHALL register an ItemView that displays in either the Obsidian sidebar or main editor area based on user settings.
#### Scenario: View activation in sidebar
- **WHEN** the user clicks the ribbon icon or runs the toggle command
- **AND** the default view location is set to "sidebar"
- **THEN** the OpenCode view opens in the right sidebar
- **AND** if the view already exists, it is revealed
#### Scenario: View activation in main area
- **WHEN** the user clicks the ribbon icon or runs the toggle command
- **AND** the default view location is set to "main"
- **THEN** the OpenCode view opens as a new tab in the main editor area
- **AND** if the view already exists in any location, it is revealed
### Requirement: Ribbon Icon
The plugin SHALL add a ribbon icon that activates the OpenCode view in the configured default location.
#### Scenario: Ribbon icon click
- **WHEN** the user clicks the OpenCode ribbon icon
- **THEN** the OpenCode view is activated in the location specified by the default view location setting
@@ -1,23 +0,0 @@
## 1. Implementation
- [ ] 1.1 Add `defaultViewLocation` type and setting to `src/types.ts`
- Add type: `"sidebar" | "main"`
- Add to `OpenCodeSettings` interface
- Add default value `"sidebar"` to `DEFAULT_SETTINGS`
- [ ] 1.2 Update `src/main.ts` view activation logic
- Modify `activateView()` method to check `defaultViewLocation` setting
- For "sidebar": use `getLeaf("right")` (existing behavior)
- For "main": use `getLeaf("tab")` to open in main editor area
- Ensure existing view detection works for both locations
- [ ] 1.3 Add setting UI to `src/SettingsTab.ts`
- Add dropdown setting for "Default view location"
- Options: "Sidebar" and "Main window"
- Description explaining the behavior difference
- [ ] 1.4 Test the feature
- Verify sidebar mode opens in right sidebar
- Verify main mode opens as a tab in editor area
- Verify toggling reveals existing view regardless of location
- Verify setting persists across plugin reload
@@ -1,121 +0,0 @@
## Context
This plugin embeds the OpenCode AI assistant into Obsidian by spawning a local server process and displaying its web UI in an iframe within the Obsidian sidebar. The design prioritizes simplicity and reliability for the MVP.
**Constraints:**
- Desktop-only (requires Node.js child_process APIs)
- Must work within Obsidian's plugin sandbox
- OpenCode CLI must be installed separately by the user
## Goals / Non-Goals
**Goals:**
- Seamless embedding of OpenCode UI in Obsidian sidebar
- Reliable server process lifecycle management
- Configurable server and project settings
- Quick access via ribbon icon and keyboard shortcut
**Non-Goals:**
- Mobile support (not possible due to Node.js dependencies)
- Native UI rendering (OpenCode provides its own web UI)
- Multiple simultaneous OpenCode instances
- Deep Obsidian integration (file linking, note references)
## Decisions
### 1. Iframe Embedding vs Native Rendering
**Decision:** Embed OpenCode's web UI via iframe rather than building native Obsidian UI.
**Rationale:**
- Can be up and running quickly
- OpenCode already provides a full-featured web interface
- Reduces maintenance burden - UI updates come from OpenCode automatically
- Allows feature parity with standalone OpenCode web experience
**Trade-offs:**
- Limited ability to customize UI appearance
- Requires CORS configuration (`--cors app://obsidian.md`)
- Iframe isolation limits some interactions
### 2. Process State Machine
**Decision:** Use a 4-state machine: `stopped``starting``running` | `error`
**Rationale:**
- Clear, predictable state transitions
- Enables reactive UI that shows appropriate content for each state
- Prevents race conditions (can't start while starting)
**States:**
- `stopped`: Server not running, show start button
- `starting`: Spawn initiated, polling health endpoint, show spinner
- `running`: Health check passed, show iframe
- `error`: Spawn failed or health check timeout, show retry option
### 3. Lazy Start Strategy
**Decision:** Start server when view opens, not when plugin loads.
**Rationale:**
- Faster Obsidian startup (server spawn is ~2-5 seconds)
- User may not need OpenCode every session
- Auto-start available as opt-in setting for users who prefer it
**Implementation:** `OpenCodeView.onOpen()` calls `plugin.startServer()` if state is `stopped`.
### 4. Health Check Polling
**Decision:** Poll `/global/health` endpoint every 500ms during startup, with 15-second timeout.
**Rationale:**
- Server needs time to initialize before accepting connections
- Polling is simple and reliable
- 15 seconds accommodates slow systems without waiting too long
**Trade-offs:**
- Slight delay before iframe loads (typically 1-3 seconds)
- Could use server stdout parsing instead, but health endpoint is more reliable
### 5. Graceful Shutdown
**Decision:** SIGTERM first, SIGKILL after 2-second timeout.
**Rationale:**
- Allows server to clean up resources gracefully
- SIGKILL fallback ensures process doesn't hang
- 2 seconds is sufficient for typical cleanup
### 6. Project Directory Encoding
**Decision:** Encode project directory as base64 in URL path (e.g., `http://127.0.0.1:14096/{base64-path}`).
**Rationale:**
- OpenCode server supports multiple projects via URL routing
- Base64 handles special characters in paths safely
- Allows future support for switching projects without server restart
### 7. Callback-Based State Subscriptions
**Decision:** Use callback array pattern for state change notifications.
**Rationale:**
- Simpler than event emitters or observables for this scale
- View subscribes to plugin state changes in `onOpen()`
- Immediate notification ensures UI stays in sync
**Implementation:**
```typescript
plugin.onProcessStateChange((state) => {
this.currentState = state;
this.updateView();
});
```
## Risks / Trade-offs
| Risk | Mitigation |
|------|------------|
| OpenCode CLI not installed | Clear error message with settings link |
| Port already in use | Health check detects existing server, reuses it |
| Server crash during use | View shows error state, user can retry |
| Slow server startup | 15-second timeout with spinner feedback |
## Open Questions
- Should we support multiple project directories (tabs/switcher)?
- Should settings changes hot-reload the iframe without full restart?
- Should we add connection status indicator in header?
@@ -1,16 +0,0 @@
# Change: Document MVP Capabilities
## Why
The Obsidian OpenCode plugin MVP has been implemented but lacks formal specification. This change retroactively documents the existing functionality to establish a baseline specification that can be expanded as the MVP is polished.
## What Changes
- **ADDED** `001-mvp-opencode-embed` capability spec covering:
- Server process management (spawn, health check, shutdown, state machine)
- Sidebar view with iframe embedding and state-reactive UI
- Plugin settings with validation
- Commands and ribbon icon integration
## Impact
- Affected specs: None (new spec)
- Affected code: None (documentation only)
- This is a retroactive documentation effort with no code changes
@@ -1,138 +0,0 @@
## ADDED Requirements
### Requirement: Server Process Spawning
The plugin SHALL spawn the OpenCode server process using the configured executable path, port, and hostname when the user initiates server start.
#### Scenario: Successful server spawn
- **WHEN** the user starts the server
- **THEN** the plugin spawns `opencode serve --port <port> --hostname <hostname> --cors app://obsidian.md`
- **AND** the process runs with the vault directory as the working directory
### Requirement: Server Health Checking
The plugin SHALL verify server availability by polling a health endpoint during startup.
#### Scenario: Health check during startup
- **WHEN** the server process is spawned
- **THEN** the plugin polls `GET /global/health` every 500ms
- **AND** transitions to running state when the endpoint returns HTTP 200
- **AND** transitions to error state if 15 seconds elapse without success
#### Scenario: Existing server detected
- **WHEN** the user starts the server
- **AND** a server is already running on the configured port
- **THEN** the plugin reuses the existing server
- **AND** transitions directly to running state
### Requirement: Server Shutdown
The plugin SHALL gracefully terminate the server process when stopping.
#### Scenario: Graceful shutdown
- **WHEN** the user stops the server
- **THEN** the plugin sends SIGTERM to the process
- **AND** sends SIGKILL after 2 seconds if the process is still running
### Requirement: Process State Management
The plugin SHALL maintain a state machine with states: stopped, starting, running, and error.
#### Scenario: State transitions
- **WHEN** the server is not running
- **THEN** the state is `stopped`
- **WHEN** spawn is initiated
- **THEN** the state transitions to `starting`
- **WHEN** health check succeeds
- **THEN** the state transitions to `running`
- **WHEN** spawn fails or health check times out
- **THEN** the state transitions to `error`
### Requirement: Sidebar View Registration
The plugin SHALL register an ItemView that displays in the Obsidian sidebar.
#### Scenario: View activation
- **WHEN** the user clicks the ribbon icon or runs the toggle command
- **THEN** the OpenCode view opens in the right sidebar
- **AND** if the view already exists, it is revealed
### Requirement: View State Rendering
The plugin SHALL render different UI content based on the current process state.
#### Scenario: Stopped state UI
- **WHEN** the process state is `stopped`
- **THEN** the view displays a "Start OpenCode" button
#### Scenario: Starting state UI
- **WHEN** the process state is `starting`
- **THEN** the view displays a loading spinner with "Starting OpenCode..." message
#### Scenario: Running state UI
- **WHEN** the process state is `running`
- **THEN** the view displays a header with controls and an iframe loading the server URL
#### Scenario: Error state UI
- **WHEN** the process state is `error`
- **THEN** the view displays an error message with "Retry" and "Open Settings" buttons
### Requirement: Iframe Controls
The plugin SHALL provide controls in the view header when the server is running.
#### Scenario: Header controls available
- **WHEN** the server is running
- **THEN** the header displays reload and stop buttons
- **AND** clicking reload refreshes the iframe
- **AND** clicking stop terminates the server
### Requirement: Lazy Server Start
The plugin SHALL start the server automatically when the view is opened if not already running.
#### Scenario: Auto-start on view open
- **WHEN** the user opens the OpenCode view
- **AND** the server is in `stopped` state
- **THEN** the plugin initiates server start
### Requirement: Settings Configuration
The plugin SHALL provide configurable settings for server port, hostname, executable path, project directory, and auto-start behavior.
#### Scenario: Settings persistence
- **WHEN** the user modifies settings
- **THEN** changes are persisted to plugin data
- **AND** the process manager is updated with new settings
### Requirement: Project Directory Validation
The plugin SHALL validate the project directory setting and support tilde expansion.
#### Scenario: Valid absolute path
- **WHEN** the user enters an absolute path or path starting with ~
- **AND** the path exists and is a directory
- **THEN** the setting is saved with the expanded path
#### Scenario: Invalid path rejection
- **WHEN** the user enters a relative path or non-existent path
- **THEN** a notice is displayed explaining the error
- **AND** the setting is not saved
### Requirement: Project Directory Auto-Restart
The plugin SHALL restart the server when the project directory setting changes while running.
#### Scenario: Restart on directory change
- **WHEN** the user changes the project directory
- **AND** the server is currently running
- **THEN** the plugin stops and restarts the server with the new directory
### Requirement: Commands Registration
The plugin SHALL register commands for toggling the view and controlling the server.
#### Scenario: Toggle command
- **WHEN** the user runs "Toggle OpenCode panel" command or presses Mod+Shift+O
- **THEN** the view opens if closed, or closes if open
#### Scenario: Start and stop commands
- **WHEN** the user runs "Start OpenCode server" command
- **THEN** the server starts
- **WHEN** the user runs "Stop OpenCode server" command
- **THEN** the server stops
### Requirement: Ribbon Icon
The plugin SHALL add a ribbon icon that activates the OpenCode view.
#### Scenario: Ribbon icon click
- **WHEN** the user clicks the OpenCode ribbon icon
- **THEN** the OpenCode view is activated in the right sidebar
@@ -1,5 +0,0 @@
## 1. Documentation
- [x] 1.1 Create proposal.md
- [x] 1.2 Create design.md with architectural decisions
- [x] 1.3 Create specs/001-mvp-opencode-embed/spec.md with requirements and scenarios
- [x] 1.4 Validate with `openspec validate document-mvp-capabilities --strict`
-134
View File
@@ -1,134 +0,0 @@
# Project Context
## Purpose
Obsidian plugin that embeds the OpenCode AI assistant via an iframe. The plugin spawns a local OpenCode server process and displays its web UI in the Obsidian sidebar, enabling AI-assisted coding and note-taking within Obsidian.
## Tech Stack
- **TypeScript** - Primary language with strict null checks
- **Obsidian Plugin API** - For UI integration, views, settings, and commands
- **esbuild** - Bundler (ES2018 target, CommonJS output)
- **Node.js child_process** - For spawning the OpenCode server process
- **Bun** - Package manager and runtime
## Project Conventions
### Code Style
**Imports:**
- ES modules with named imports
- Order: Obsidian API → Node.js builtins → local modules
- Use `type` keyword for type-only imports
- Relative paths with `./` prefix
```typescript
import { Plugin, WorkspaceLeaf, Notice } from "obsidian";
import { spawn, ChildProcess } from "child_process";
import type OpenCodePlugin from "./main";
import { OpenCodeSettings, DEFAULT_SETTINGS } from "./types";
```
**Naming Conventions:**
| Type | Convention | Example |
|------|------------|---------|
| Classes | PascalCase | `OpenCodePlugin`, `ProcessManager` |
| Interfaces/Types | PascalCase | `OpenCodeSettings`, `ProcessState` |
| Constants | UPPER_CASE or camelCase | `DEFAULT_SETTINGS`, `OPENCODE_VIEW_TYPE` |
| Variables/functions | camelCase | `getVaultPath`, `startServer` |
| Private members | camelCase (no prefix) | `private processManager` |
| Files | PascalCase for classes | `ProcessManager.ts`, `OpenCodeView.ts` |
**TypeScript Patterns:**
- `strictNullChecks` enabled - always handle null/undefined
- Union types for state: `"stopped" | "starting" | "running" | "error"`
- `async/await` over raw Promises
- Explicit return types on public methods
### Architecture Patterns
**Project Structure:**
```
src/
├── main.ts # Plugin entry, extends Plugin
├── types.ts # Types and constants
├── OpenCodeView.ts # Sidebar view (ItemView) with iframe
├── ProcessManager.ts # Server process lifecycle
├── SettingsTab.ts # Settings UI (PluginSettingTab)
└── icons.ts # Custom icon registration
```
**Obsidian Patterns:**
- Extend `Plugin` with `onload()`/`onunload()` lifecycle
- Extend `ItemView` for views: `getViewType()`, `onOpen()`, `onClose()`
- Extend `PluginSettingTab` for settings: `display()`
- DOM helpers: `createEl()`, `createDiv()`, `setIcon()`
- Register in `onload()`, clean up in `onunload()`
**State Management:**
- Callback-based subscriptions for state changes
- Centralized state in manager classes (ProcessManager)
- Immediate notification on state change via callbacks
**Error Handling:**
- try/catch for async operations
- `console.error()` for debugging
- `new Notice()` for user-facing errors
- Boolean returns for success/failure
- Silent catch for non-critical ops (health checks)
### Testing Strategy
Not currently configured. If adding tests:
- Use Vitest for test runner
- Place tests in `src/__tests__/` or `*.test.ts` files
- Add ESLint with `@typescript-eslint/parser` for linting
### Git Workflow
Standard feature branch workflow. Commit messages should be concise and describe the change.
## Domain Context
**OpenCode Server:**
- OpenCode is an AI assistant CLI that can run as a web server
- The plugin spawns `opencode serve` with port/hostname/CORS flags
- Server health is checked via `/global/health` endpoint
- Project directory is encoded as base64 in the URL path
**Obsidian Integration:**
- View is registered with `OPENCODE_VIEW_TYPE` constant
- Opens in right sidebar by default
- Supports lazy start (starts server when view opens)
- Auto-start option available in settings
**Process States:**
- `stopped` - Server not running
- `starting` - Server spawn initiated, waiting for health check
- `running` - Server healthy and responding
- `error` - Server failed to start or crashed
## Important Constraints
**Desktop Only:**
- Uses Node.js APIs (`child_process.spawn()`) unavailable on mobile
- File system access via vault adapter requires desktop
- Check for desktop environment before adding mobile-incompatible features
**Build Output:**
- Must produce single `main.js` file (CommonJS format)
- External: `obsidian`, `electron`, CodeMirror modules, Node.js builtins
**TypeScript Config:**
- Target: ES6
- Module: ESNext (bundled to CJS)
- Strict null checks enabled
- No implicit any
## External Dependencies
**Runtime:**
- OpenCode CLI must be installed on user's system (configurable path)
- Default port: 14096, hostname: 127.0.0.1
**Development:**
- `obsidian` - Obsidian API types and runtime
- `@types/node` - Node.js type definitions
- `esbuild` - Build bundler
- `typescript` - Type checking
-35
View File
@@ -1,35 +0,0 @@
# OpenCode-Obsidian Plugin roadmap and ideas
## Roadmap
### 0.1
* Versioning and changelog
### 0.2
* Windows support
* 3-4 sample skills useful in a vault
### 0.3
* AGENTS.md initialization
### 1.0
* TBD
## Ideas
### Skills
This repository should contain a set of Agent Skills that can be enabled from the settings page.
### AGENTS.md initialization
OpenCode could be asked to generate initial AGENTS.md file for the vault, based on a template/prompt.
### "Native" Obsidian integration
Implement custom UI connecting to OpenCode server in order to have a seamless integration with Obsidian (clickable links, etc). This would be nice but also quite tedious (would need to re-implement large chunks of OC UI). Not sure if this is worth the effort.
Status: on-hold
@@ -1,150 +0,0 @@
## ADDED Requirements
### Requirement: Server Process Spawning
The plugin SHALL spawn the OpenCode server process using the configured executable path, port, and hostname when the user initiates server start.
#### Scenario: Successful server spawn
- **WHEN** the user starts the server
- **THEN** the plugin spawns `opencode serve --port <port> --hostname <hostname> --cors app://obsidian.md`
- **AND** the process runs with the vault directory as the working directory
### Requirement: Server Health Checking
The plugin SHALL verify server availability by polling a health endpoint during startup.
#### Scenario: Health check during startup
- **WHEN** the server process is spawned
- **THEN** the plugin polls `GET /global/health` every 500ms
- **AND** transitions to running state when the endpoint returns HTTP 200
- **AND** transitions to error state if 15 seconds elapse without success
#### Scenario: Existing server detected
- **WHEN** the user starts the server
- **AND** a server is already running on the configured port
- **THEN** the plugin reuses the existing server
- **AND** transitions directly to running state
### Requirement: Server Shutdown
The plugin SHALL gracefully terminate the server process when stopping.
#### Scenario: Graceful shutdown
- **WHEN** the user stops the server
- **THEN** the plugin sends SIGTERM to the process
- **AND** sends SIGKILL after 2 seconds if the process is still running
### Requirement: Process State Management
The plugin SHALL maintain a state machine with states: stopped, starting, running, and error.
#### Scenario: State transitions
- **WHEN** the server is not running
- **THEN** the state is `stopped`
- **WHEN** spawn is initiated
- **THEN** the state transitions to `starting`
- **WHEN** health check succeeds
- **THEN** the state transitions to `running`
- **WHEN** spawn fails or health check times out
- **THEN** the state transitions to `error`
### Requirement: Sidebar View Registration
The plugin SHALL register an ItemView that displays in either the Obsidian sidebar or main editor area based on user settings.
#### Scenario: View activation in sidebar
- **WHEN** the user clicks the ribbon icon or runs the toggle command
- **AND** the default view location is set to "sidebar"
- **THEN** the OpenCode view opens in the right sidebar
- **AND** if the view already exists, it is revealed
#### Scenario: View activation in main area
- **WHEN** the user clicks the ribbon icon or runs the toggle command
- **AND** the default view location is set to "main"
- **THEN** the OpenCode view opens as a new tab in the main editor area
- **AND** if the view already exists in any location, it is revealed
### Requirement: View State Rendering
The plugin SHALL render different UI content based on the current process state.
#### Scenario: Stopped state UI
- **WHEN** the process state is `stopped`
- **THEN** the view displays a "Start OpenCode" button
#### Scenario: Starting state UI
- **WHEN** the process state is `starting`
- **THEN** the view displays a loading spinner with "Starting OpenCode..." message
#### Scenario: Running state UI
- **WHEN** the process state is `running`
- **THEN** the view displays a header with controls and an iframe loading the server URL
#### Scenario: Error state UI
- **WHEN** the process state is `error`
- **THEN** the view displays an error message with "Retry" and "Open Settings" buttons
### Requirement: Iframe Controls
The plugin SHALL provide controls in the view header when the server is running.
#### Scenario: Header controls available
- **WHEN** the server is running
- **THEN** the header displays reload and stop buttons
- **AND** clicking reload refreshes the iframe
- **AND** clicking stop terminates the server
### Requirement: Lazy Server Start
The plugin SHALL start the server automatically when the view is opened if not already running.
#### Scenario: Auto-start on view open
- **WHEN** the user opens the OpenCode view
- **AND** the server is in `stopped` state
- **THEN** the plugin initiates server start
### Requirement: Settings Configuration
The plugin SHALL provide configurable settings for server port, hostname, executable path, project directory, auto-start behavior, and default view location.
#### Scenario: Settings persistence
- **WHEN** the user modifies settings
- **THEN** changes are persisted to plugin data
- **AND** the process manager is updated with new settings
#### Scenario: Default view location options
- **WHEN** the user configures default view location
- **THEN** the options available are "sidebar" and "main"
- **AND** the default value is "sidebar"
### Requirement: Project Directory Validation
The plugin SHALL validate the project directory setting and support tilde expansion.
#### Scenario: Valid absolute path
- **WHEN** the user enters an absolute path or path starting with ~
- **AND** the path exists and is a directory
- **THEN** the setting is saved with the expanded path
#### Scenario: Invalid path rejection
- **WHEN** the user enters a relative path or non-existent path
- **THEN** a notice is displayed explaining the error
- **AND** the setting is not saved
### Requirement: Project Directory Auto-Restart
The plugin SHALL restart the server when the project directory setting changes while running.
#### Scenario: Restart on directory change
- **WHEN** the user changes the project directory
- **AND** the server is currently running
- **THEN** the plugin stops and restarts the server with the new directory
### Requirement: Commands Registration
The plugin SHALL register commands for toggling the view and controlling the server.
#### Scenario: Toggle command
- **WHEN** the user runs "Toggle OpenCode panel" command or presses Mod+Shift+O
- **THEN** the view opens if closed, or closes if open
#### Scenario: Start and stop commands
- **WHEN** the user runs "Start OpenCode server" command
- **THEN** the server starts
- **WHEN** the user runs "Stop OpenCode server" command
- **THEN** the server stops
### Requirement: Ribbon Icon
The plugin SHALL add a ribbon icon that activates the OpenCode view in the configured default location.
#### Scenario: Ribbon icon click
- **WHEN** the user clicks the OpenCode ribbon icon
- **THEN** the OpenCode view is activated in the location specified by the default view location setting
-28
View File
@@ -1,28 +0,0 @@
{
"name": "obsidian-opencode",
"version": "0.1.0",
"description": "Embed OpenCode AI assistant in Obsidian",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "bun run tsc -noEmit -skipLibCheck && bun run esbuild.config.mjs production",
"test": "bun test"
},
"keywords": [
"obsidian",
"opencode",
"ai",
"assistant"
],
"author": "mat",
"license": "MIT",
"devDependencies": {
"@types/bun": "^1.3.5",
"@types/node": "^20.11.0",
"builtin-modules": "^3.3.0",
"esbuild": "^0.21.5",
"obsidian": "latest",
"tslib": "^2.6.2",
"typescript": "^5.4.5"
}
}
-228
View File
@@ -1,228 +0,0 @@
import { ItemView, WorkspaceLeaf, setIcon } from "obsidian";
import { OPENCODE_VIEW_TYPE } from "./types";
import { OPENCODE_ICON_NAME } from "./icons";
import type OpenCodePlugin from "./main";
import { ProcessState } from "./ProcessManager";
export class OpenCodeView extends ItemView {
plugin: OpenCodePlugin;
private iframeEl: HTMLIFrameElement | null = null;
private currentState: ProcessState = "stopped";
private unsubscribeStateChange: (() => void) | null = null;
constructor(leaf: WorkspaceLeaf, plugin: OpenCodePlugin) {
super(leaf);
this.plugin = plugin;
}
getViewType(): string {
return OPENCODE_VIEW_TYPE;
}
getDisplayText(): string {
return "OpenCode";
}
getIcon(): string {
return OPENCODE_ICON_NAME;
}
async onOpen(): Promise<void> {
this.contentEl.empty();
this.contentEl.addClass("opencode-container");
// Subscribe to state changes
this.unsubscribeStateChange = this.plugin.onProcessStateChange((state) => {
this.currentState = state;
this.updateView();
});
// Initial render
this.currentState = this.plugin.getProcessState();
this.updateView();
// Start server if not running (lazy start) - don't await to avoid blocking view open
if (this.currentState === "stopped") {
this.plugin.startServer();
}
}
async onClose(): Promise<void> {
// Unsubscribe from state changes to prevent memory leak
if (this.unsubscribeStateChange) {
this.unsubscribeStateChange();
this.unsubscribeStateChange = null;
}
// Clean up iframe
if (this.iframeEl) {
this.iframeEl.src = "about:blank";
this.iframeEl = null;
}
}
private updateView(): void {
switch (this.currentState) {
case "stopped":
this.renderStoppedState();
break;
case "starting":
this.renderStartingState();
break;
case "running":
this.renderRunningState();
break;
case "error":
this.renderErrorState();
break;
}
}
private renderStoppedState(): void {
this.contentEl.empty();
const statusContainer = this.contentEl.createDiv({
cls: "opencode-status-container",
});
const iconEl = statusContainer.createDiv({ cls: "opencode-status-icon" });
setIcon(iconEl, "power-off");
statusContainer.createEl("h3", { text: "OpenCode is stopped" });
statusContainer.createEl("p", {
text: "Click the button below to start the OpenCode server.",
cls: "opencode-status-message",
});
const startButton = statusContainer.createEl("button", {
text: "Start OpenCode",
cls: "mod-cta",
});
startButton.addEventListener("click", () => {
this.plugin.startServer();
});
}
private renderStartingState(): void {
this.contentEl.empty();
const statusContainer = this.contentEl.createDiv({
cls: "opencode-status-container",
});
const loadingEl = statusContainer.createDiv({ cls: "opencode-loading" });
loadingEl.createDiv({ cls: "opencode-spinner" });
statusContainer.createEl("h3", { text: "Starting OpenCode..." });
statusContainer.createEl("p", {
text: "Please wait while the server starts up.",
cls: "opencode-status-message",
});
}
private renderRunningState(): void {
this.contentEl.empty();
const headerEl = this.contentEl.createDiv({ cls: "opencode-header" });
const titleSection = headerEl.createDiv({ cls: "opencode-header-title" });
const iconEl = titleSection.createSpan();
setIcon(iconEl, OPENCODE_ICON_NAME);
titleSection.createSpan({ text: "OpenCode" });
const actionsEl = headerEl.createDiv({ cls: "opencode-header-actions" });
const reloadButton = actionsEl.createEl("button", {
attr: { "aria-label": "Reload" },
});
setIcon(reloadButton, "refresh-cw");
reloadButton.addEventListener("click", () => {
this.reloadIframe();
});
const stopButton = actionsEl.createEl("button", {
attr: { "aria-label": "Stop server" },
});
setIcon(stopButton, "square");
stopButton.addEventListener("click", () => {
this.plugin.stopServer();
});
const iframeContainer = this.contentEl.createDiv({
cls: "opencode-iframe-container",
});
console.log("[OpenCode] Loading iframe with URL:", this.plugin.getServerUrl());
this.iframeEl = iframeContainer.createEl("iframe", {
cls: "opencode-iframe",
attr: {
src: this.plugin.getServerUrl(),
frameborder: "0",
allow: "clipboard-read; clipboard-write",
},
});
this.iframeEl.addEventListener("error", () => {
console.error("Failed to load OpenCode iframe");
});
}
private renderErrorState(): void {
this.contentEl.empty();
const statusContainer = this.contentEl.createDiv({
cls: "opencode-status-container opencode-error",
});
const iconEl = statusContainer.createDiv({ cls: "opencode-status-icon" });
setIcon(iconEl, "alert-circle");
statusContainer.createEl("h3", { text: "Failed to start OpenCode" });
const errorMessage = this.plugin.getLastError();
if (errorMessage) {
statusContainer.createEl("p", {
text: errorMessage,
cls: "opencode-status-message opencode-error-message",
});
} else {
statusContainer.createEl("p", {
text: "There was an error starting the OpenCode server.",
cls: "opencode-status-message",
});
}
const buttonContainer = statusContainer.createDiv({
cls: "opencode-button-group",
});
const retryButton = buttonContainer.createEl("button", {
text: "Retry",
cls: "mod-cta",
});
retryButton.addEventListener("click", () => {
this.plugin.startServer();
});
const settingsButton = buttonContainer.createEl("button", {
text: "Open Settings",
});
settingsButton.addEventListener("click", () => {
(this.app as any).setting.open();
(this.app as any).setting.openTabById("obsidian-opencode");
});
}
private reloadIframe(): void {
if (this.iframeEl) {
const src = this.iframeEl.src;
this.iframeEl.src = "about:blank";
setTimeout(() => {
if (this.iframeEl) {
this.iframeEl.src = src;
}
}, 100);
}
}
}
@@ -1,215 +0,0 @@
import { spawn, ChildProcess } from "child_process";
import { OpenCodeSettings } from "./types";
export type ProcessState = "stopped" | "starting" | "running" | "error";
export class ProcessManager {
private process: ChildProcess | null = null;
private state: ProcessState = "stopped";
private lastError: string | null = null;
private earlyExitCode: number | null = null;
private settings: OpenCodeSettings;
private projectDirectory: string;
private onStateChange: (state: ProcessState) => void;
constructor(
settings: OpenCodeSettings,
projectDirectory: string,
onStateChange: (state: ProcessState) => void
) {
this.settings = settings;
this.projectDirectory = projectDirectory;
this.onStateChange = onStateChange;
}
updateSettings(settings: OpenCodeSettings): void {
this.settings = settings;
}
updateProjectDirectory(directory: string): void {
this.projectDirectory = directory;
}
getState(): ProcessState {
return this.state;
}
getLastError(): string | null {
return this.lastError;
}
getUrl(): string {
const encodedPath = btoa(this.projectDirectory);
return `http://${this.settings.hostname}:${this.settings.port}/${encodedPath}`;
}
async start(): Promise<boolean> {
if (this.state === "running" || this.state === "starting") {
return true;
}
this.setState("starting");
this.lastError = null;
this.earlyExitCode = null;
if (!this.projectDirectory) {
return this.setError("Project directory (vault) not configured");
}
if (await this.checkServerHealth()) {
console.log("[OpenCode] Server already running on port", this.settings.port);
this.setState("running");
return true;
}
console.log("[OpenCode] Starting server:", {
opencodePath: this.settings.opencodePath,
port: this.settings.port,
hostname: this.settings.hostname,
cwd: this.projectDirectory,
projectDirectory: this.projectDirectory,
});
this.process = spawn(
this.settings.opencodePath,
[
"serve",
"--port",
this.settings.port.toString(),
"--hostname",
this.settings.hostname,
"--cors",
"app://obsidian.md",
],
{
cwd: this.projectDirectory,
env: { ...process.env },
stdio: ["ignore", "pipe", "pipe"],
detached: false,
}
);
console.log("[OpenCode] Process spawned with PID:", this.process.pid);
this.process.stdout?.on("data", (data) => {
console.log("[OpenCode]", data.toString().trim());
});
this.process.stderr?.on("data", (data) => {
console.error("[OpenCode Error]", data.toString().trim());
});
this.process.on("exit", (code, signal) => {
console.log(`[OpenCode] Process exited with code ${code}, signal ${signal}`);
this.process = null;
if (this.state === "starting" && code !== null && code !== 0) {
this.earlyExitCode = code;
}
if (this.state === "running") {
this.setState("stopped");
}
});
this.process.on("error", (err: NodeJS.ErrnoException) => {
console.error("[OpenCode] Failed to start process:", err);
this.process = null;
if (err.code === "ENOENT") {
this.setError(`Executable not found at '${this.settings.opencodePath}'`);
} else {
this.setError(`Failed to start: ${err.message}`);
}
});
const ready = await this.waitForServerOrExit(this.settings.startupTimeout);
if (ready) {
this.setState("running");
return true;
}
if (this.state === "error") {
return false;
}
this.stop();
if (this.earlyExitCode !== null) {
return this.setError(`Process exited unexpectedly (exit code ${this.earlyExitCode})`);
}
if (!this.process) {
return this.setError("Process exited before server became ready");
}
return this.setError("Server failed to start within timeout");
}
stop(): void {
if (!this.process) {
this.setState("stopped");
return;
}
const proc = this.process;
console.log("[OpenCode] Stopping process with PID:", proc.pid);
this.setState("stopped");
this.process = null;
proc.kill("SIGTERM");
// Force kill after 2 seconds if still running
setTimeout(() => {
if (proc.exitCode === null && proc.signalCode === null) {
console.log("[OpenCode] Process still running, sending SIGKILL");
proc.kill("SIGKILL");
}
}, 2000);
}
private setState(state: ProcessState): void {
this.state = state;
this.onStateChange(state);
}
private setError(message: string): false {
this.lastError = message;
console.error("[OpenCode Error]", message);
this.setState("error");
return false;
}
private async checkServerHealth(): Promise<boolean> {
try {
const response = await fetch(`${this.getUrl()}/global/health`, {
method: "GET",
signal: AbortSignal.timeout(2000),
});
return response.ok;
} catch {
return false;
}
}
private async waitForServerOrExit(timeoutMs: number): Promise<boolean> {
const startTime = Date.now();
const pollInterval = 500;
while (Date.now() - startTime < timeoutMs) {
if (!this.process) {
console.log("[OpenCode] Process exited before server became ready");
return false;
}
if (await this.checkServerHealth()) {
return true;
}
await this.sleep(pollInterval);
}
return false;
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
-247
View File
@@ -1,247 +0,0 @@
import { App, PluginSettingTab, Setting, Notice } from "obsidian";
import { existsSync, statSync } from "fs";
import { homedir } from "os";
import type OpenCodePlugin from "./main";
import type { ViewLocation } from "./types";
function expandTilde(path: string): string {
if (path === "~") {
return homedir();
}
if (path.startsWith("~/")) {
return path.replace("~", homedir());
}
return path;
}
export class OpenCodeSettingTab extends PluginSettingTab {
plugin: OpenCodePlugin;
private validateTimeout: ReturnType<typeof setTimeout> | null = null;
constructor(app: App, plugin: OpenCodePlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl("h2", { text: "OpenCode Settings" });
containerEl.createEl("h3", { text: "Server Configuration" });
new Setting(containerEl)
.setName("Port")
.setDesc("Port number for the OpenCode web server")
.addText((text) =>
text
.setPlaceholder("14096")
.setValue(this.plugin.settings.port.toString())
.onChange(async (value) => {
const port = parseInt(value, 10);
if (!isNaN(port) && port > 0 && port < 65536) {
this.plugin.settings.port = port;
await this.plugin.saveSettings();
}
})
);
new Setting(containerEl)
.setName("Hostname")
.setDesc("Hostname to bind the server to (usually 127.0.0.1)")
.addText((text) =>
text
.setPlaceholder("127.0.0.1")
.setValue(this.plugin.settings.hostname)
.onChange(async (value) => {
this.plugin.settings.hostname = value || "127.0.0.1";
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("OpenCode path")
.setDesc(
"Path to the OpenCode executable. Leave as 'opencode' if it's in your PATH."
)
.addText((text) =>
text
.setPlaceholder("opencode")
.setValue(this.plugin.settings.opencodePath)
.onChange(async (value) => {
this.plugin.settings.opencodePath = value || "opencode";
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("Project directory")
.setDesc(
"Override the starting directory for OpenCode. Leave empty to use the vault root. Supports ~ for home directory."
)
.addText((text) =>
text
.setPlaceholder("/path/to/project or ~/project")
.setValue(this.plugin.settings.projectDirectory)
.onChange((value) => {
// Debounce validation to avoid spamming notices on every keypress
if (this.validateTimeout) {
clearTimeout(this.validateTimeout);
}
this.validateTimeout = setTimeout(async () => {
await this.validateAndSetProjectDirectory(value);
}, 500);
})
);
containerEl.createEl("h3", { text: "Behavior" });
new Setting(containerEl)
.setName("Auto-start server")
.setDesc(
"Automatically start the OpenCode server when Obsidian opens (not recommended for faster startup)"
)
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.autoStart)
.onChange(async (value) => {
this.plugin.settings.autoStart = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("Default view location")
.setDesc(
"Where to open the OpenCode panel: sidebar opens in the right panel, main opens as a tab in the editor area"
)
.addDropdown((dropdown) =>
dropdown
.addOption("sidebar", "Sidebar")
.addOption("main", "Main window")
.setValue(this.plugin.settings.defaultViewLocation)
.onChange(async (value) => {
this.plugin.settings.defaultViewLocation = value as ViewLocation;
await this.plugin.saveSettings();
})
);
containerEl.createEl("h3", { text: "Server Status" });
const statusContainer = containerEl.createDiv({ cls: "opencode-settings-status" });
this.renderServerStatus(statusContainer);
}
private async validateAndSetProjectDirectory(value: string): Promise<void> {
const trimmed = value.trim();
// Empty value is valid - means use vault root
if (!trimmed) {
await this.plugin.updateProjectDirectory("");
return;
}
// Validate absolute path (supports ~, /, and Windows drive letters)
if (!trimmed.startsWith("/") && !trimmed.startsWith("~") && !trimmed.match(/^[A-Za-z]:\\/)) {
new Notice("Project directory must be an absolute path (or start with ~)");
return;
}
const expanded = expandTilde(trimmed);
try {
if (!existsSync(expanded)) {
new Notice("Project directory does not exist");
return;
}
const stat = statSync(expanded);
if (!stat.isDirectory()) {
new Notice("Project directory path is not a directory");
return;
}
} catch (error) {
new Notice(`Failed to validate path: ${(error as Error).message}`);
return;
}
await this.plugin.updateProjectDirectory(expanded);
}
private renderServerStatus(container: HTMLElement): void {
container.empty();
const state = this.plugin.getProcessState();
const statusText = {
stopped: "Stopped",
starting: "Starting...",
running: "Running",
error: "Error",
};
const statusClass = {
stopped: "status-stopped",
starting: "status-starting",
running: "status-running",
error: "status-error",
};
const statusEl = container.createDiv({ cls: "opencode-status-line" });
statusEl.createSpan({ text: "Status: " });
statusEl.createSpan({
text: statusText[state],
cls: `opencode-status-badge ${statusClass[state]}`,
});
if (state === "running") {
const urlEl = container.createDiv({ cls: "opencode-status-line" });
urlEl.createSpan({ text: "URL: " });
const linkEl = urlEl.createEl("a", {
text: this.plugin.getServerUrl(),
href: this.plugin.getServerUrl(),
});
linkEl.addEventListener("click", (e) => {
e.preventDefault();
window.open(this.plugin.getServerUrl(), "_blank");
});
}
const buttonContainer = container.createDiv({ cls: "opencode-settings-buttons" });
if (state === "stopped" || state === "error") {
const startButton = buttonContainer.createEl("button", {
text: "Start Server",
cls: "mod-cta",
});
startButton.addEventListener("click", async () => {
await this.plugin.startServer();
this.renderServerStatus(container);
});
}
if (state === "running") {
const stopButton = buttonContainer.createEl("button", {
text: "Stop Server",
});
stopButton.addEventListener("click", () => {
this.plugin.stopServer();
this.renderServerStatus(container);
});
const restartButton = buttonContainer.createEl("button", {
text: "Restart Server",
cls: "mod-warning",
});
restartButton.addEventListener("click", async () => {
this.plugin.stopServer();
await this.plugin.startServer();
this.renderServerStatus(container);
});
}
if (state === "starting") {
buttonContainer.createSpan({
text: "Please wait...",
cls: "opencode-status-waiting",
});
}
}
}
-14
View File
@@ -1,14 +0,0 @@
import { addIcon } from "obsidian";
export const OPENCODE_ICON_NAME = "opencode-logo";
// Monochrome OpenCode "O" logo mark derived from the official brand assets
// Uses currentColor for theme compatibility
const OPENCODE_LOGO_SVG = `<svg viewBox="0 0 24 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 24H6V12H18V24Z" fill="currentColor" opacity="0.4"/>
<path d="M18 6H6V24H18V6ZM24 30H0V0H24V30Z" fill="currentColor"/>
</svg>`;
export function registerOpenCodeIcons(): void {
addIcon(OPENCODE_ICON_NAME, OPENCODE_LOGO_SVG);
}
-211
View File
@@ -1,211 +0,0 @@
import { Plugin, WorkspaceLeaf, Notice } from "obsidian";
import { OpenCodeSettings, DEFAULT_SETTINGS, OPENCODE_VIEW_TYPE } from "./types";
import { OpenCodeView } from "./OpenCodeView";
import { OpenCodeSettingTab } from "./SettingsTab";
import { ProcessManager, ProcessState } from "./ProcessManager";
import { registerOpenCodeIcons, OPENCODE_ICON_NAME } from "./icons";
export default class OpenCodePlugin extends Plugin {
settings: OpenCodeSettings = DEFAULT_SETTINGS;
private processManager: ProcessManager;
private stateChangeCallbacks: Array<(state: ProcessState) => void> = [];
async onload(): Promise<void> {
console.log("Loading OpenCode plugin");
registerOpenCodeIcons();
await this.loadSettings();
const projectDirectory = this.getProjectDirectory();
this.processManager = new ProcessManager(
this.settings,
projectDirectory,
(state) => this.notifyStateChange(state)
);
console.log("[OpenCode] Configured with project directory:", projectDirectory);
this.registerView(OPENCODE_VIEW_TYPE, (leaf) => new OpenCodeView(leaf, this));
this.addSettingTab(new OpenCodeSettingTab(this.app, this));
this.addRibbonIcon(OPENCODE_ICON_NAME, "OpenCode", () => {
this.activateView();
});
this.addCommand({
id: "toggle-opencode-view",
name: "Toggle OpenCode panel",
callback: () => {
this.toggleView();
},
hotkeys: [
{
modifiers: ["Mod", "Shift"],
key: "o",
},
],
});
this.addCommand({
id: "start-opencode-server",
name: "Start OpenCode server",
callback: () => {
this.startServer();
},
});
this.addCommand({
id: "stop-opencode-server",
name: "Stop OpenCode server",
callback: () => {
this.stopServer();
},
});
if (this.settings.autoStart) {
this.app.workspace.onLayoutReady(async () => {
await this.startServer();
});
}
console.log("OpenCode plugin loaded");
}
async onunload(): Promise<void> {
this.stopServer();
this.app.workspace.detachLeavesOfType(OPENCODE_VIEW_TYPE);
}
async loadSettings(): Promise<void> {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings(): Promise<void> {
await this.saveData(this.settings);
this.processManager.updateSettings(this.settings);
}
// Update project directory and restart server if running
async updateProjectDirectory(directory: string): Promise<void> {
this.settings.projectDirectory = directory;
await this.saveData(this.settings);
this.processManager.updateProjectDirectory(this.getProjectDirectory());
if (this.getProcessState() === "running") {
this.stopServer();
await this.startServer();
}
}
private getExistingLeaf(): WorkspaceLeaf | null {
const leaves = this.app.workspace.getLeavesOfType(OPENCODE_VIEW_TYPE);
return leaves.length > 0 ? leaves[0] : null;
}
async activateView(): Promise<void> {
const existingLeaf = this.getExistingLeaf();
if (existingLeaf) {
this.app.workspace.revealLeaf(existingLeaf);
return;
}
// Create new leaf based on defaultViewLocation setting
let leaf: WorkspaceLeaf | null = null;
if (this.settings.defaultViewLocation === "main") {
leaf = this.app.workspace.getLeaf("tab");
} else {
leaf = this.app.workspace.getRightLeaf(false);
}
if (leaf) {
await leaf.setViewState({
type: OPENCODE_VIEW_TYPE,
active: true,
});
this.app.workspace.revealLeaf(leaf);
}
}
async toggleView(): Promise<void> {
const existingLeaf = this.getExistingLeaf();
if (existingLeaf) {
// Check if the view is in the sidebar or main area
const isInSidebar = existingLeaf.getRoot() === this.app.workspace.rightSplit;
if (isInSidebar) {
// For sidebar views, check if sidebar is collapsed
const rightSplit = this.app.workspace.rightSplit;
if (rightSplit && !rightSplit.collapsed) {
existingLeaf.detach();
} else {
this.app.workspace.revealLeaf(existingLeaf);
}
} else {
// For main area views, just detach (close the tab)
existingLeaf.detach();
}
} else {
await this.activateView();
}
}
async startServer(): Promise<boolean> {
const success = await this.processManager.start();
if (success) {
new Notice("OpenCode server started");
}
return success;
}
stopServer(): void {
this.processManager.stop();
new Notice("OpenCode server stopped");
}
getProcessState(): ProcessState {
return this.processManager?.getState() ?? "stopped";
}
getLastError(): string | null {
return this.processManager.getLastError() ?? null;
}
getServerUrl(): string {
return this.processManager.getUrl();
}
onProcessStateChange(callback: (state: ProcessState) => void): () => void {
this.stateChangeCallbacks.push(callback);
return () => {
const index = this.stateChangeCallbacks.indexOf(callback);
if (index > -1) {
this.stateChangeCallbacks.splice(index, 1);
}
};
}
private notifyStateChange(state: ProcessState): void {
for (const callback of this.stateChangeCallbacks) {
callback(state);
}
}
getProjectDirectory(): string {
if (this.settings.projectDirectory) {
console.log("[OpenCode] Using project directory from settings:", this.settings.projectDirectory);
return this.settings.projectDirectory;
}
const adapter = this.app.vault.adapter as any;
const vaultPath = adapter.basePath || "";
if (!vaultPath) {
console.warn("[OpenCode] Warning: Could not determine vault path");
}
console.log("[OpenCode] Using vault path as project directory:", vaultPath);
return vaultPath;
}
}
-23
View File
@@ -1,23 +0,0 @@
export type ViewLocation = "sidebar" | "main";
export interface OpenCodeSettings {
port: number;
hostname: string;
autoStart: boolean;
opencodePath: string;
projectDirectory: string;
startupTimeout: number;
defaultViewLocation: ViewLocation;
}
export const DEFAULT_SETTINGS: OpenCodeSettings = {
port: 14096,
hostname: "127.0.0.1",
autoStart: false,
opencodePath: "opencode",
projectDirectory: "",
startupTimeout: 15000,
defaultViewLocation: "sidebar",
};
export const OPENCODE_VIEW_TYPE = "opencode-view";
@@ -1,219 +0,0 @@
import { describe, test, expect, beforeAll, afterEach } from "bun:test";
import { ProcessManager, ProcessState } from "../src/ProcessManager";
import { OpenCodeSettings } from "../src/types";
// Test configuration
const TEST_PORT_BASE = 15000;
const TEST_TIMEOUT_MS = 10000; // 10 seconds for server startup in tests
const PROJECT_DIR = process.cwd();
let currentPort = TEST_PORT_BASE;
function getNextPort(): number {
return currentPort++;
}
function createTestSettings(port: number): OpenCodeSettings {
return {
port,
hostname: "127.0.0.1",
autoStart: false,
opencodePath: "opencode",
projectDirectory: "",
startupTimeout: TEST_TIMEOUT_MS,
defaultViewLocation: "sidebar",
};
}
// Track current manager for cleanup
let currentManager: ProcessManager | null = null;
// Verify opencode binary is available before running tests
beforeAll(async () => {
const proc = Bun.spawn(["opencode", "--version"], {
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(
"opencode binary not found or not executable. " +
"Please ensure 'opencode' is installed and available in PATH."
);
}
});
// Cleanup after each test
afterEach(async () => {
if (currentManager) {
currentManager.stop();
// Give process time to fully terminate
await new Promise((resolve) => setTimeout(resolve, 500));
currentManager = null;
}
});
describe("ProcessManager", () => {
describe("happy path", () => {
test("starts server and transitions to running state", async () => {
const port = getNextPort();
const settings = createTestSettings(port);
const stateHistory: ProcessState[] = [];
currentManager = new ProcessManager(
settings,
PROJECT_DIR,
(state) => stateHistory.push(state)
);
expect(currentManager.getState()).toBe("stopped");
const success = await currentManager.start();
expect(success).toBe(true);
expect(currentManager.getState()).toBe("running");
expect(stateHistory).toContain("starting");
expect(stateHistory).toContain("running");
});
test("reports correct server URL with encoded project directory", async () => {
const port = getNextPort();
const settings = createTestSettings(port);
currentManager = new ProcessManager(
settings,
PROJECT_DIR,
() => {}
);
const url = currentManager.getUrl();
const expectedBase = `http://127.0.0.1:${port}`;
const expectedPath = btoa(PROJECT_DIR);
expect(url).toBe(`${expectedBase}/${expectedPath}`);
});
test("stops server gracefully and transitions to stopped state", async () => {
const port = getNextPort();
const settings = createTestSettings(port);
const stateHistory: ProcessState[] = [];
currentManager = new ProcessManager(
settings,
PROJECT_DIR,
(state) => stateHistory.push(state)
);
await currentManager.start();
expect(currentManager.getState()).toBe("running");
currentManager.stop();
expect(currentManager.getState()).toBe("stopped");
expect(stateHistory).toContain("stopped");
});
test("state callbacks fire in correct order: starting -> running", async () => {
const port = getNextPort();
const settings = createTestSettings(port);
const stateHistory: ProcessState[] = [];
currentManager = new ProcessManager(
settings,
PROJECT_DIR,
(state) => stateHistory.push(state)
);
await currentManager.start();
// Verify order: first starting, then running
const startingIndex = stateHistory.indexOf("starting");
const runningIndex = stateHistory.indexOf("running");
expect(startingIndex).toBeGreaterThanOrEqual(0);
expect(runningIndex).toBeGreaterThan(startingIndex);
});
test("can restart after stop", async () => {
const port = getNextPort();
const settings = createTestSettings(port);
currentManager = new ProcessManager(
settings,
PROJECT_DIR,
() => {}
);
// First start
const firstStart = await currentManager.start();
expect(firstStart).toBe(true);
expect(currentManager.getState()).toBe("running");
// Stop
currentManager.stop();
expect(currentManager.getState()).toBe("stopped");
// Wait for process to fully terminate
await new Promise((resolve) => setTimeout(resolve, 1000));
// Restart
const secondStart = await currentManager.start();
expect(secondStart).toBe(true);
expect(currentManager.getState()).toBe("running");
});
test("returns true immediately if already running", async () => {
const port = getNextPort();
const settings = createTestSettings(port);
currentManager = new ProcessManager(
settings,
PROJECT_DIR,
() => {}
);
// First start
await currentManager.start();
expect(currentManager.getState()).toBe("running");
// Second start should return true immediately without state changes
const stateHistory: ProcessState[] = [];
const originalOnStateChange = (currentManager as any).onStateChange;
(currentManager as any).onStateChange = (state: ProcessState) => {
stateHistory.push(state);
originalOnStateChange(state);
};
const result = await currentManager.start();
expect(result).toBe(true);
expect(currentManager.getState()).toBe("running");
// Should not have triggered any state changes
expect(stateHistory).toEqual([]);
});
test("health check endpoint is accessible when running", async () => {
const port = getNextPort();
const settings = createTestSettings(port);
currentManager = new ProcessManager(
settings,
PROJECT_DIR,
() => {}
);
await currentManager.start();
// Verify we can hit the health endpoint
const url = currentManager.getUrl();
const healthUrl = `${url}/global/health`;
const response = await fetch(healthUrl, {
signal: AbortSignal.timeout(2000),
});
expect(response.ok).toBe(true);
});
});
});
-24
View File
@@ -1,24 +0,0 @@
{
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES6",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"lib": [
"DOM",
"ES5",
"ES6",
"ES7"
]
},
"include": [
"src/**/*.ts"
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"pluginList": [
"mtymek/opencode-obsidian"
],
"pluginSubListFrozenVersion": [
{
"repo": "mtymek/opencode-obsidian",
"version": ""
}
],
"themesList": [],
"updateAtStartup": true,
"updateThemesAtStartup": true,
"enableAfterInstall": true,
"loggingEnabled": false,
"loggingPath": "BRAT-log",
"loggingVerboseEnabled": false,
"debuggingMode": false,
"notificationsEnabled": true,
"globalTokenName": "",
"personalAccessToken": "",
"selectLatestPluginVersionByDefault": false,
"allowIncompatiblePlugins": false
}
+14
View File
@@ -0,0 +1,14 @@
{
"port": 14096,
"hostname": "127.0.0.1",
"autoStart": false,
"opencodePath": "opencode",
"projectDirectory": "",
"startupTimeout": 45000,
"defaultViewLocation": "sidebar",
"injectWorkspaceContext": true,
"maxNotesInContext": 20,
"maxSelectionLength": 2000,
"customCommand": "",
"useCustomCommand": false
}
+1
View File
@@ -0,0 +1 @@
{"id":"opencode-obsidian","name":"OpenCode-Obsidian","version":"0.2.1","minAppVersion":"1.4.0","description":"Embed OpenCode AI assistant in Obsidian for AI-powered note management","author":"mtymek","isDesktopOnly":true}
@@ -191,3 +191,18 @@
color: var(--text-muted);
font-style: italic;
}
/* Error details in settings */
.opencode-error-details {
margin-top: 12px;
padding: 10px 12px;
background-color: var(--background-secondary);
border-radius: 6px;
border-left: 3px solid var(--text-error);
}
.opencode-error-text {
color: var(--text-error);
font-size: 0.9em;
line-height: 1.4;
}