refactor: items 8-10, 12, 13 from code review

- Move parseRetryAfter to backend.go (where it's called)
- Extract paths.RuntimeDir() replacing 3 divergent XDG_RUNTIME_DIR impls
- Rename CodeWhispererBackend → KiroBackend, NewCodeWhisperer → NewKiro
- Export RPCRequest/RPCError/OutputNotification from toolsrv/rpcwire.go
- Factor tools/lsp/cmdutil: Run() and RunCustom() for 7 LSP binaries
This commit is contained in:
Ollie Agent 2026-08-09 11:32:31 +02:00
parent 9c90cb8954
commit 74635b8e00
19 changed files with 381 additions and 457 deletions

View File

@ -9,6 +9,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"strconv"
"strings" "strings"
"time" "time"
) )
@ -240,3 +241,22 @@ func isContextOverflow(body string) bool {
} }
return false return false
} }
// parseRetryAfter parses the Retry-After header value, which may be an integer
// number of seconds or an HTTP-date. Returns zero if the header is absent or
// unparseable.
func parseRetryAfter(header string) time.Duration {
if header == "" {
return 0
}
header = strings.TrimSpace(header)
if secs, err := strconv.Atoi(header); err == nil {
return time.Duration(secs) * time.Second
}
if t, err := http.ParseTime(header); err == nil {
if d := time.Until(t); d > 0 {
return d
}
}
return 0
}

View File

