Skip to content

Commit fbad9da

Browse files
Allow configuration of external opencode serve process (#295)
* feature: Allow configuration of external opencode serve process (rather than spawning automatically). This lets a developer connect to a containerized or other remote opencode server instance. --------- Co-authored-by: Francis Belanger <francis.belanger@gmail.com>
1 parent 3e890ae commit fbad9da

16 files changed

Lines changed: 1699 additions & 78 deletions

README.md

Lines changed: 190 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,15 +49,19 @@ Refer to the [Quick Chat](#-quick-chat) section for more details.
4949
- [Installation](#-installation)
5050
- [Configuration](#️-configuration)
5151
- [Usage](#-usage)
52+
- [Permissions](#-permissions)
5253
- [Context](#-context)
5354
- [Agents](#-agents)
54-
- [User Commands](#user-commands)
55+
- [Custom/External Server Configuration](#-customexternal-server-configuration)
56+
- [User Commands and Slash Commands](#user-commands-and-slash-commands)
5557
- [Contextual Actions for Snapshots](#-contextual-actions-for-snapshots)
56-
- [Prompt Guard](#-prompt-guard)
58+
- [Contextual Restore points](#-contextual-restore-points)
59+
- [Highlight Groups](#highlight-groups)
60+
- [Prompt Guard](#️-prompt-guard)
5761
- [Custom user hooks](#-custom-user-hooks)
5862
- [Server-Sent Events (SSE) autocmds](#-server-sent-events-sse-autocmds)
59-
- [Quick Chat](#-quick-chat)
60-
- [Setting up opencode](#-setting-up-opencode)
63+
- [Quick Chat](#quick-chat)
64+
- [Setting up Opencode](#-setting-up-opencode)
6165

6266
## ⚠️Caution
6367

@@ -120,6 +124,17 @@ require('opencode').setup({
120124
default_system_prompt = nil, -- Custom system prompt to use for all sessions. If nil, uses the default built-in system prompt
121125
keymap_prefix = '<leader>o', -- Default keymap prefix for global keymaps change to your preferred prefix and it will be applied to all keymaps starting with <leader>o
122126
opencode_executable = 'opencode', -- Name of your opencode binary
127+
128+
-- Server configuration for custom/external opencode servers
129+
server = {
130+
url = nil, -- URL/hostname (e.g., 'http://192.168.1.100', 'localhost', 'https://myserver.com')
131+
port = nil, -- Port number (e.g., 8080), 'auto' for random port
132+
timeout = 5, -- Health check timeout in seconds when connecting
133+
spawn_command = nil, -- Optional function to start the server: function(port, url) ... end
134+
auto_kill = true, -- Kill spawned servers when last nvim instance exits (default: true) Only applies to servers spawned by the plugin with spawn_command/kill_command
135+
path_map = nil, -- Map host paths to server paths: string ('/app') or function(path) -> string
136+
},
137+
123138
keymap = {
124139
editor = {
125140
['<leader>og'] = { 'toggle' }, -- Open opencode. Close if opened
@@ -757,6 +772,177 @@ You can create custom agents through your opencode config file. Each agent can h
757772

758773
See [Opencode Agents Documentation](https://opencode.ai/docs/agents/) for full configuration options.
759774

775+
## 🔌 Custom/External Server Configuration
776+
777+
By default, opencode.nvim spawns a local `opencode serve` process. You can instead connect to an external or containerized opencode server by configuring the `server` table.
778+
779+
### Basic Connection
780+
781+
Connect to an existing server:
782+
783+
```lua
784+
require('opencode').setup({
785+
server = {
786+
url = 'localhost', -- or 'http://192.168.1.100'
787+
port = 8080,
788+
timeout = 5,
789+
},
790+
})
791+
```
792+
793+
### Auto-Spawning with Docker
794+
795+
Use `spawn_command` to automatically start your server and `kill_command` to stop it:
796+
797+
```lua
798+
require('opencode').setup({
799+
server = {
800+
url = 'localhost',
801+
port = 'auto', -- Random port for project isolation
802+
-- Path mapping: translate host paths to container paths
803+
path_map = function(host_path)
804+
local cwd = vim.fn.getcwd()
805+
-- Replace host project directory with container mount point
806+
return host_path:gsub(vim.pesc(cwd), '/app')
807+
end,
808+
-- Spawn command: start Docker container with opencode server
809+
spawn_command = function(port, url)
810+
local dir_name = string.lower(vim.fn.fnamemodify(vim.fn.getcwd(), ":t"))
811+
local cwd = vim.fn.getcwd()
812+
local container_name = string.format('opencode-%s', dir_name)
813+
814+
-- Check if container is already running
815+
local check_cmd = string.format('docker ps --filter "name=%s" --format "{{.Names}}"', container_name)
816+
local handle = io.popen(check_cmd)
817+
local result = handle:read("*a")
818+
handle:close()
819+
820+
if result and result:match(container_name) then
821+
print(string.format("[opencode.nvim] Container %s is already running, skipping start", container_name))
822+
return true
823+
end
824+
825+
-- First, try to stop any existing container with the same name
826+
os.execute(string.format('docker stop %s 2>/dev/null || true', container_name))
827+
828+
local cmd = string.format([[
829+
docker run -d --rm \
830+
--name %s \
831+
-p %d:4096 \
832+
-v ~/.local/state/opencode:/home/node/.local/state/opencode \
833+
-v ~/.local/share/opencode:/home/node/.local/share/opencode \
834+
-v ~/.config/opencode:/home/node/.config/opencode \
835+
-v "%s":/app:rw \
836+
opencode:latest opencode serve --port 4096 --hostname '0.0.0.0']],
837+
container_name,
838+
port,
839+
cwd
840+
)
841+
842+
print(string.format("[opencode.nvim] Starting OpenCode container: %s on port %d", container_name, port))
843+
return os.execute(cmd)
844+
end,
845+
-- Kill command: stop Docker container when auto_kill is triggered
846+
kill_command = function(port, url)
847+
local dir_name = string.lower(vim.fn.fnamemodify(vim.fn.getcwd(), ":t"))
848+
local container_name = string.format('opencode-%s', dir_name)
849+
850+
print(string.format("[opencode.nvim] Stopping OpenCode container: %s", container_name))
851+
return os.execute(string.format('docker stop %s 2>/dev/null', container_name))
852+
end,
853+
auto_kill = true, -- Enable automatic cleanup when last nvim exits
854+
},
855+
})
856+
```
857+
858+
### Path Mapping for Containers/WSL
859+
860+
When paths on the server differ from your host (e.g., `/app` in container vs `/home/user/project` on host):
861+
862+
```lua
863+
require('opencode').setup({
864+
server = {
865+
url = 'localhost',
866+
port = 8080,
867+
path_map = '/app', -- Simple string replacement
868+
},
869+
})
870+
```
871+
872+
### Auto-Spawning with WSL
873+
874+
Run opencode server inside WSL while using Neovim on Windows:
875+
876+
```lua
877+
require('opencode').setup({
878+
server = {
879+
url = 'localhost',
880+
port = 'auto', -- Random port for project isolation
881+
882+
-- Spawn opencode server inside WSL
883+
spawn_command = function(port, url)
884+
local cmd = string.format(
885+
'wsl.exe -e bash -c "opencode serve --hostname 127.0.0.1 --port %d"',
886+
port
887+
)
888+
print(string.format('[opencode.nvim] Starting WSL server on port %d', port))
889+
return vim.fn.jobstart(cmd, { detach = 1 })
890+
end,
891+
892+
-- Kill WSL opencode process
893+
kill_command = function(port, url)
894+
print(string.format('[opencode.nvim] Stopping WSL server on port %d', port))
895+
vim.fn.jobstart('wsl.exe -e pkill -f "opencode serve.*--port ' .. port .. '"')
896+
end,
897+
898+
-- Windows → WSL path translation (for requests)
899+
path_map = function(host_path)
900+
if vim.fn.has('win32') == 1 then
901+
-- Convert C:\Users\... → /mnt/c/Users/...
902+
local drive, rest = host_path:match('^([A-Za-z]):(.*)$')
903+
if drive then
904+
local wsl_path = '/mnt/' .. drive:lower() .. rest:gsub('\\', '/')
905+
return wsl_path
906+
end
907+
end
908+
return host_path
909+
end,
910+
911+
-- WSL → Windows path translation (for responses)
912+
reverse_path_map = function(server_path)
913+
-- Convert /mnt/c/Users/... → C:\Users\...
914+
local drive, rest = server_path:match('^/mnt/([a-z])(.*)$')
915+
if drive then
916+
local windows_path = drive:upper() .. ':' .. rest:gsub('/', '\\')
917+
return windows_path
918+
end
919+
return server_path
920+
end,
921+
922+
auto_kill = true, -- Kill server when last nvim instance exits
923+
},
924+
})
925+
```
926+
927+
### Configuration Options
928+
929+
- `url` (string | nil): Server hostname/URL (e.g., 'localhost', 'http://192.168.1.100')
930+
- `port` (number | 'auto' | nil): Port number, `'auto'` for random port, or nil for default (4096)
931+
- `timeout` (number): Health check timeout in seconds (default: 5)
932+
- `spawn_command` (function | nil): Optional function to start server: `function(port, url) ... end`
933+
- `kill_command` (function | nil): Optional function to stop server when `auto_kill` triggers: `function(port, url) ... end`
934+
- `auto_kill` (boolean): Kill spawned servers when last nvim instance exits (default: true)
935+
- `path_map` (string | function | nil): Transform host paths to server paths (for outgoing requests)
936+
- `reverse_path_map` (function | nil): Transform server paths back to host paths (for incoming responses/events)
937+
938+
### Multi-Instance Support
939+
940+
When `port = 'auto'` is used, opencode.nvim:
941+
942+
- Tracks which nvim instances are using each port
943+
- Only kills the server when the last nvim instance exits (if `auto_kill = true`). Only applies to servers spawned by the plugin with `spawn_command`/`kill_command`.
944+
- Locally spawned servers will be killed automatically regardless of the auto_kill setting if they are the last nvim instance using them
945+
760946
## User Commands and Slash Commands
761947

762948
You can run predefined user commands and built-in slash commands from the input window by typing `/`. This opens a command picker where you can select a command to execute. The output of the command will be included in your prompt context.

lua/opencode/api_client.lua

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,29 @@
11
local server_job = require('opencode.server_job')
22
local state = require('opencode.state')
3+
local url_encode = require('opencode.util').url_encode
4+
local apply_path_map = require('opencode.util').apply_path_map
5+
local reverse_transform_paths_recursive = require('opencode.util').reverse_transform_paths_recursive
6+
7+
--- Transform file paths in API payloads using configured path_map
8+
--- @param data any The data to transform (table, string, or other)
9+
--- @return any transformed_data The data with paths transformed
10+
local function transform_paths_recursive(data)
11+
if type(data) ~= 'table' then
12+
return data
13+
end
14+
15+
local result = {}
16+
for key, value in pairs(data) do
17+
if type(value) == 'string' and (key == 'filePath' or key == 'path' or key == 'directory') then
18+
result[key] = apply_path_map(value)
19+
elseif type(value) == 'table' then
20+
result[key] = transform_paths_recursive(value)
21+
else
22+
result[key] = value
23+
end
24+
end
25+
return result
26+
end
327

428
--- @class OpencodeApiClient
529
--- @field base_url string The base URL of the opencode server
@@ -67,11 +91,13 @@ function OpencodeApiClient:_call(endpoint, method, body, query)
6791
query.directory = state.current_cwd or vim.fn.getcwd()
6892
end
6993

94+
query = transform_paths_recursive(query)
95+
7096
local params = {}
7197

7298
for k, v in pairs(query) do
7399
if v ~= nil then
74-
table.insert(params, k .. '=' .. tostring(v))
100+
table.insert(params, url_encode(k) .. '=' .. url_encode(v))
75101
end
76102
end
77103

@@ -80,7 +106,13 @@ function OpencodeApiClient:_call(endpoint, method, body, query)
80106
end
81107
end
82108

83-
return server_job.call_api(url, method, body)
109+
if body and type(body) == 'table' then
110+
body = transform_paths_recursive(body)
111+
end
112+
113+
return server_job.call_api(url, method, body):and_then(function(result)
114+
return reverse_transform_paths_recursive(result)
115+
end)
84116
end
85117

86118
-- Project endpoints
@@ -433,15 +465,16 @@ function OpencodeApiClient:subscribe_to_events(directory, on_event)
433465
self:_ensure_base_url()
434466
local url = self.base_url .. '/event'
435467
if directory then
436-
url = url .. '?directory=' .. directory
468+
local mapped_directory = apply_path_map(directory)
469+
url = url .. '?directory=' .. url_encode(mapped_directory)
437470
end
438471

439472
return server_job.stream_api(url, 'GET', nil, function(chunk)
440-
-- strip data: prefix if present
441473
chunk = chunk:gsub('^data:%s*', '')
442474
local ok, event = pcall(vim.json.decode, vim.trim(chunk))
443475
if ok and event then
444-
on_event(event --[[@as table]])
476+
local transformed_event = reverse_transform_paths_recursive(event)
477+
on_event(transformed_event --[[@as table]])
445478
end
446479
end)
447480
end

lua/opencode/config.lua

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,21 @@ M.defaults = {
1414
legacy_commands = true,
1515
keymap_prefix = '<leader>o',
1616
opencode_executable = 'opencode',
17+
server = {
18+
url = nil,
19+
port = nil,
20+
timeout = 5,
21+
retry_delay = 2000,
22+
spawn_command = nil,
23+
kill_command = nil,
24+
auto_kill = true,
25+
path_map = function(path)
26+
return path
27+
end,
28+
reverse_path_map = function(path)
29+
return path
30+
end,
31+
},
1732
keymap = {
1833
editor = {
1934
['<leader>og'] = { 'toggle', desc = 'Toggle Opencode window' },

lua/opencode/curl.lua

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ local function build_curl_args(opts)
2828
table.insert(args, opts.proxy)
2929
end
3030

31+
if opts.timeout then
32+
table.insert(args, '--max-time')
33+
table.insert(args, tostring(math.ceil(opts.timeout / 1000)))
34+
end
35+
3136
table.insert(args, opts.url)
3237

3338
return args
@@ -76,6 +81,9 @@ end
7681
function M.request(opts)
7782
local args = build_curl_args(opts)
7883

84+
local log = require('opencode.log')
85+
log.debug('curl.request: executing command: %s', table.concat(args, ' '))
86+
7987
if opts.stream then
8088
local buffer = ''
8189
-- job.pid is not cleared on process exit
@@ -157,7 +165,8 @@ function M.request(opts)
157165
vim.system(args, job_opts, function(result)
158166
if result.code ~= 0 then
159167
if opts.on_error then
160-
opts.on_error({ message = result.stderr or 'curl failed' })
168+
local err_msg = (result.stderr and result.stderr ~= '') and result.stderr or 'curl failed'
169+
opts.on_error({ message = err_msg })
161170
end
162171
return
163172
end

0 commit comments

Comments
 (0)