@ -1,12 +1,12 @@
package backend package backend
// CodeWhispererBackend implements Backend for Amazon CodeWhisperer / Kiro. // KiroBackend implements Backend for the Kiro streaming API.
// //
// COVERAGE: Intentionally untested. Reverse-engineered Kiro streaming API // COVERAGE: Intentionally untested. Reverse-engineered Kiro streaming API
// client requiring a live session to test. // client requiring a live session to test.
// //
// //
// Auth is configured via the apiKey parameter to NewCodeWhisperer: // Auth is configured via the apiKey parameter to NewKiro:
// - Empty string → read from Kiro CLI SQLite database at the default path // - Empty string → read from Kiro CLI SQLite database at the default path
// ($XDG_DATA_HOME/kiro-cli/data.sqlite3 on Linux). // ($XDG_DATA_HOME/kiro-cli/data.sqlite3 on Linux).
// - "sqlite:///path/to/data.sqlite3" → read from the specified SQLite file. // - "sqlite:///path/to/data.sqlite3" → read from the specified SQLite file.
@ -35,7 +35,7 @@ import (
// ── Public constructor ──────────────────────────────────────────────────────── // ── Public constructor ────────────────────────────────────────────────────────
type CodeWhispererBackend struct { type KiroBackend struct {
baseBackend baseBackend
endpoint string // overrides auth-derived endpoint if non-empty endpoint string // overrides auth-derived endpoint if non-empty
extraHeaders map[string]string extraHeaders map[string]string
@ -49,13 +49,13 @@ type CodeWhispererBackend struct {
modelsCacheAt time.Time modelsCacheAt time.Time
} }
// NewCodeWhisperer returns a CodeWhisperer backend. See package doc for apiKey semantics. // NewKiro returns a Kiro backend. See package doc for apiKey semantics.
func NewCodeWhisperer(apiKey string) (*CodeWhispererBackend, error) { func NewKiro(apiKey string) (*KiroBackend, error) {
if strings.TrimSpace(apiKey) == "" { if strings.TrimSpace(apiKey) == "" {
apiKey = kiroDefaultSQLiteKey() apiKey = kiroDefaultSQLiteKey()
} }
authSource, err := newKiroAuthSource(apiKey) authSource, err := newKiroAuthSource(apiKey)
b := &CodeWhispererBackend{ b := &KiroBackend{
baseBackend: baseBackend{name: "kiro"}, baseBackend: baseBackend{name: "kiro"},
authSource: authSource, authSource: authSource,
authInitErr: err, authInitErr: err,
@ -65,9 +65,9 @@ func NewCodeWhisperer(apiKey string) (*CodeWhispererBackend, error) {
return b, nil return b, nil
} }
func (b *CodeWhispererBackend) DefaultModel() string { return "auto" } func (b *KiroBackend) DefaultModel() string { return "auto" }
func (b *CodeWhispererBackend) Models(ctx context.Context) []string { func (b *KiroBackend) Models(ctx context.Context) []string {
b.modelsMu.Lock() b.modelsMu.Lock()
if b.modelsCache != nil && time.Since(b.modelsCacheAt) < 24*time.Hour { if b.modelsCache != nil && time.Since(b.modelsCacheAt) < 24*time.Hour {
result := b.modelsCache result := b.modelsCache
@ -92,7 +92,7 @@ func (b *CodeWhispererBackend) Models(ctx context.Context) []string {
return ids return ids
} }
func (b *CodeWhispererBackend) fetchModels(ctx context.Context) *kiroListModelsResponse { func (b *KiroBackend) fetchModels(ctx context.Context) *kiroListModelsResponse {
if b.authInitErr != nil { if b.authInitErr != nil {
return nil return nil
} }
@ -113,7 +113,7 @@ func (b *CodeWhispererBackend) fetchModels(ctx context.Context) *kiroListModelsR
return resp return resp
} }
func (b *CodeWhispererBackend) ContextLength(ctx context.Context) int { func (b *KiroBackend) ContextLength(ctx context.Context) int {
if b.ctxLen > 0 { if b.ctxLen > 0 {
return b.ctxLen return b.ctxLen
} }
@ -136,7 +136,7 @@ func (b *CodeWhispererBackend) ContextLength(ctx context.Context) int {
// ── Backend interface ───────────────────────────────────────────────────────── // ── Backend interface ─────────────────────────────────────────────────────────
func (b *CodeWhispererBackend) ChatStream( func (b *KiroBackend) ChatStream(
ctx context.Context, ctx context.Context,
messages []Message, messages []Message,
tools []Tool, tools []Tool,
@ -172,7 +172,7 @@ const kiroMaxThrottleRetries = 5
// Tests can override this. // Tests can override this.
var kiroThrottleBaseDelay = 5 * time.Second var kiroThrottleBaseDelay = 5 * time.Second
func (b *CodeWhispererBackend) runStream(ctx context.Context, req *kiroGenerateRequest, ch chan<- StreamEvent) { func (b *KiroBackend) runStream(ctx context.Context, req *kiroGenerateRequest, ch chan<- StreamEvent) {
var throttleAttempts int var throttleAttempts int
for { for {
token, err := b.authSource.AccessToken(ctx) token, err := b.authSource.AccessToken(ctx)
@ -258,14 +258,14 @@ func (b *CodeWhispererBackend) runStream(ctx context.Context, req *kiroGenerateR
} }
} }
func (b *CodeWhispererBackend) resolveEndpoint(ctx context.Context) (string, error) { func (b *KiroBackend) resolveEndpoint(ctx context.Context) (string, error) {
if ep := strings.TrimSpace(b.endpoint); ep != "" { if ep := strings.TrimSpace(b.endpoint); ep != "" {
return ep, nil return ep, nil
} }
return b.authSource.DefaultEndpoint(ctx) return b.authSource.DefaultEndpoint(ctx)
} }
func (b *CodeWhispererBackend) shouldRefresh(err error) bool { func (b *KiroBackend) shouldRefresh(err error) bool {
if !b.authSource.CanRefresh() { if !b.authSource.CanRefresh() {
return false return false
} }

View File

@ -1,6 +1,6 @@
package backend package backend
// Low-level plumbing for the Amazon CodeWhisperer / Kiro backend. // Low-level plumbing for the Kiro backend.
// COVERAGE: Intentionally untested. See codewhisperer.go. // COVERAGE: Intentionally untested. See codewhisperer.go.
// //
// - Wire types for the GenerateAssistantResponse API // - Wire types for the GenerateAssistantResponse API

View File

@ -95,7 +95,7 @@ func newBackendFromConfig(which string, cfg configFile) (Backend, error) {
if token == "" { if token == "" {
token = os.Getenv("OLLIE_KIRO_TOKEN") token = os.Getenv("OLLIE_KIRO_TOKEN")
} }
b, err = NewCodeWhisperer(token) b, err = NewKiro(token)
case "gemini": case "gemini":
key := bc.Key key := bc.Key

View File

@ -9,9 +9,7 @@ import (
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"strconv"
"strings" "strings"
"time"
) )
// OpenAIBackend speaks the OpenAI /v1/chat/completions wire format. // OpenAIBackend speaks the OpenAI /v1/chat/completions wire format.
@ -549,21 +547,3 @@ func (b *OpenAIBackend) ChatStream(ctx context.Context, messages []Message, tool
return streamRequest(b.client, httpReq, "openai", streamOpenAISSE) return streamRequest(b.client, httpReq, "openai", streamOpenAISSE)
} }
// parseRetryAfter parses the Retry-After header value, which may be an integer
// number of seconds or an HTTP-date. Returns zero if the header is absent or
// unparseable.
func parseRetryAfter(header string) time.Duration {
if header == "" {
return 0
}
header = strings.TrimSpace(header)
if secs, err := strconv.Atoi(header); err == nil {
return time.Duration(secs) * time.Second
}
if t, err := http.ParseTime(header); err == nil {
if d := time.Until(t); d > 0 {
return d
}
}
return 0
}

View File

@ -158,30 +158,14 @@ func serveSocket(ctx context.Context, srv *toolsrv.Server, sockPath string) {
wg.Wait() wg.Wait()
} }
type rpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}
type rpcResponse struct { type rpcResponse struct {
JSONRPC string `json:"jsonrpc"` JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"` ID json.RawMessage `json:"id"`
Result any `json:"result,omitempty"` Result any `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"` Error *toolsrv.RPCError `json:"error,omitempty"`
Stream bool `json:"stream,omitempty"` Stream bool `json:"stream,omitempty"`
} }
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
type outputNotif struct {
Data string `json:"data"`
}
type lockedEncoder struct { type lockedEncoder struct {
mu sync.Mutex mu sync.Mutex
enc *json.Encoder enc *json.Encoder
@ -198,7 +182,7 @@ func serveRPC(ctx context.Context, srv *toolsrv.Server, in io.Reader, out io.Wri
enc := &lockedEncoder{enc: json.NewEncoder(out)} enc := &lockedEncoder{enc: json.NewEncoder(out)}
for { for {
var req rpcRequest var req toolsrv.RPCRequest
if err := dec.Decode(&req); err != nil { if err := dec.Decode(&req); err != nil {
if err == io.EOF || ctx.Err() != nil { if err == io.EOF || ctx.Err() != nil {
return return
@ -206,7 +190,7 @@ func serveRPC(ctx context.Context, srv *toolsrv.Server, in io.Reader, out io.Wri
enc.Encode(rpcResponse{ enc.Encode(rpcResponse{
JSONRPC: "2.0", JSONRPC: "2.0",
ID: req.ID, ID: req.ID,
Error: &rpcError{Code: -32700, Message: "parse error: " + err.Error()}, Error: &toolsrv.RPCError{Code: -32700, Message: "parse error: " + err.Error()},
}) })
continue continue
} }
@ -215,7 +199,7 @@ func serveRPC(ctx context.Context, srv *toolsrv.Server, in io.Reader, out io.Wri
} }
} }
func handleRPC(ctx context.Context, srv *toolsrv.Server, req rpcRequest, enc *lockedEncoder) { func handleRPC(ctx context.Context, srv *toolsrv.Server, req toolsrv.RPCRequest, enc *lockedEncoder) {
switch req.Method { switch req.Method {
case "list_tools": case "list_tools":
tools, err := srv.ListTools() tools, err := srv.ListTools()
@ -223,7 +207,7 @@ func handleRPC(ctx context.Context, srv *toolsrv.Server, req rpcRequest, enc *lo
enc.Encode(rpcResponse{ enc.Encode(rpcResponse{
JSONRPC: "2.0", JSONRPC: "2.0",
ID: req.ID, ID: req.ID,
Error: &rpcError{Code: -32000, Message: err.Error()}, Error: &toolsrv.RPCError{Code: -32000, Message: err.Error()},
}) })
} else { } else {
enc.Encode(rpcResponse{ enc.Encode(rpcResponse{
@ -258,7 +242,7 @@ func handleRPC(ctx context.Context, srv *toolsrv.Server, req rpcRequest, enc *lo
Value string `json:"value"` Value string `json:"value"`
} }
if err := json.Unmarshal(req.Params, &params); err != nil { if err := json.Unmarshal(req.Params, &params); err != nil {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}}) enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &toolsrv.RPCError{Code: -32602, Message: err.Error()}})
return return
} }
srv.SetEnv(params.Key, params.Value) srv.SetEnv(params.Key, params.Value)
@ -269,7 +253,7 @@ func handleRPC(ctx context.Context, srv *toolsrv.Server, req rpcRequest, enc *lo
Dir string `json:"dir"` Dir string `json:"dir"`
} }
if err := json.Unmarshal(req.Params, &params); err != nil { if err := json.Unmarshal(req.Params, &params); err != nil {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}}) enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &toolsrv.RPCError{Code: -32602, Message: err.Error()}})
return return
} }
srv.SetCWD(params.Dir) srv.SetCWD(params.Dir)
@ -280,7 +264,7 @@ func handleRPC(ctx context.Context, srv *toolsrv.Server, req rpcRequest, enc *lo
Names []string `json:"names"` Names []string `json:"names"`
} }
if err := json.Unmarshal(req.Params, &params); err != nil { if err := json.Unmarshal(req.Params, &params); err != nil {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}}) enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &toolsrv.RPCError{Code: -32602, Message: err.Error()}})
return return
} }
srv.SetAllowTools(params.Names) srv.SetAllowTools(params.Names)
@ -299,11 +283,11 @@ func handleRPC(ctx context.Context, srv *toolsrv.Server, req rpcRequest, enc *lo
Signal int `json:"signal"` Signal int `json:"signal"`
} }
if err := json.Unmarshal(req.Params, &params); err != nil { if err := json.Unmarshal(req.Params, &params); err != nil {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}}) enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &toolsrv.RPCError{Code: -32602, Message: err.Error()}})
return return
} }
if err := srv.SignalDetached(params.PID, syscall.Signal(params.Signal)); err != nil { if err := srv.SignalDetached(params.PID, syscall.Signal(params.Signal)); err != nil {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32000, Message: err.Error()}}) enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &toolsrv.RPCError{Code: -32000, Message: err.Error()}})
} else { } else {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: true}) enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: true})
} }
@ -313,12 +297,12 @@ func handleRPC(ctx context.Context, srv *toolsrv.Server, req rpcRequest, enc *lo
PID int `json:"pid"` PID int `json:"pid"`
} }
if err := json.Unmarshal(req.Params, &params); err != nil { if err := json.Unmarshal(req.Params, &params); err != nil {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}}) enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &toolsrv.RPCError{Code: -32602, Message: err.Error()}})
return return
} }
out, err := srv.GetDetachedOutput(params.PID) out, err := srv.GetDetachedOutput(params.PID)
if err != nil { if err != nil {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32000, Message: err.Error()}}) enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &toolsrv.RPCError{Code: -32000, Message: err.Error()}})
} else { } else {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: out}) enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: out})
} }
@ -328,7 +312,7 @@ func handleRPC(ctx context.Context, srv *toolsrv.Server, req rpcRequest, enc *lo
PID int `json:"pid"` PID int `json:"pid"`
} }
if err := json.Unmarshal(req.Params, &params); err != nil { if err := json.Unmarshal(req.Params, &params); err != nil {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}}) enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &toolsrv.RPCError{Code: -32602, Message: err.Error()}})
return return
} }
ok := srv.DismissDetached(params.PID) ok := srv.DismissDetached(params.PID)
@ -339,11 +323,11 @@ func handleRPC(ctx context.Context, srv *toolsrv.Server, req rpcRequest, enc *lo
Name string `json:"name"` Name string `json:"name"`
} }
if err := json.Unmarshal(req.Params, &params); err != nil { if err := json.Unmarshal(req.Params, &params); err != nil {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}}) enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &toolsrv.RPCError{Code: -32602, Message: err.Error()}})
return return
} }
if err := srv.LoadTool(params.Name); err != nil { if err := srv.LoadTool(params.Name); err != nil {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32000, Message: err.Error()}}) enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &toolsrv.RPCError{Code: -32000, Message: err.Error()}})
} else { } else {
res, _ := json.Marshal(map[string]string{"result": "loaded: " + params.Name}) res, _ := json.Marshal(map[string]string{"result": "loaded: " + params.Name})
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: json.RawMessage(res)}) enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: json.RawMessage(res)})
@ -354,7 +338,7 @@ func handleRPC(ctx context.Context, srv *toolsrv.Server, req rpcRequest, enc *lo
enc.Encode(rpcResponse{ enc.Encode(rpcResponse{
JSONRPC: "2.0", JSONRPC: "2.0",
ID: req.ID, ID: req.ID,
Result: outputNotif{Data: data}, Result: toolsrv.OutputNotification{Data: data},
Stream: true, Stream: true,
}) })
}) })
@ -363,7 +347,7 @@ func handleRPC(ctx context.Context, srv *toolsrv.Server, req rpcRequest, enc *lo
enc.Encode(rpcResponse{ enc.Encode(rpcResponse{
JSONRPC: "2.0", JSONRPC: "2.0",
ID: req.ID, ID: req.ID,
Error: &rpcError{Code: -32000, Message: err.Error()}, Error: &toolsrv.RPCError{Code: -32000, Message: err.Error()},
}) })
} else { } else {
enc.Encode(rpcResponse{ enc.Encode(rpcResponse{

View File

@ -84,11 +84,7 @@ func runServer(sockPath string) {
// Server creation deferred until after bypass broker is ready (see below) // Server creation deferred until after bypass broker is ready (see below)
// Start bypass broker // Start bypass broker
xdg := os.Getenv("XDG_RUNTIME_DIR") bypassSocket := filepath.Join(paths.RuntimeDir(), "ollie", "bypass.sock")
if xdg == "" {
xdg = fmt.Sprintf("/run/user/%d", os.Getuid())
}
bypassSocket := filepath.Join(xdg, "ollie", "bypass.sock")
os.Setenv("OLLIE_BYPASS_SOCKET", bypassSocket) //nolint:errcheck os.Setenv("OLLIE_BYPASS_SOCKET", bypassSocket) //nolint:errcheck
policyPath := filepath.Join(paths.DataDir(), "bypass-policy.yaml") policyPath := filepath.Join(paths.DataDir(), "bypass-policy.yaml")

View File

@ -1,6 +1,7 @@
package paths package paths
import ( import (
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@ -16,6 +17,14 @@ func ExpandHome(path string) string {
return path return path
} }
// RuntimeDir returns $XDG_RUNTIME_DIR or the standard fallback /run/user/UID.
func RuntimeDir() string {
if d := os.Getenv("XDG_RUNTIME_DIR"); d != "" {
return d
}
return fmt.Sprintf("/run/user/%d", os.Getuid())
}
// xdgConfigHome returns $XDG_CONFIG_HOME or ~/.config. // xdgConfigHome returns $XDG_CONFIG_HOME or ~/.config.
func xdgConfigHome() string { func xdgConfigHome() string {
if p := os.Getenv("XDG_CONFIG_HOME"); p != "" { if p := os.Getenv("XDG_CONFIG_HOME"); p != "" {

View File

@ -1,8 +1,9 @@
package lsp package lsp
import ( import (
"os"
"path/filepath" "path/filepath"
"ollie/paths"
) )
// Adapter describes an LSP server for a specific language. // Adapter describes an LSP server for a specific language.
@ -108,11 +109,7 @@ func LanguageID(path string) string {
// SockPath returns the path to the bridge Unix socket. // SockPath returns the path to the bridge Unix socket.
func SockPath() string { func SockPath() string {
xdg := os.Getenv("XDG_RUNTIME_DIR") return filepath.Join(paths.RuntimeDir(), "ollie-lsp.sock")
if xdg == "" {
xdg = "/tmp"
}
return filepath.Join(xdg, "ollie-lsp.sock")
} }
// PidPath returns the path to the bridge PID file. // PidPath returns the path to the bridge PID file.

View File

@ -4,69 +4,32 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os"
"path/filepath"
"strconv"
"ollie/tools/lsp" "ollie/tools/lsp/cmdutil"
) )
func main() { func main() {
if len(os.Args) > 1 && os.Args[1] == "--bridge" { cmdutil.Run("completion", func(result json.RawMessage) {
lsp.RunBridge() var list struct {
return Items []completionItem `json:"items"`
} }
if json.Unmarshal(result, &list) == nil && len(list.Items) > 0 {
for _, item := range list.Items {
printItem(item)
}
return
}
var args struct { var items []completionItem
Path string `json:"path"` if json.Unmarshal(result, &items) == nil {
Line string `json:"line"` for _, item := range items {
Col string `json:"col"` printItem(item)
} }
if err := json.NewDecoder(os.Stdin).Decode(&args); err != nil { return
fmt.Fprintf(os.Stderr, "error: %v\n", err) }
os.Exit(1)
}
file, _ := filepath.Abs(args.Path)
line, _ := strconv.Atoi(args.Line)
col, _ := strconv.Atoi(args.Col)
result, err := lsp.ClientRequest(&lsp.BridgeRequest{
Method: "completion",
File: file,
Line: line - 1,
Col: col - 1,
})
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if result == nil || string(result) == "null" {
fmt.Println("(no completions)") fmt.Println("(no completions)")
return })
}
// CompletionList or []CompletionItem
var list struct {
Items []completionItem `json:"items"`
}
if json.Unmarshal(result, &list) == nil && len(list.Items) > 0 {
for _, item := range list.Items {
printItem(item)
}
return
}
var items []completionItem
if json.Unmarshal(result, &items) == nil {
for _, item := range items {
printItem(item)
}
return
}
fmt.Println("(no completions)")
} }
func printItem(item completionItem) { func printItem(item completionItem) {

View File

@ -4,91 +4,50 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os"
"path/filepath"
"strconv"
"ollie/tools/lsp" "ollie/tools/lsp"
"ollie/tools/lsp/cmdutil"
) )
func main() { func main() {
if len(os.Args) > 1 && os.Args[1] == "--bridge" { cmdutil.Run("definition", func(result json.RawMessage) {
if err := lsp.RunBridge(); err != nil { var locations []struct {
fmt.Fprintf(os.Stderr, "bridge: %v\n", err)
os.Exit(1)
}
return
}
var args struct {
Path string `json:"path"`
Line string `json:"line"`
Col string `json:"col"`
}
if err := json.NewDecoder(os.Stdin).Decode(&args); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
file, _ := filepath.Abs(args.Path)
line, _ := strconv.Atoi(args.Line)
col, _ := strconv.Atoi(args.Col)
result, err := lsp.ClientRequest(&lsp.BridgeRequest{
Method: "definition",
File: file,
Line: line - 1, // 1-indexed → 0-indexed
Col: col - 1, // 1-indexed → 0-indexed
})
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if result == nil || string(result) == "null" {
fmt.Println("(no definition found)")
return
}
// Parse location(s)
var locations []struct {
URI string `json:"uri"`
TargetURI string `json:"targetUri"`
Range *rang `json:"range"`
TargetRange *rang `json:"targetRange"`
}
// Try as array first
if err := json.Unmarshal(result, &locations); err != nil {
// Try as single location
var single struct {
URI string `json:"uri"` URI string `json:"uri"`
TargetURI string `json:"targetUri"` TargetURI string `json:"targetUri"`
Range *rang `json:"range"` Range *rang `json:"range"`
TargetRange *rang `json:"targetRange"` TargetRange *rang `json:"targetRange"`
} }
if err := json.Unmarshal(result, &single); err != nil { if err := json.Unmarshal(result, &locations); err != nil {
fmt.Println("(no definition found)") var single struct {
return URI string `json:"uri"`
TargetURI string `json:"targetUri"`
Range *rang `json:"range"`
TargetRange *rang `json:"targetRange"`
}
if err := json.Unmarshal(result, &single); err != nil {
fmt.Println("(no definition found)")
return
}
locations = append(locations, single)
} }
locations = append(locations, single)
}
for _, loc := range locations { for _, loc := range locations {
uri := loc.URI uri := loc.URI
if uri == "" { if uri == "" {
uri = loc.TargetURI uri = loc.TargetURI
}
path := lsp.URIToPath(uri)
r := loc.Range
if r == nil {
r = loc.TargetRange
}
if r == nil {
fmt.Println(path)
continue
}
fmt.Printf("%s:%d:%d\n", path, r.Start.Line+1, r.Start.Character+1)
} }
path := lsp.URIToPath(uri) })
r := loc.Range
if r == nil {
r = loc.TargetRange
}
if r == nil {
fmt.Println(path)
continue
}
fmt.Printf("%s:%d:%d\n", path, r.Start.Line+1, r.Start.Character+1)
}
} }
type rang struct { type rang struct {

View File

@ -8,50 +8,48 @@ import (
"path/filepath" "path/filepath"
"ollie/tools/lsp" "ollie/tools/lsp"
"ollie/tools/lsp/cmdutil"
) )
func main() { func main() {
if len(os.Args) > 1 && os.Args[1] == "--bridge" { cmdutil.RunCustom(func() {
lsp.RunBridge() var args struct {
return Path string `json:"path"`
} }
if err := json.NewDecoder(os.Stdin).Decode(&args); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
var args struct { file, _ := filepath.Abs(args.Path)
Path string `json:"path"`
}
if err := json.NewDecoder(os.Stdin).Decode(&args); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
file, _ := filepath.Abs(args.Path) result, err := lsp.ClientRequest(&lsp.BridgeRequest{
Method: "diagnostics",
File: file,
})
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
result, err := lsp.ClientRequest(&lsp.BridgeRequest{ if result == nil || string(result) == "null" || string(result) == "[]" {
Method: "diagnostics", fmt.Println("(no diagnostics)")
File: file, return
}
var diags []diagnostic
if err := json.Unmarshal(result, &diags); err != nil {
fmt.Println("(no diagnostics)")
return
}
for _, d := range diags {
sev := severity(d.Severity)
line := d.Range.Start.Line + 1
col := d.Range.Start.Character + 1
fmt.Printf("%s:%d:%d: %s: %s\n", file, line, col, sev, d.Message)
}
}) })
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if result == nil || string(result) == "null" || string(result) == "[]" {
fmt.Println("(no diagnostics)")
return
}
var diags []diagnostic
if err := json.Unmarshal(result, &diags); err != nil {
fmt.Println("(no diagnostics)")
return
}
for _, d := range diags {
sev := severity(d.Severity)
line := d.Range.Start.Line + 1
col := d.Range.Start.Character + 1
fmt.Printf("%s:%d:%d: %s: %s\n", file, line, col, sev, d.Message)
}
} }
type diagnostic struct { type diagnostic struct {

View File

@ -4,66 +4,29 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os"
"path/filepath"
"strconv"
"ollie/tools/lsp" "ollie/tools/lsp/cmdutil"
) )
func main() { func main() {
if len(os.Args) > 1 && os.Args[1] == "--bridge" { cmdutil.Run("hover", func(result json.RawMessage) {
lsp.RunBridge() var hover struct {
return Contents struct {
} Kind string `json:"kind"`
Value string `json:"value"`
var args struct { } `json:"contents"`
Path string `json:"path"`
Line string `json:"line"`
Col string `json:"col"`
}
if err := json.NewDecoder(os.Stdin).Decode(&args); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
file, _ := filepath.Abs(args.Path)
line, _ := strconv.Atoi(args.Line)
col, _ := strconv.Atoi(args.Col)
result, err := lsp.ClientRequest(&lsp.BridgeRequest{
Method: "hover",
File: file,
Line: line - 1,
Col: col - 1,
})
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if result == nil || string(result) == "null" {
fmt.Println("(no hover information)")
return
}
var hover struct {
Contents struct {
Kind string `json:"kind"`
Value string `json:"value"`
} `json:"contents"`
}
if err := json.Unmarshal(result, &hover); err != nil {
// Try as plain string
var plain struct {
Contents string `json:"contents"`
} }
if json.Unmarshal(result, &plain) == nil { if err := json.Unmarshal(result, &hover); err != nil {
fmt.Println(plain.Contents) var plain struct {
Contents string `json:"contents"`
}
if json.Unmarshal(result, &plain) == nil {
fmt.Println(plain.Contents)
return
}
fmt.Println("(no hover information)")
return return
} }
fmt.Println("(no hover information)") fmt.Println(hover.Contents.Value)
return })
}
fmt.Println(hover.Contents.Value)
} }

View File

@ -4,66 +4,31 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os"
"path/filepath"
"strconv"
"ollie/tools/lsp" "ollie/tools/lsp"
"ollie/tools/lsp/cmdutil"
) )
func main() { func main() {
if len(os.Args) > 1 && os.Args[1] == "--bridge" { cmdutil.Run("references", func(result json.RawMessage) {
lsp.RunBridge() var locations []struct {
return URI string `json:"uri"`
} Range struct {
Start struct {
Line int `json:"line"`
Character int `json:"character"`
} `json:"start"`
} `json:"range"`
}
if err := json.Unmarshal(result, &locations); err != nil {
fmt.Println("(no references found)")
return
}
var args struct { fmt.Printf("STATUS=ok MATCH_COUNT=%d\n", len(locations))
Path string `json:"path"` for _, loc := range locations {
Line string `json:"line"` path := lsp.URIToPath(loc.URI)
Col string `json:"col"` fmt.Printf("%s:%d:%d\n", path, loc.Range.Start.Line+1, loc.Range.Start.Character+1)
} }
if err := json.NewDecoder(os.Stdin).Decode(&args); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
file, _ := filepath.Abs(args.Path)
line, _ := strconv.Atoi(args.Line)
col, _ := strconv.Atoi(args.Col)
result, err := lsp.ClientRequest(&lsp.BridgeRequest{
Method: "references",
File: file,
Line: line - 1,
Col: col - 1,
}) })
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if result == nil || string(result) == "null" {
fmt.Println("(no references found)")
return
}
var locations []struct {
URI string `json:"uri"`
Range struct {
Start struct {
Line int `json:"line"`
Character int `json:"character"`
} `json:"start"`
} `json:"range"`
}
if err := json.Unmarshal(result, &locations); err != nil {
fmt.Println("(no references found)")
return
}
fmt.Printf("STATUS=ok MATCH_COUNT=%d\n", len(locations))
for _, loc := range locations {
path := lsp.URIToPath(loc.URI)
fmt.Printf("%s:%d:%d\n", path, loc.Range.Start.Line+1, loc.Range.Start.Character+1)
}
} }

View File

@ -11,53 +11,50 @@ import (
"strings" "strings"
"ollie/tools/lsp" "ollie/tools/lsp"
"ollie/tools/lsp/cmdutil"
) )
func main() { func main() {
if len(os.Args) > 1 && os.Args[1] == "--bridge" { cmdutil.RunCustom(func() {
lsp.RunBridge() var args struct {
return Path string `json:"path"`
} Line string `json:"line"`
Col string `json:"col"`
NewName string `json:"new_name"`
}
if err := json.NewDecoder(os.Stdin).Decode(&args); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
var args struct { file, _ := filepath.Abs(args.Path)
Path string `json:"path"` line, _ := strconv.Atoi(args.Line)
Line string `json:"line"` col, _ := strconv.Atoi(args.Col)
Col string `json:"col"`
NewName string `json:"new_name"`
}
if err := json.NewDecoder(os.Stdin).Decode(&args); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
file, _ := filepath.Abs(args.Path) result, err := lsp.ClientRequest(&lsp.BridgeRequest{
line, _ := strconv.Atoi(args.Line) Method: "rename",
col, _ := strconv.Atoi(args.Col) File: file,
Line: line - 1,
Col: col - 1,
NewName: args.NewName,
})
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
result, err := lsp.ClientRequest(&lsp.BridgeRequest{ if result == nil || string(result) == "null" {
Method: "rename", fmt.Println("(rename produced no changes)")
File: file, return
Line: line - 1, }
Col: col - 1,
NewName: args.NewName, summary := applyWorkspaceEdit(result)
if summary == "" {
fmt.Println("(rename produced no changes)")
} else {
fmt.Print(summary)
}
}) })
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if result == nil || string(result) == "null" {
fmt.Println("(rename produced no changes)")
return
}
// Parse WorkspaceEdit and apply
summary := applyWorkspaceEdit(result)
if summary == "" {
fmt.Println("(rename produced no changes)")
} else {
fmt.Print(summary)
}
} }
func applyWorkspaceEdit(raw json.RawMessage) string { func applyWorkspaceEdit(raw json.RawMessage) string {
@ -74,7 +71,6 @@ func applyWorkspaceEdit(raw json.RawMessage) string {
return "" return ""
} }
// Normalize to path → edits map
changes := make(map[string][]textEdit) changes := make(map[string][]textEdit)
if len(edit.DocumentChanges) > 0 { if len(edit.DocumentChanges) > 0 {
for _, dc := range edit.DocumentChanges { for _, dc := range edit.DocumentChanges {
@ -97,7 +93,6 @@ func applyWorkspaceEdit(raw json.RawMessage) string {
} }
lines := strings.SplitAfter(string(data), "\n") lines := strings.SplitAfter(string(data), "\n")
// Sort edits reverse (bottom-up) to preserve positions
sort.Slice(edits, func(i, j int) bool { sort.Slice(edits, func(i, j int) bool {
if edits[i].Range.Start.Line != edits[j].Range.Start.Line { if edits[i].Range.Start.Line != edits[j].Range.Start.Line {
return edits[i].Range.Start.Line > edits[j].Range.Start.Line return edits[i].Range.Start.Line > edits[j].Range.Start.Line

View File

@ -8,61 +8,57 @@ import (
"path/filepath" "path/filepath"
"ollie/tools/lsp" "ollie/tools/lsp"
"ollie/tools/lsp/cmdutil"
) )
func main() { func main() {
if len(os.Args) > 1 && os.Args[1] == "--bridge" { cmdutil.RunCustom(func() {
lsp.RunBridge() var args struct {
return Path string `json:"path"`
} Query string `json:"query"`
}
var args struct { if err := json.NewDecoder(os.Stdin).Decode(&args); err != nil {
Path string `json:"path"` fmt.Fprintf(os.Stderr, "error: %v\n", err)
Query string `json:"query"` os.Exit(1)
}
if err := json.NewDecoder(os.Stdin).Decode(&args); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
file, _ := filepath.Abs(args.Path)
workspace := args.Query != ""
result, err := lsp.ClientRequest(&lsp.BridgeRequest{
Method: "symbols",
File: file,
Query: args.Query,
Workspace: workspace,
})
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if result == nil || string(result) == "null" {
fmt.Println("(no symbols found)")
return
}
// Try document symbols (hierarchical)
var docSymbols []documentSymbol
if json.Unmarshal(result, &docSymbols) == nil && len(docSymbols) > 0 {
printDocSymbols(docSymbols, 0)
return
}
// Try workspace symbols (flat)
var wsSymbols []workspaceSymbol
if json.Unmarshal(result, &wsSymbols) == nil && len(wsSymbols) > 0 {
for _, s := range wsSymbols {
path := lsp.URIToPath(s.Location.URI)
line := s.Location.Range.Start.Line + 1
fmt.Printf("%s:%d %s [%s]\n", path, line, s.Name, symbolKind(s.Kind))
} }
return
}
fmt.Println("(no symbols found)") file, _ := filepath.Abs(args.Path)
workspace := args.Query != ""
result, err := lsp.ClientRequest(&lsp.BridgeRequest{
Method: "symbols",
File: file,
Query: args.Query,
Workspace: workspace,
})
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if result == nil || string(result) == "null" {
fmt.Println("(no symbols found)")
return
}
var docSymbols []documentSymbol
if json.Unmarshal(result, &docSymbols) == nil && len(docSymbols) > 0 {
printDocSymbols(docSymbols, 0)
return
}
var wsSymbols []workspaceSymbol
if json.Unmarshal(result, &wsSymbols) == nil && len(wsSymbols) > 0 {
for _, s := range wsSymbols {
path := lsp.URIToPath(s.Location.URI)
line := s.Location.Range.Start.Line + 1
fmt.Printf("%s:%d %s [%s]\n", path, line, s.Name, symbolKind(s.Kind))
}
return
}
fmt.Println("(no symbols found)")
})
} }
func printDocSymbols(symbols []documentSymbol, indent int) { func printDocSymbols(symbols []documentSymbol, indent int) {

View File

@ -0,0 +1,85 @@
// Package cmdutil provides shared boilerplate for LSP tool command binaries.
package cmdutil
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"ollie/tools/lsp"
)
// PositionArgs are the common args for position-based LSP commands.
type PositionArgs struct {
Path string `json:"path"`
Line string `json:"line"`
Col string `json:"col"`
}
// ResolvePosition converts the string fields to absolute path and 0-indexed ints.
func (a PositionArgs) ResolvePosition() (file string, line, col int) {
file, _ = filepath.Abs(a.Path)
l, _ := strconv.Atoi(a.Line)
c, _ := strconv.Atoi(a.Col)
return file, l - 1, c - 1
}
// Run is the common entry point for position-based LSP commands.
// It handles --bridge, reads PositionArgs from stdin, dispatches the request,
// and passes the result to format. If format is nil, raw JSON is printed.
func Run(method string, format func(json.RawMessage)) {
if len(os.Args) > 1 && os.Args[1] == "--bridge" {
if err := lsp.RunBridge(); err != nil {
fmt.Fprintf(os.Stderr, "bridge: %v\n", err)
os.Exit(1)
}
return
}
var args PositionArgs
if err := json.NewDecoder(os.Stdin).Decode(&args); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
file, line, col := args.ResolvePosition()
result, err := lsp.ClientRequest(&lsp.BridgeRequest{
Method: method,
File: file,
Line: line,
Col: col,
})
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if result == nil || string(result) == "null" {
fmt.Printf("(no %s found)\n", method)
return
}
if format != nil {
format(result)
} else {
os.Stdout.Write(result)
fmt.Println()
}
}
// RunCustom handles --bridge and then calls fn for custom argument handling.
// Use for commands that don't follow the simple position-args pattern
// (e.g. diagnostics, rename, symbols).
func RunCustom(fn func()) {
if len(os.Args) > 1 && os.Args[1] == "--bridge" {
if err := lsp.RunBridge(); err != nil {
fmt.Fprintf(os.Stderr, "bridge: %v\n", err)
os.Exit(1)
}
return
}
fn()
}

22
toolsrv/rpcwire.go Normal file
View File

@ -0,0 +1,22 @@
package toolsrv
import "encoding/json"
// RPCRequest is a JSON-RPC 2.0 request.
type RPCRequest struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
// RPCError is a JSON-RPC 2.0 error object.
type RPCError struct {
Code int `json:"code"`
Message string `json:"message"`
}
// OutputNotification carries streamed tool output data.
type OutputNotification struct {
Data string `json:"data"`
}

View File

@ -46,7 +46,7 @@ func localDial(ctx context.Context, cwd string, opts ...Option) (*transport, err
} }
// Generate a unique socket path. // Generate a unique socket path.
sockDir := filepath.Join(runtimeDir(), "ollie") sockDir := filepath.Join(paths.RuntimeDir(), "ollie")
os.MkdirAll(sockDir, 0700) os.MkdirAll(sockDir, 0700)
sockPath := filepath.Join(sockDir, fmt.Sprintf("toolsrv-%d-%d.sock", os.Getpid(), socketSeq.Add(1))) sockPath := filepath.Join(sockDir, fmt.Sprintf("toolsrv-%d-%d.sock", os.Getpid(), socketSeq.Add(1)))
@ -137,7 +137,7 @@ func sshDial(ctx context.Context, cfg RemoteConfig) (*transport, error) {
} }
// Generate socket paths. // Generate socket paths.
sockDir := filepath.Join(runtimeDir(), "ollie") sockDir := filepath.Join(paths.RuntimeDir(), "ollie")
os.MkdirAll(sockDir, 0700) os.MkdirAll(sockDir, 0700)
localSock := filepath.Join(sockDir, fmt.Sprintf("remote-%s-%d-%d.sock", cfg.SSHTarget, os.Getpid(), socketSeq.Add(1))) localSock := filepath.Join(sockDir, fmt.Sprintf("remote-%s-%d-%d.sock", cfg.SSHTarget, os.Getpid(), socketSeq.Add(1)))
remoteSock := fmt.Sprintf("/tmp/ollie-toolsrv-%d.sock", os.Getpid()) remoteSock := fmt.Sprintf("/tmp/ollie-toolsrv-%d.sock", os.Getpid())
@ -285,11 +285,3 @@ func shellEscape(s string) string {
} }
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
} }
// runtimeDir returns XDG_RUNTIME_DIR or a fallback.
func runtimeDir() string {
if d := os.Getenv("XDG_RUNTIME_DIR"); d != "" {
return d
}
return filepath.Join(os.TempDir(), fmt.Sprintf("runtime-%d", os.Getuid()))
}