mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 17:10:43 -10:00
Remove bundled MCP servers and add code index fallback
This commit is contained in:
1 parent
046ccec992
commit
655c72ad9b
82 files changed
+1330
-5822
No files matched your search
@@ -2,11 +2,7 @@ VERSION := $(shell git describe --tags --always 2>/dev/null || echo dev)
|
||||
LDFLAGS := -s -w -X main.version=$(VERSION)
|
||||
GOEXE := $(shell go env GOEXE)
|
||||
|
||||
# CodeGraph release pinned for the bundled MCP server / e2e test. Bump together
|
||||
# with any change to the integration in internal/codegraph.
|
||||
CODEGRAPH_VERSION := v0.9.7
|
||||
|
||||
.PHONY: build vet fmt test hooks cross clean e2e-codegraph
|
||||
.PHONY: build vet fmt test hooks cross clean
|
||||
|
||||
build:
|
||||
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/reasonix$(GOEXE) ./cmd/reasonix
|
||||
@@ -35,18 +31,3 @@ cross:
|
||||
|
||||
clean:
|
||||
rm -rf bin dist
|
||||
|
||||
# Fetch the matching CodeGraph bundle into bin/codegraph/ (the distribution
|
||||
# layout: launcher at bin/codegraph/bin/codegraph beside bin/reasonix) and run the
|
||||
# gated MCP end-to-end test against it. Requires `gh`. Windows: install via the
|
||||
# upstream install.ps1 and run the test with REASONIX_CODEGRAPH_BIN set.
|
||||
e2e-codegraph:
|
||||
@os=$$(uname -s | tr 'A-Z' 'a-z'); arch=$$(uname -m); \
|
||||
case $$arch in arm64|aarch64) arch=arm64;; x86_64|amd64) arch=x64;; *) echo "unsupported arch $$arch"; exit 1;; esac; \
|
||||
asset=codegraph-$$os-$$arch.tar.gz; dest=bin/codegraph; \
|
||||
echo "fetching $$asset ($(CODEGRAPH_VERSION)) -> $$dest"; \
|
||||
rm -rf $$dest && mkdir -p $$dest; \
|
||||
gh release download $(CODEGRAPH_VERSION) -R colbymchenry/codegraph -p $$asset -O /tmp/$$asset; \
|
||||
tar -xzf /tmp/$$asset -C $$dest --strip-components=1; \
|
||||
REASONIX_CODEGRAPH_E2E=1 REASONIX_CODEGRAPH_BIN=$$PWD/$$dest/bin/codegraph \
|
||||
go test ./internal/codegraph/ -run E2E -v -count=1
|
||||
+2
-366
@@ -30,8 +30,6 @@ import (
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/billing"
|
||||
"reasonix/internal/boot"
|
||||
"reasonix/internal/builtinmcp"
|
||||
"reasonix/internal/codegraph"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
@@ -41,7 +39,6 @@ import (
|
||||
"reasonix/internal/i18n"
|
||||
"reasonix/internal/mcpdiag"
|
||||
"reasonix/internal/memory"
|
||||
"reasonix/internal/netclient"
|
||||
"reasonix/internal/plugin"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/skill"
|
||||
@@ -58,8 +55,6 @@ const eventChannel = "agent:event"
|
||||
// reopen behavior remains predictable on every platform.
|
||||
const singleInstanceID = "com.reasonix.desktop"
|
||||
|
||||
var updateBuiltInCodegraph = codegraph.UpdateWithClient
|
||||
|
||||
// PromptHistoryEntry is one user prompt extracted from a session JSONL file.
|
||||
// The frontend uses these for ↑/↓ prompt-history navigation.
|
||||
type PromptHistoryEntry struct {
|
||||
@@ -114,9 +109,6 @@ type App struct {
|
||||
botInstalls map[string]*botInstallSession
|
||||
botRuntime *desktopBotRuntime
|
||||
|
||||
builtInMCPUpdatesMu sync.RWMutex
|
||||
builtInMCPUpdates map[string]BuiltInMCPUpdateStatus
|
||||
|
||||
metrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics
|
||||
|
||||
runtimeEvents asyncRuntimeEmitter
|
||||
@@ -330,7 +322,6 @@ func (a *App) startup(ctx context.Context) {
|
||||
|
||||
go a.restoreOrBuildTabs()
|
||||
a.goSafe("refreshBotRuntime", a.refreshBotRuntime)
|
||||
a.goSafe("checkBuiltInMCPUpdates", a.checkBuiltInMCPUpdates)
|
||||
a.goSafe("sendStartupPing", a.sendStartupPing)
|
||||
a.goSafe("flushMetrics", a.flushMetrics)
|
||||
a.goSafe("flushPendingCrash", a.flushPendingCrash)
|
||||
@@ -3659,12 +3650,6 @@ type ToolView struct {
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type BuiltInMCPUpdateResult struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// SkillView is one discoverable skill for the drawer.
|
||||
type SkillView struct {
|
||||
Name string `json:"name"`
|
||||
@@ -3716,11 +3701,9 @@ func (a *App) Capabilities() CapabilitiesView {
|
||||
seen := map[string]bool{}
|
||||
connected := map[string]bool{}
|
||||
retainedDisabled := map[string]ServerView{}
|
||||
var loadedCfg *config.Config
|
||||
configured := map[string]config.PluginEntry{}
|
||||
var configuredEntries []config.PluginEntry
|
||||
if cfg, err := config.LoadForRoot(tab.WorkspaceRoot); err == nil {
|
||||
loadedCfg = cfg
|
||||
configuredEntries = append(configuredEntries, cfg.Plugins...)
|
||||
for _, p := range configuredEntries {
|
||||
configured[p.Name] = p
|
||||
@@ -3737,10 +3720,6 @@ func (a *App) Capabilities() CapabilitiesView {
|
||||
}
|
||||
if p, ok := configured[s.Name]; ok {
|
||||
view = withPluginConfig(view, p)
|
||||
} else if s.Name == "codegraph" && loadedCfg != nil {
|
||||
view = withCodegraphConfig(view, loadedCfg.Codegraph)
|
||||
} else if p, ok := builtinmcp.Entry(s.Name); ok {
|
||||
view = withBuiltInMCPConfig(view, p, builtInMCPEnabled(loadedCfg, p.Name))
|
||||
}
|
||||
out.Servers = append(out.Servers, view)
|
||||
}
|
||||
@@ -3751,17 +3730,13 @@ func (a *App) Capabilities() CapabilitiesView {
|
||||
}
|
||||
if p, ok := configured[f.Name]; ok {
|
||||
view = withPluginConfig(view, p)
|
||||
} else if f.Name == "codegraph" && loadedCfg != nil {
|
||||
view = withCodegraphConfig(view, loadedCfg.Codegraph)
|
||||
} else if p, ok := builtinmcp.Entry(f.Name); ok {
|
||||
view = withBuiltInMCPConfig(view, p, builtInMCPEnabled(loadedCfg, p.Name))
|
||||
}
|
||||
out.Servers = append(out.Servers, view)
|
||||
}
|
||||
}
|
||||
// Configured servers that are neither connected nor failed are either lazy
|
||||
// (deferred), background/eager (initializing), or toggled off this session.
|
||||
if len(configuredEntries) > 0 || loadedCfg != nil {
|
||||
if len(configuredEntries) > 0 {
|
||||
for _, p := range configuredEntries {
|
||||
if seen[p.Name] {
|
||||
continue
|
||||
@@ -3788,44 +3763,6 @@ func (a *App) Capabilities() CapabilitiesView {
|
||||
out.Servers = append(out.Servers, withPluginConfig(ServerView{Name: p.Name, Status: status}, p))
|
||||
seen[p.Name] = true
|
||||
}
|
||||
if loadedCfg != nil && !seen["codegraph"] {
|
||||
status := "disabled"
|
||||
if loadedCfg.Codegraph.Enabled {
|
||||
status = "initializing"
|
||||
}
|
||||
if s, ok := disabled["codegraph"]; ok {
|
||||
s.Status = "disabled"
|
||||
s.Transport = "stdio"
|
||||
s.BuiltIn = true
|
||||
s = withCodegraphConfig(s, loadedCfg.Codegraph)
|
||||
s.Error = ""
|
||||
out.Servers = append(out.Servers, s)
|
||||
retainedDisabled["codegraph"] = s
|
||||
delete(disabled, "codegraph")
|
||||
} else {
|
||||
out.Servers = append(out.Servers, withCodegraphConfig(ServerView{Name: "codegraph", Status: status}, loadedCfg.Codegraph))
|
||||
}
|
||||
seen["codegraph"] = true
|
||||
}
|
||||
for _, p := range builtinmcp.Entries() {
|
||||
if configured[p.Name].Name != "" || seen[p.Name] {
|
||||
continue
|
||||
}
|
||||
enabled := builtInMCPEnabled(loadedCfg, p.Name)
|
||||
if s, ok := disabled[p.Name]; ok {
|
||||
s.Status = "disabled"
|
||||
s = withBuiltInMCPConfig(s, p, enabled)
|
||||
s.Error = ""
|
||||
out.Servers = append(out.Servers, s)
|
||||
retainedDisabled[p.Name] = s
|
||||
delete(disabled, p.Name)
|
||||
} else if enabled {
|
||||
out.Servers = append(out.Servers, withBuiltInMCPConfig(ServerView{Name: p.Name, Status: "deferred"}, p, true))
|
||||
} else {
|
||||
out.Servers = append(out.Servers, withBuiltInMCPConfig(ServerView{Name: p.Name, Status: "disabled"}, p, false))
|
||||
}
|
||||
seen[p.Name] = true
|
||||
}
|
||||
}
|
||||
out.Servers = orderServerViews(out.Servers, order)
|
||||
|
||||
@@ -3874,37 +3811,6 @@ func withPluginConfig(v ServerView, p config.PluginEntry) ServerView {
|
||||
return v
|
||||
}
|
||||
|
||||
func withCodegraphConfig(v ServerView, c config.CodegraphConfig) ServerView {
|
||||
v.Name = "codegraph"
|
||||
v.Transport = "stdio"
|
||||
v.BuiltIn = true
|
||||
v.Configured = true
|
||||
v.AutoStart = c.ShouldAutoStart()
|
||||
v.Tier = c.ResolvedTier()
|
||||
v.Command = strings.TrimSpace(c.Path)
|
||||
if v.Command == "" {
|
||||
v.Command = "codegraph"
|
||||
}
|
||||
v.Args = []string{"serve", "--mcp"}
|
||||
v.AuthStatus = mcpdiag.AuthNone
|
||||
return v
|
||||
}
|
||||
|
||||
func withBuiltInMCPConfig(v ServerView, p config.PluginEntry, enabled bool) ServerView {
|
||||
v = withPluginConfig(v, p)
|
||||
v.Name = p.Name
|
||||
v.BuiltIn = true
|
||||
v.Configured = true
|
||||
v.AutoStart = enabled
|
||||
v.AuthStatus = mcpdiag.AuthNone
|
||||
v.AuthURL = ""
|
||||
return v
|
||||
}
|
||||
|
||||
func builtInMCPEnabled(cfg *config.Config, name string) bool {
|
||||
return cfg != nil && cfg.BuiltInMCP.Enabled(name)
|
||||
}
|
||||
|
||||
func skillRootsView() []SkillRootView {
|
||||
cwd, _ := os.Getwd()
|
||||
cfg, _ := config.Load()
|
||||
@@ -4140,9 +4046,6 @@ func (a *App) AddMCPServer(in MCPServerInput) (int, error) {
|
||||
if ctrl == nil {
|
||||
return 0, fmt.Errorf("no active session")
|
||||
}
|
||||
if builtinmcp.IsBuiltIn(strings.TrimSpace(in.Name)) {
|
||||
return 0, fmt.Errorf("%s is built in; no configuration is required", strings.TrimSpace(in.Name))
|
||||
}
|
||||
entry := config.PluginEntry{
|
||||
Name: in.Name,
|
||||
Type: normalizeMCPTransport(in.Transport),
|
||||
@@ -4161,9 +4064,6 @@ func (a *App) AddMCPServer(in MCPServerInput) (int, error) {
|
||||
// UpdateMCPServer edits a persisted external MCP server. The name is the stable
|
||||
// identity; callers must remove + add if they want to rename a server.
|
||||
func (a *App) UpdateMCPServer(name string, in MCPServerInput) error {
|
||||
if name == "codegraph" {
|
||||
return fmt.Errorf("codegraph is built in; configure it with [codegraph]")
|
||||
}
|
||||
ctrl := a.activeCtrl()
|
||||
if ctrl == nil {
|
||||
return fmt.Errorf("no active session")
|
||||
@@ -4176,9 +4076,6 @@ func (a *App) UpdateMCPServer(name string, in MCPServerInput) error {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
if builtinmcp.IsBuiltIn(name) {
|
||||
return fmt.Errorf("%s is built in; it cannot be edited", name)
|
||||
}
|
||||
return fmt.Errorf("no configured MCP server named %q", name)
|
||||
}
|
||||
updated.Type = normalizeMCPTransport(in.Transport)
|
||||
@@ -4223,16 +4120,6 @@ func (a *App) UpdateMCPServer(name string, in MCPServerInput) error {
|
||||
|
||||
// RemoveMCPServer disconnects a live server and drops it from config (the row's ✕).
|
||||
func (a *App) RemoveMCPServer(name string) error {
|
||||
if name == "codegraph" {
|
||||
return fmt.Errorf("codegraph is built in; it cannot be removed")
|
||||
}
|
||||
if builtinmcp.IsBuiltIn(name) {
|
||||
if _, found, err := a.desktopMCPServerForEdit(name); err != nil {
|
||||
return err
|
||||
} else if !found {
|
||||
return fmt.Errorf("%s is built in; it cannot be removed", name)
|
||||
}
|
||||
}
|
||||
tab := a.activeTab()
|
||||
if tab == nil || tab.Ctrl == nil {
|
||||
return fmt.Errorf("no active session")
|
||||
@@ -4268,8 +4155,6 @@ func (a *App) ReconnectMCPServer(name string) error {
|
||||
entry := config.PluginEntry{Name: name}
|
||||
if p, found, cfgErr := a.desktopMCPServerForEdit(name); cfgErr == nil && found {
|
||||
entry = p
|
||||
} else if p, ok := builtinmcp.Entry(name); ok {
|
||||
entry = p
|
||||
}
|
||||
recordMCPFailure(tab.Ctrl, entry, err)
|
||||
return err
|
||||
@@ -4280,71 +4165,10 @@ func (a *App) ReconnectMCPServer(name string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateBuiltInMCPServer downloads and activates the latest runtime for a
|
||||
// bundled MCP. It is intentionally explicit because newer MCP releases can
|
||||
// change tool schemas and prompt-cache shape.
|
||||
func (a *App) UpdateBuiltInMCPServer(name string) (BuiltInMCPUpdateResult, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name != "codegraph" {
|
||||
return BuiltInMCPUpdateResult{}, fmt.Errorf("%s is not an updatable built-in MCP server", name)
|
||||
}
|
||||
tab := a.activeTab()
|
||||
if tab == nil || tab.Ctrl == nil {
|
||||
return BuiltInMCPUpdateResult{}, fmt.Errorf("no active session")
|
||||
}
|
||||
cfg, err := config.LoadForRoot(tab.WorkspaceRoot)
|
||||
if err != nil {
|
||||
return BuiltInMCPUpdateResult{}, err
|
||||
}
|
||||
client, err := netclient.NewHTTPClient(cfg.NetworkProxySpec(), netclient.TransportOptions{})
|
||||
if err != nil {
|
||||
return BuiltInMCPUpdateResult{}, err
|
||||
}
|
||||
updated, err := updateBuiltInCodegraph(a.bootContext(), client, nil)
|
||||
if err != nil {
|
||||
return BuiltInMCPUpdateResult{}, err
|
||||
}
|
||||
result := BuiltInMCPUpdateResult{Name: name, Version: updated.Version, Path: updated.Path}
|
||||
a.recordBuiltInMCPUpdateStatus(BuiltInMCPUpdateStatus{
|
||||
Name: name,
|
||||
Mode: "manual",
|
||||
Current: codegraph.ActiveVersion(),
|
||||
Latest: updated.Version,
|
||||
Phase: "activated",
|
||||
Path: updated.Path,
|
||||
})
|
||||
|
||||
a.mu.RLock()
|
||||
_, sessionDisabled := tab.disabledMCP[name]
|
||||
a.mu.RUnlock()
|
||||
if cfg.Codegraph.Enabled && !sessionDisabled {
|
||||
if mcpConnected(tab.Ctrl, name) {
|
||||
tab.Ctrl.DisconnectMCPServer(name)
|
||||
}
|
||||
if h := tab.Ctrl.Host(); h != nil {
|
||||
h.ClearFailure(name)
|
||||
}
|
||||
if _, err := tab.Ctrl.ConnectCodegraphMCPServerForRoot(cfg, tab.WorkspaceRoot); err != nil {
|
||||
recordCodegraphFailure(tab.Ctrl, cfg.Codegraph, err)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ClearMCPServerAuthentication removes local auth-like config for one MCP and
|
||||
// clears the current session's cached connection failure. It does not remove the
|
||||
// server itself or try to sign the user out of the third-party browser session.
|
||||
func (a *App) ClearMCPServerAuthentication(name string) error {
|
||||
if name == "codegraph" {
|
||||
return fmt.Errorf("codegraph is built in; it has no stored MCP authentication")
|
||||
}
|
||||
if builtinmcp.IsBuiltIn(name) {
|
||||
if _, found, err := a.desktopMCPServerForEdit(name); err != nil {
|
||||
return err
|
||||
} else if !found {
|
||||
return fmt.Errorf("%s is built in; it has no stored MCP authentication", name)
|
||||
}
|
||||
}
|
||||
ctrl := a.activeCtrl()
|
||||
if ctrl == nil {
|
||||
return fmt.Errorf("no active session")
|
||||
@@ -4367,16 +4191,10 @@ func (a *App) SetMCPServerEnabled(name string, enabled bool) error {
|
||||
if tab == nil || tab.Ctrl == nil {
|
||||
return fmt.Errorf("no active session")
|
||||
}
|
||||
if name == "codegraph" {
|
||||
return a.setCodegraphEnabled(enabled)
|
||||
}
|
||||
configuredEntry, hasConfiguredEntry, err := a.desktopMCPServerForEdit(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if builtinmcp.IsBuiltIn(name) && !hasConfiguredEntry {
|
||||
return a.setBuiltInMCPEnabled(name, enabled)
|
||||
}
|
||||
if enabled {
|
||||
_, err := a.connectConfiguredMCPServerForTab(tab, name)
|
||||
if err == nil {
|
||||
@@ -4423,31 +4241,18 @@ func (a *App) connectConfiguredMCPServerForTab(tab *WorkspaceTab, name string) (
|
||||
return tab.Ctrl.ConnectMCPServer(p)
|
||||
}
|
||||
}
|
||||
if name == "codegraph" {
|
||||
return tab.Ctrl.ConnectCodegraphMCPServerForRoot(cfg, tab.WorkspaceRoot)
|
||||
}
|
||||
if p, ok := builtinmcp.Entry(name); ok {
|
||||
return tab.Ctrl.ConnectMCPServer(p)
|
||||
}
|
||||
return 0, fmt.Errorf("no configured MCP server named %q", name)
|
||||
}
|
||||
|
||||
// SetMCPServerTier is kept for old desktop bindings. New config writes drop the
|
||||
// retired tier field; for CodeGraph this now means "enable and start in the
|
||||
// background".
|
||||
// retired tier field.
|
||||
func (a *App) SetMCPServerTier(name, tier string) error {
|
||||
if name == "codegraph" {
|
||||
return a.setCodegraphTier(tier)
|
||||
}
|
||||
tier = normalizeMCPTier(tier)
|
||||
updated, found, err := a.desktopMCPServerForEdit(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
if builtinmcp.IsBuiltIn(name) {
|
||||
return fmt.Errorf("%s is built in; it always uses lazy startup", name)
|
||||
}
|
||||
return fmt.Errorf("no configured MCP server named %q", name)
|
||||
}
|
||||
updated.Tier = tier
|
||||
@@ -4471,124 +4276,6 @@ func (a *App) SetMCPServerTier(name, tier string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) setBuiltInMCPEnabled(name string, enabled bool) error {
|
||||
entry, ok := builtinmcp.Entry(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("no built-in MCP server named %q", name)
|
||||
}
|
||||
cfg, path, err := a.loadDesktopUserConfigForEdit()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !cfg.BuiltInMCP.SetEnabled(name, enabled) {
|
||||
return fmt.Errorf("no built-in MCP server named %q", name)
|
||||
}
|
||||
tab := a.activeTab()
|
||||
if tab == nil || tab.Ctrl == nil {
|
||||
return fmt.Errorf("no active session")
|
||||
}
|
||||
if err := cfg.SaveTo(path); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.syncProjectBuiltInMCPOverride(cfg.BuiltInMCP); err != nil {
|
||||
return err
|
||||
}
|
||||
if enabled {
|
||||
a.mu.Lock()
|
||||
delete(tab.disabledMCP, name)
|
||||
a.mu.Unlock()
|
||||
_, err := tab.Ctrl.ConnectMCPServer(entry)
|
||||
if err != nil {
|
||||
recordMCPFailure(tab.Ctrl, entry, err)
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if h := tab.Ctrl.Host(); h != nil {
|
||||
h.ClearFailure(name)
|
||||
}
|
||||
tab.Ctrl.DisconnectMCPServer(name)
|
||||
s := withBuiltInMCPConfig(ServerView{Name: name, Status: "disabled"}, entry, false)
|
||||
a.mu.Lock()
|
||||
if tab.disabledMCP == nil {
|
||||
tab.disabledMCP = map[string]ServerView{}
|
||||
}
|
||||
tab.disabledMCP[name] = s
|
||||
tab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})
|
||||
a.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) setCodegraphEnabled(enabled bool) error {
|
||||
cfg, path, err := a.loadDesktopUserConfigForEdit()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tab := a.activeTab()
|
||||
if tab == nil || tab.Ctrl == nil {
|
||||
return fmt.Errorf("no active session")
|
||||
}
|
||||
cfg.Codegraph.Enabled = enabled
|
||||
if err := cfg.SaveTo(path); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.syncProjectCodegraphOverride(cfg.Codegraph); err != nil {
|
||||
return err
|
||||
}
|
||||
if enabled {
|
||||
a.mu.Lock()
|
||||
delete(tab.disabledMCP, "codegraph")
|
||||
a.mu.Unlock()
|
||||
if _, err := tab.Ctrl.ConnectCodegraphMCPServerForRoot(cfg, tab.WorkspaceRoot); err != nil {
|
||||
recordCodegraphFailure(tab.Ctrl, cfg.Codegraph, err)
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if h := tab.Ctrl.Host(); h != nil {
|
||||
h.ClearFailure("codegraph")
|
||||
}
|
||||
tab.Ctrl.DisconnectMCPServer("codegraph")
|
||||
s := withCodegraphConfig(ServerView{Name: "codegraph", Status: "disabled"}, cfg.Codegraph)
|
||||
a.mu.Lock()
|
||||
if tab.disabledMCP == nil {
|
||||
tab.disabledMCP = map[string]ServerView{}
|
||||
}
|
||||
tab.disabledMCP["codegraph"] = s
|
||||
tab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})
|
||||
a.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) setCodegraphTier(_ string) error {
|
||||
cfg, path, err := a.loadDesktopUserConfigForEdit()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Codegraph.Enabled = true
|
||||
cfg.Codegraph.Tier = ""
|
||||
if err := cfg.SaveTo(path); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.syncProjectCodegraphOverride(cfg.Codegraph); err != nil {
|
||||
return err
|
||||
}
|
||||
tab := a.activeTab()
|
||||
if tab == nil || tab.Ctrl == nil {
|
||||
return nil
|
||||
}
|
||||
a.mu.Lock()
|
||||
delete(tab.disabledMCP, "codegraph")
|
||||
a.mu.Unlock()
|
||||
if !mcpConnected(tab.Ctrl, "codegraph") {
|
||||
if _, err := tab.Ctrl.ConnectCodegraphMCPServerForRoot(cfg, tab.WorkspaceRoot); err != nil {
|
||||
recordCodegraphFailure(tab.Ctrl, cfg.Codegraph, err)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) desktopMCPServerForEdit(name string) (config.PluginEntry, bool, error) {
|
||||
cfg, _, err := a.loadDesktopUserConfigForEdit()
|
||||
if err != nil {
|
||||
@@ -4661,40 +4348,6 @@ func (a *App) removeProjectMCPOverride(name string) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (a *App) syncProjectCodegraphOverride(c config.CodegraphConfig) error {
|
||||
path := projectConfigPathForRoot(a.activeWorkspaceRoot())
|
||||
userPath := config.UserConfigPath()
|
||||
if path == "" || sameConfigPath(path, userPath) {
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
cfg := config.LoadForEdit(path)
|
||||
cfg.Codegraph = c
|
||||
return cfg.SaveTo(path)
|
||||
}
|
||||
|
||||
func (a *App) syncProjectBuiltInMCPOverride(c config.BuiltInMCPConfig) error {
|
||||
path := projectConfigPathForRoot(a.activeWorkspaceRoot())
|
||||
userPath := config.UserConfigPath()
|
||||
if path == "" || sameConfigPath(path, userPath) {
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
cfg := config.LoadForEdit(path)
|
||||
cfg.BuiltInMCP = c
|
||||
return cfg.SaveTo(path)
|
||||
}
|
||||
|
||||
func findPluginEntry(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {
|
||||
for _, p := range entries {
|
||||
if p.Name == name {
|
||||
@@ -4768,22 +4421,6 @@ func recordMCPFailure(ctrl *control.Controller, e config.PluginEntry, err error)
|
||||
}, err)
|
||||
}
|
||||
|
||||
func recordCodegraphFailure(ctrl *control.Controller, c config.CodegraphConfig, err error) {
|
||||
if ctrl == nil || ctrl.Host() == nil || err == nil {
|
||||
return
|
||||
}
|
||||
cmd := strings.TrimSpace(c.Path)
|
||||
if cmd == "" {
|
||||
cmd = "codegraph"
|
||||
}
|
||||
ctrl.Host().RecordFailure(plugin.Spec{
|
||||
Name: "codegraph",
|
||||
Type: "stdio",
|
||||
Command: cmd,
|
||||
Args: []string{"serve", "--mcp"},
|
||||
}, err)
|
||||
}
|
||||
|
||||
func findMCPServerView(ctrl *control.Controller, name string) (ServerView, bool) {
|
||||
if ctrl == nil || ctrl.Host() == nil {
|
||||
return ServerView{}, false
|
||||
@@ -5299,7 +4936,6 @@ type WorkspaceChangesView struct {
|
||||
// and "@" menu regardless of where they appear.
|
||||
var workspaceNoiseNames = map[string]bool{
|
||||
".codex": true,
|
||||
".codegraph": true,
|
||||
".DS_Store": true,
|
||||
".git": true,
|
||||
".npm": true,
|
||||
|
||||
+4
-521
@@ -6,7 +6,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -18,7 +17,6 @@ import (
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/codegraph"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
@@ -1620,12 +1618,6 @@ func TestSearchFileRefsFindsNestedBasename(t *testing.T) {
|
||||
if err := os.WriteFile(filepath.Join(dir, "node_modules", "pkg", "runtime.js"), []byte("noise"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(dir, ".codegraph", "cache"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, ".codegraph", "cache", "runtime.js"), []byte("index"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, noise := range []string{".codex", ".npm", ".pnpm-store", "bin", "dist", "stage", "tmp"} {
|
||||
if err := os.MkdirAll(filepath.Join(dir, noise), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1652,7 +1644,7 @@ func TestSearchFileRefsFindsNestedBasename(t *testing.T) {
|
||||
|
||||
app := &App{}
|
||||
listed := app.ListDir("")
|
||||
for _, hidden := range []string{".codex", ".codegraph", ".npm", ".pnpm-store", "bin", "dist", "stage", "tmp"} {
|
||||
for _, hidden := range []string{".codex", ".npm", ".pnpm-store", "bin", "dist", "stage", "tmp"} {
|
||||
if hasDirEntry(listed, hidden) {
|
||||
t.Fatalf("ListDir should hide local noise %q, got %+v", hidden, listed)
|
||||
}
|
||||
@@ -1680,7 +1672,6 @@ func TestSearchFileRefsFindsNestedBasename(t *testing.T) {
|
||||
}
|
||||
for _, hidden := range []string{
|
||||
".codex/runtime.js",
|
||||
".codegraph/cache/runtime.js",
|
||||
".npm/runtime.js",
|
||||
".pnpm-store/runtime.js",
|
||||
"bin/runtime.js",
|
||||
@@ -2186,7 +2177,7 @@ func TestForkCreatesActiveTabWithoutSwitchingSourceController(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
workspace := robustTempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(workspace, "reasonix.toml"), []byte("[codegraph]\nenabled = false\n"), 0o644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(workspace, "reasonix.toml"), []byte(""), 0o644); err != nil {
|
||||
t.Fatalf("write workspace config: %v", err)
|
||||
}
|
||||
dir := config.SessionDir()
|
||||
@@ -2281,9 +2272,6 @@ func TestCapabilitiesShowsDefaultMCPAsInitializingNotDisabled(t *testing.T) {
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[[plugins]]
|
||||
name = "playwright"
|
||||
command = "npx"
|
||||
@@ -2312,134 +2300,11 @@ args = ["-y", "@playwright/mcp"]
|
||||
t.Fatalf("playwright MCP missing from Capabilities: %+v", view.Servers)
|
||||
}
|
||||
|
||||
func TestCapabilitiesShowsDefaultCodegraphDisabled(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
defer app.activeCtrl().Close()
|
||||
|
||||
view := app.Capabilities()
|
||||
for _, s := range view.Servers {
|
||||
if s.Name == "codegraph" {
|
||||
if s.Status != "disabled" {
|
||||
t.Fatalf("codegraph status = %q, want disabled; server = %+v", s.Status, s)
|
||||
}
|
||||
if !s.BuiltIn || !s.Configured {
|
||||
t.Fatalf("codegraph builtIn/configured = %v/%v, want true/true; server = %+v", s.BuiltIn, s.Configured, s)
|
||||
}
|
||||
if s.AutoStart {
|
||||
t.Fatalf("codegraph autoStart = true, want false; server = %+v", s)
|
||||
}
|
||||
if s.Tier != "background" {
|
||||
t.Fatalf("codegraph tier = %q, want background; server = %+v", s.Tier, s)
|
||||
}
|
||||
if s.Command != "codegraph" || !reflect.DeepEqual(s.Args, []string{"serve", "--mcp"}) {
|
||||
t.Fatalf("codegraph command = %q %+v, want codegraph serve --mcp", s.Command, s.Args)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("codegraph missing from Capabilities: %+v", view.Servers)
|
||||
}
|
||||
|
||||
func TestCapabilitiesShowsBuiltInMCPDefaults(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
defer app.activeCtrl().Close()
|
||||
|
||||
view := app.Capabilities()
|
||||
want := map[string][]string{
|
||||
"time": []string{"builtin-mcp", "time"},
|
||||
}
|
||||
found := map[string]bool{}
|
||||
for _, s := range view.Servers {
|
||||
if s.Name != "time" && s.Name != "context7" {
|
||||
continue
|
||||
}
|
||||
found[s.Name] = true
|
||||
wantStatus := map[string]string{"time": "deferred", "context7": "disabled"}[s.Name]
|
||||
wantAutoStart := s.Name == "time"
|
||||
if s.Status != wantStatus {
|
||||
t.Fatalf("%s status = %q, want %s; server = %+v", s.Name, s.Status, wantStatus, s)
|
||||
}
|
||||
if !s.BuiltIn || !s.Configured || s.AutoStart != wantAutoStart {
|
||||
t.Fatalf("%s builtIn/configured/autoStart = %v/%v/%v, want true/true/%v; server = %+v", s.Name, s.BuiltIn, s.Configured, s.AutoStart, wantAutoStart, s)
|
||||
}
|
||||
if s.Tier != "lazy" || s.Transport != "stdio" || strings.TrimSpace(s.Command) == "" {
|
||||
t.Fatalf("%s transport/tier/command = %q/%q/%q, want stdio/lazy/non-empty; server = %+v", s.Name, s.Transport, s.Tier, s.Command, s)
|
||||
}
|
||||
if s.Name == "time" && !reflect.DeepEqual(s.Args, want["time"]) {
|
||||
t.Fatalf("time args = %+v, want %+v", s.Args, want["time"])
|
||||
}
|
||||
if s.Name == "context7" && !validContext7Runner(s.Command, s.Args) {
|
||||
t.Fatalf("context7 runner = %q %+v, want npx/pnpm/bunx for @upstash/context7-mcp", s.Command, s.Args)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"time", "context7"} {
|
||||
if !found[name] {
|
||||
t.Fatalf("built-in MCP %s missing from Capabilities: %+v", name, view.Servers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilitiesShowsManuallyEnabledContext7Deferred(t *testing.T) {
|
||||
func TestConfiguredMCPWithFormerBuiltInNameIsUserServer(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[builtin_mcp]
|
||||
context7_enabled = true
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
defer app.activeCtrl().Close()
|
||||
|
||||
view := app.Capabilities()
|
||||
for _, s := range view.Servers {
|
||||
if s.Name == "context7" {
|
||||
if s.Status != "deferred" || !s.AutoStart || !s.BuiltIn || !s.Configured {
|
||||
t.Fatalf("enabled context7 view = %+v, want deferred built-in configured autoStart", s)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("context7 missing from Capabilities: %+v", view.Servers)
|
||||
}
|
||||
|
||||
func validContext7Runner(command string, args []string) bool {
|
||||
switch command {
|
||||
case "npx":
|
||||
return reflect.DeepEqual(args, []string{"-y", "@upstash/context7-mcp"})
|
||||
case "pnpm":
|
||||
return reflect.DeepEqual(args, []string{"dlx", "@upstash/context7-mcp"})
|
||||
case "bunx":
|
||||
return reflect.DeepEqual(args, []string{"@upstash/context7-mcp"})
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredMCPWithBuiltInNameTakesPrecedence(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[[plugins]]
|
||||
name = "time"
|
||||
command = "custom-time"
|
||||
@@ -2461,7 +2326,7 @@ tier = "lazy"
|
||||
}
|
||||
found = true
|
||||
if s.BuiltIn || !s.Configured || s.Command != "custom-time" || !reflect.DeepEqual(s.Args, []string{"serve"}) {
|
||||
t.Fatalf("configured time view = %+v, want user config to take precedence over built-in", s)
|
||||
t.Fatalf("configured time view = %+v, want ordinary user MCP config", s)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
@@ -2488,9 +2353,6 @@ func TestEditAndRemoveConfiguredMCPWithBuiltInName(t *testing.T) {
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[[plugins]]
|
||||
name = "time"
|
||||
command = "custom-time"
|
||||
@@ -2532,45 +2394,11 @@ args = ["serve"]
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetBuiltInMCPDisabledWritesBuiltInConfigOnly(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
defer app.activeCtrl().Close()
|
||||
|
||||
if err := app.SetMCPServerEnabled("time", false); err != nil {
|
||||
t.Fatalf("SetMCPServerEnabled(time,false): %v", err)
|
||||
}
|
||||
view := app.Capabilities()
|
||||
for _, s := range view.Servers {
|
||||
if s.Name == "time" {
|
||||
if s.Status != "disabled" || !s.BuiltIn || !s.Configured {
|
||||
t.Fatalf("time disabled view = %+v, want disabled built-in configured", s)
|
||||
}
|
||||
cfg := config.LoadForEdit(config.UserConfigPath())
|
||||
if _, ok := findPluginEntry(cfg.Plugins, "time"); ok {
|
||||
t.Fatalf("time built-in disable wrote a user plugin: %+v", cfg.Plugins)
|
||||
}
|
||||
if cfg.BuiltInMCP.TimeEnabled {
|
||||
t.Fatalf("time built-in disable left time_enabled true: %+v", cfg.BuiltInMCP)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("time missing from Capabilities after disable: %+v", view.Servers)
|
||||
}
|
||||
|
||||
func TestCapabilitiesMarksBackgroundRemoteMCPAuthPossible(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[[plugins]]
|
||||
name = "dida"
|
||||
type = "http"
|
||||
@@ -2601,9 +2429,6 @@ func TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) {
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[[plugins]]
|
||||
name = "stripe"
|
||||
type = "http"
|
||||
@@ -2635,9 +2460,6 @@ func TestCapabilitiesMarksAuthFailureRequired(t *testing.T) {
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[[plugins]]
|
||||
name = "figma"
|
||||
type = "http"
|
||||
@@ -2670,9 +2492,6 @@ func TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) {
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[[plugins]]
|
||||
name = "figma"
|
||||
type = "http"
|
||||
@@ -2733,9 +2552,6 @@ func TestUpdateMCPServerMigratesLegacyTierToBackground(t *testing.T) {
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[[plugins]]
|
||||
name = "playwright"
|
||||
command = "npx"
|
||||
@@ -2807,9 +2623,6 @@ func TestUpdateMCPServerSplitsPastedCommandLine(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[[plugins]]
|
||||
name = "playwright"
|
||||
command = "npx"
|
||||
@@ -2847,9 +2660,6 @@ func TestUpdateMCPServerRecordsReconnectFailure(t *testing.T) {
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[[plugins]]
|
||||
name = "broken"
|
||||
command = "npx"
|
||||
@@ -2902,9 +2712,6 @@ func TestSetMCPServerTierRecordsConnectFailure(t *testing.T) {
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[[plugins]]
|
||||
name = "broken"
|
||||
command = "reasonix-missing-mcp-binary"
|
||||
@@ -2961,335 +2768,11 @@ tier = "lazy"
|
||||
t.Fatalf("broken MCP missing from Capabilities: %+v", view.Servers)
|
||||
}
|
||||
|
||||
func TestSetMCPServerTierEnablesCodegraphAndIgnoresLegacyTier(t *testing.T) {
|
||||
t.Setenv("HOME", robustTempDir(t))
|
||||
t.Setenv("USERPROFILE", robustTempDir(t))
|
||||
t.Setenv("XDG_CONFIG_HOME", robustTempDir(t))
|
||||
t.Setenv("AppData", robustTempDir(t))
|
||||
t.Setenv("PATH", robustTempDir(t))
|
||||
t.Setenv("REASONIX_CACHE_DIR", robustTempDir(t)) // isolate the codegraph bundle cache so Resolve fails deterministically
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
|
||||
[codegraph]
|
||||
enabled = false
|
||||
auto_install = true
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
defer app.activeCtrl().Close()
|
||||
|
||||
if err := app.SetMCPServerTier("codegraph", "eager"); err != nil {
|
||||
t.Fatalf("SetMCPServerTier(codegraph): %v", err)
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !cfg.Codegraph.Enabled {
|
||||
t.Fatal("codegraph enabled = false, want true after legacy tier update")
|
||||
}
|
||||
if got := cfg.Codegraph.Tier; got != "" {
|
||||
t.Fatalf("codegraph tier = %q, want ignored legacy tier", got)
|
||||
}
|
||||
userCfg := config.LoadForEdit(config.UserConfigPath())
|
||||
if !userCfg.Codegraph.Enabled {
|
||||
t.Fatal("user codegraph enabled = false, want true after legacy tier update")
|
||||
}
|
||||
if got := userCfg.Codegraph.Tier; got != "" {
|
||||
t.Fatalf("user codegraph tier = %q, want ignored legacy tier", got)
|
||||
}
|
||||
if !mcpFailed(app.activeCtrl(), "codegraph") {
|
||||
t.Fatalf("Host.Failures() = %+v, want codegraph failure recorded for missing runtime", app.activeCtrl().Host().Failures())
|
||||
}
|
||||
view := app.Capabilities()
|
||||
for _, s := range view.Servers {
|
||||
if s.Name == "codegraph" {
|
||||
if s.Status != "failed" {
|
||||
t.Fatalf("codegraph status = %q, want failed; server = %+v", s.Status, s)
|
||||
}
|
||||
if !s.BuiltIn || !s.Configured || s.Tier != "background" || !s.AutoStart {
|
||||
t.Fatalf("codegraph view did not preserve built-in config: %+v", s)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("codegraph missing from Capabilities: %+v", view.Servers)
|
||||
}
|
||||
|
||||
func TestSetMCPServerEnabledPersistsCodegraphOff(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
|
||||
[codegraph]
|
||||
enabled = true
|
||||
tier = "lazy"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
defer app.activeCtrl().Close()
|
||||
|
||||
if err := app.SetMCPServerEnabled("codegraph", false); err != nil {
|
||||
t.Fatalf("SetMCPServerEnabled(codegraph,false): %v", err)
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Codegraph.Enabled {
|
||||
t.Fatal("codegraph enabled = true, want false after disabling")
|
||||
}
|
||||
userCfg := config.LoadForEdit(config.UserConfigPath())
|
||||
if userCfg.Codegraph.Enabled {
|
||||
t.Fatal("user codegraph enabled = true, want false after disabling")
|
||||
}
|
||||
view := app.Capabilities()
|
||||
for _, s := range view.Servers {
|
||||
if s.Name == "codegraph" {
|
||||
if s.Status != "disabled" || s.AutoStart {
|
||||
t.Fatalf("codegraph disabled view = %+v, want disabled with autoStart=false", s)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("codegraph missing from Capabilities: %+v", view.Servers)
|
||||
}
|
||||
|
||||
func TestUpdateBuiltInMCPServerUpdatesCodegraphRuntime(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
|
||||
[codegraph]
|
||||
enabled = false
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
orig := updateBuiltInCodegraph
|
||||
defer func() { updateBuiltInCodegraph = orig }()
|
||||
called := false
|
||||
updateBuiltInCodegraph = func(ctx context.Context, client *http.Client, log func(string)) (codegraph.UpdateResult, error) {
|
||||
called = true
|
||||
if ctx == nil {
|
||||
t.Fatal("context is nil")
|
||||
}
|
||||
if client == nil {
|
||||
t.Fatal("http client is nil")
|
||||
}
|
||||
return codegraph.UpdateResult{Version: "v9.9.9", Path: filepath.Join(dir, "cache", "codegraph")}, nil
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
defer app.activeCtrl().Close()
|
||||
|
||||
got, err := app.UpdateBuiltInMCPServer("codegraph")
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateBuiltInMCPServer(codegraph): %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("updater was not called")
|
||||
}
|
||||
if got.Name != "codegraph" || got.Version != "v9.9.9" || got.Path == "" {
|
||||
t.Fatalf("UpdateBuiltInMCPServer result = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateBuiltInMCPServerRejectsOtherServers(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
defer app.activeCtrl().Close()
|
||||
|
||||
if _, err := app.UpdateBuiltInMCPServer("time"); err == nil {
|
||||
t.Fatal("UpdateBuiltInMCPServer(time) succeeded; want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltInMCPBackgroundNotifyDoesNotDownload(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
t.Setenv("REASONIX_CACHE_DIR", robustTempDir(t))
|
||||
|
||||
origCheck := checkCodegraphLatest
|
||||
origDownload := downloadLatestCodegraph
|
||||
origUpdate := updateBuiltInCodegraph
|
||||
defer func() {
|
||||
checkCodegraphLatest = origCheck
|
||||
downloadLatestCodegraph = origDownload
|
||||
updateBuiltInCodegraph = origUpdate
|
||||
}()
|
||||
|
||||
checkCodegraphLatest = func(ctx context.Context, client *http.Client) (string, error) {
|
||||
return "v99.99.99", nil
|
||||
}
|
||||
downloadLatestCodegraph = func(ctx context.Context, client *http.Client, log func(string)) (codegraph.UpdateResult, error) {
|
||||
t.Fatal("notify mode should not download")
|
||||
return codegraph.UpdateResult{}, nil
|
||||
}
|
||||
updateBuiltInCodegraph = func(ctx context.Context, client *http.Client, log func(string)) (codegraph.UpdateResult, error) {
|
||||
t.Fatal("notify mode should not activate")
|
||||
return codegraph.UpdateResult{}, nil
|
||||
}
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.BuiltInMCPUpdates.Mode = config.BuiltInMCPUpdateModeNotify
|
||||
statuses, err := NewApp().runBuiltInMCPUpdateCheck(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("runBuiltInMCPUpdateCheck: %v", err)
|
||||
}
|
||||
if len(statuses) != 1 || statuses[0].Phase != "available" || statuses[0].Latest != "v99.99.99" {
|
||||
t.Fatalf("statuses = %+v, want available latest v99.99.99", statuses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltInMCPUpdateStatusesReturnArray(t *testing.T) {
|
||||
app := NewApp()
|
||||
empty := app.BuiltInMCPUpdateStatuses()
|
||||
if empty == nil {
|
||||
t.Fatal("empty BuiltInMCPUpdateStatuses returned nil; Wails should encode []")
|
||||
}
|
||||
app.recordBuiltInMCPUpdateStatus(BuiltInMCPUpdateStatus{
|
||||
Name: "codegraph",
|
||||
Mode: "notify",
|
||||
Current: "v0.9.7",
|
||||
Latest: "v9.9.9",
|
||||
Phase: "available",
|
||||
})
|
||||
statuses := app.BuiltInMCPUpdateStatuses()
|
||||
if len(statuses) != 1 || statuses[0].Name != "codegraph" || statuses[0].Phase != "available" {
|
||||
t.Fatalf("BuiltInMCPUpdateStatuses = %+v, want codegraph available", statuses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltInMCPBackgroundDownloadDoesNotActivate(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
t.Setenv("REASONIX_CACHE_DIR", robustTempDir(t))
|
||||
|
||||
origCheck := checkCodegraphLatest
|
||||
origDownload := downloadLatestCodegraph
|
||||
origUpdate := updateBuiltInCodegraph
|
||||
defer func() {
|
||||
checkCodegraphLatest = origCheck
|
||||
downloadLatestCodegraph = origDownload
|
||||
updateBuiltInCodegraph = origUpdate
|
||||
}()
|
||||
|
||||
checkCodegraphLatest = func(ctx context.Context, client *http.Client) (string, error) {
|
||||
if _, ok := ctx.Deadline(); !ok {
|
||||
t.Fatal("manifest check context has no deadline")
|
||||
}
|
||||
return "v99.99.99", nil
|
||||
}
|
||||
downloaded := false
|
||||
downloadLatestCodegraph = func(ctx context.Context, client *http.Client, log func(string)) (codegraph.UpdateResult, error) {
|
||||
downloaded = true
|
||||
if deadline, ok := ctx.Deadline(); ok && time.Until(deadline) <= httpTimeout+time.Second {
|
||||
t.Fatalf("download context inherited manifest timeout; deadline=%v", deadline)
|
||||
}
|
||||
return codegraph.UpdateResult{Version: "v99.99.99", Path: filepath.Join(t.TempDir(), "codegraph")}, nil
|
||||
}
|
||||
updateBuiltInCodegraph = func(ctx context.Context, client *http.Client, log func(string)) (codegraph.UpdateResult, error) {
|
||||
t.Fatal("download mode should not activate")
|
||||
return codegraph.UpdateResult{}, nil
|
||||
}
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.BuiltInMCPUpdates.Mode = config.BuiltInMCPUpdateModeDownload
|
||||
statuses, err := NewApp().runBuiltInMCPUpdateCheck(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("runBuiltInMCPUpdateCheck: %v", err)
|
||||
}
|
||||
if !downloaded {
|
||||
t.Fatal("download mode did not call downloader")
|
||||
}
|
||||
if len(statuses) != 1 || statuses[0].Phase != "downloaded" || statuses[0].Path == "" {
|
||||
t.Fatalf("statuses = %+v, want downloaded with path", statuses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltInMCPBackgroundAutoNextSessionActivates(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
t.Setenv("REASONIX_CACHE_DIR", robustTempDir(t))
|
||||
|
||||
origCheck := checkCodegraphLatest
|
||||
origDownload := downloadLatestCodegraph
|
||||
origUpdate := updateBuiltInCodegraph
|
||||
defer func() {
|
||||
checkCodegraphLatest = origCheck
|
||||
downloadLatestCodegraph = origDownload
|
||||
updateBuiltInCodegraph = origUpdate
|
||||
}()
|
||||
|
||||
checkCodegraphLatest = func(ctx context.Context, client *http.Client) (string, error) {
|
||||
if _, ok := ctx.Deadline(); !ok {
|
||||
t.Fatal("manifest check context has no deadline")
|
||||
}
|
||||
return "v99.99.99", nil
|
||||
}
|
||||
downloadLatestCodegraph = func(ctx context.Context, client *http.Client, log func(string)) (codegraph.UpdateResult, error) {
|
||||
t.Fatal("auto_next_session mode should activate through the updater")
|
||||
return codegraph.UpdateResult{}, nil
|
||||
}
|
||||
activated := false
|
||||
updateBuiltInCodegraph = func(ctx context.Context, client *http.Client, log func(string)) (codegraph.UpdateResult, error) {
|
||||
activated = true
|
||||
if deadline, ok := ctx.Deadline(); ok && time.Until(deadline) <= httpTimeout+time.Second {
|
||||
t.Fatalf("activation context inherited manifest timeout; deadline=%v", deadline)
|
||||
}
|
||||
return codegraph.UpdateResult{Version: "v99.99.99", Path: filepath.Join(t.TempDir(), "codegraph")}, nil
|
||||
}
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.BuiltInMCPUpdates.Mode = config.BuiltInMCPUpdateModeAutoNextSession
|
||||
statuses, err := NewApp().runBuiltInMCPUpdateCheck(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("runBuiltInMCPUpdateCheck: %v", err)
|
||||
}
|
||||
if !activated {
|
||||
t.Fatal("auto_next_session mode did not activate")
|
||||
}
|
||||
if len(statuses) != 1 || statuses[0].Phase != "activated" || statuses[0].Path == "" {
|
||||
t.Fatalf("statuses = %+v, want activated with path", statuses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltInMCPUpdateIntervalStamp(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
origNow := builtInMCPUpdateNow
|
||||
defer func() { builtInMCPUpdateNow = origNow }()
|
||||
|
||||
now := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC)
|
||||
builtInMCPUpdateNow = func() time.Time { return now }
|
||||
if !shouldRunBuiltInMCPUpdateCheck(time.Hour) {
|
||||
t.Fatal("first update check should run without a stamp")
|
||||
}
|
||||
markBuiltInMCPUpdateChecked()
|
||||
if shouldRunBuiltInMCPUpdateCheck(time.Hour) {
|
||||
t.Fatal("update check should be suppressed inside interval")
|
||||
}
|
||||
builtInMCPUpdateNow = func() time.Time { return now.Add(2 * time.Hour) }
|
||||
if !shouldRunBuiltInMCPUpdateCheck(time.Hour) {
|
||||
t.Fatal("update check should run after interval")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilitiesMigratesFailedMCPConfiguredTierAfterRestart(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[[plugins]]
|
||||
name = "broken"
|
||||
command = "reasonix-missing-mcp-binary"
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
|
||||
"reasonix/internal/codegraph"
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
var (
|
||||
checkCodegraphLatest = codegraph.LatestVersionWithClient
|
||||
downloadLatestCodegraph = codegraph.DownloadLatestWithClient
|
||||
builtInMCPUpdateNow = time.Now
|
||||
)
|
||||
|
||||
type BuiltInMCPUpdateStatus struct {
|
||||
Name string `json:"name"`
|
||||
Mode string `json:"mode"`
|
||||
Current string `json:"current"`
|
||||
Latest string `json:"latest"`
|
||||
Phase string `json:"phase"` // current | available | downloaded | activated | skipped | error
|
||||
Path string `json:"path,omitempty"`
|
||||
Err string `json:"err,omitempty"`
|
||||
}
|
||||
|
||||
func (a *App) checkBuiltInMCPUpdates() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
mode := cfg.BuiltInMCPUpdates.ResolvedMode()
|
||||
if mode == config.BuiltInMCPUpdateModeOff {
|
||||
return
|
||||
}
|
||||
if !shouldRunBuiltInMCPUpdateCheck(cfg.BuiltInMCPUpdates.CheckIntervalDuration()) {
|
||||
return
|
||||
}
|
||||
statuses, _ := a.runBuiltInMCPUpdateCheck(cfg)
|
||||
markBuiltInMCPUpdateChecked()
|
||||
for _, status := range statuses {
|
||||
a.recordBuiltInMCPUpdateStatus(status)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) runBuiltInMCPUpdateCheck(cfg *config.Config) ([]BuiltInMCPUpdateStatus, error) {
|
||||
if cfg == nil {
|
||||
cfg = config.Default()
|
||||
}
|
||||
mode := cfg.BuiltInMCPUpdates.ResolvedMode()
|
||||
current := codegraph.ActiveVersion()
|
||||
if current == "" {
|
||||
current = codegraph.Version
|
||||
}
|
||||
status := BuiltInMCPUpdateStatus{
|
||||
Name: "codegraph",
|
||||
Mode: mode,
|
||||
Current: current,
|
||||
Phase: "skipped",
|
||||
}
|
||||
if mode == config.BuiltInMCPUpdateModeOff {
|
||||
return []BuiltInMCPUpdateStatus{status}, nil
|
||||
}
|
||||
client, err := httpClient()
|
||||
if err != nil {
|
||||
status.Phase = "error"
|
||||
status.Err = err.Error()
|
||||
return []BuiltInMCPUpdateStatus{status}, nil
|
||||
}
|
||||
manifestCtx, cancel := context.WithTimeout(a.reqCtx(), httpTimeout)
|
||||
defer cancel()
|
||||
latest, err := checkCodegraphLatest(manifestCtx, client)
|
||||
if err != nil {
|
||||
status.Phase = "error"
|
||||
status.Err = err.Error()
|
||||
return []BuiltInMCPUpdateStatus{status}, nil
|
||||
}
|
||||
status.Latest = latest
|
||||
if !codegraph.NewerThanActive(latest) {
|
||||
status.Phase = "current"
|
||||
return []BuiltInMCPUpdateStatus{status}, nil
|
||||
}
|
||||
switch mode {
|
||||
case config.BuiltInMCPUpdateModeDownload:
|
||||
res, err := downloadLatestCodegraph(a.reqCtx(), client, nil)
|
||||
if err != nil {
|
||||
status.Phase = "error"
|
||||
status.Err = err.Error()
|
||||
return []BuiltInMCPUpdateStatus{status}, nil
|
||||
}
|
||||
status.Latest = res.Version
|
||||
status.Path = res.Path
|
||||
status.Phase = "downloaded"
|
||||
case config.BuiltInMCPUpdateModeAutoNextSession:
|
||||
res, err := updateBuiltInCodegraph(a.reqCtx(), client, nil)
|
||||
if err != nil {
|
||||
status.Phase = "error"
|
||||
status.Err = err.Error()
|
||||
return []BuiltInMCPUpdateStatus{status}, nil
|
||||
}
|
||||
status.Latest = res.Version
|
||||
status.Path = res.Path
|
||||
status.Phase = "activated"
|
||||
default:
|
||||
status.Phase = "available"
|
||||
}
|
||||
return []BuiltInMCPUpdateStatus{status}, nil
|
||||
}
|
||||
|
||||
func (a *App) emitBuiltInMCPUpdateStatus(status BuiltInMCPUpdateStatus) {
|
||||
if a.ctx == nil {
|
||||
return
|
||||
}
|
||||
wruntime.EventsEmit(a.ctx, "builtin-mcp:update", status)
|
||||
}
|
||||
|
||||
func (a *App) recordBuiltInMCPUpdateStatus(status BuiltInMCPUpdateStatus) {
|
||||
if strings.TrimSpace(status.Name) == "" {
|
||||
return
|
||||
}
|
||||
a.builtInMCPUpdatesMu.Lock()
|
||||
if a.builtInMCPUpdates == nil {
|
||||
a.builtInMCPUpdates = map[string]BuiltInMCPUpdateStatus{}
|
||||
}
|
||||
a.builtInMCPUpdates[status.Name] = status
|
||||
a.builtInMCPUpdatesMu.Unlock()
|
||||
a.emitBuiltInMCPUpdateStatus(status)
|
||||
}
|
||||
|
||||
func (a *App) BuiltInMCPUpdateStatuses() []BuiltInMCPUpdateStatus {
|
||||
a.builtInMCPUpdatesMu.RLock()
|
||||
defer a.builtInMCPUpdatesMu.RUnlock()
|
||||
out := make([]BuiltInMCPUpdateStatus, 0, len(a.builtInMCPUpdates))
|
||||
for _, status := range a.builtInMCPUpdates {
|
||||
out = append(out, status)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func shouldRunBuiltInMCPUpdateCheck(interval time.Duration) bool {
|
||||
if interval <= 0 {
|
||||
interval = 24 * time.Hour
|
||||
}
|
||||
path, err := builtInMCPUpdateStampPath()
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
last, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(string(data)))
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
return !builtInMCPUpdateNow().Before(last.Add(interval))
|
||||
}
|
||||
|
||||
func markBuiltInMCPUpdateChecked() {
|
||||
path, err := builtInMCPUpdateStampPath()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.WriteFile(path, []byte(builtInMCPUpdateNow().Format(time.RFC3339Nano)+"\n"), 0o644)
|
||||
}
|
||||
|
||||
func builtInMCPUpdateStampPath() (string, error) {
|
||||
base, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if base == "" {
|
||||
return "", fmt.Errorf("user cache dir is empty")
|
||||
}
|
||||
return filepath.Join(base, "reasonix", "builtin-mcp-updates", "last-check"), nil
|
||||
}
|
||||
@@ -31,7 +31,7 @@ import { useToast } from "./lib/toast";
|
||||
import { asArray } from "./lib/array";
|
||||
import { clearLegacyLangPref, normalizeLangPref, readLegacyLangPref, useI18n, useT, type Translator } from "./lib/i18n";
|
||||
import { useController, type Item, type LiveStream } from "./lib/useController";
|
||||
import { app, onBuiltInMCPUpdate, onEvent, onProjectTreeChanged } from "./lib/bridge";
|
||||
import { app, onEvent, onProjectTreeChanged } from "./lib/bridge";
|
||||
import { generativeMusic, isGenerativeMusicEnabled } from "./lib/generative-music";
|
||||
import { playSuccessChime } from "./lib/sound";
|
||||
import { Transcript } from "./components/Transcript";
|
||||
@@ -906,21 +906,6 @@ export default function App() {
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return onBuiltInMCPUpdate((status) => {
|
||||
if (status.name !== "codegraph") return;
|
||||
if (status.phase === "available") {
|
||||
showToast(t("caps.updateToastAvailable", { name: "codegraph", latest: status.latest || "" }));
|
||||
} else if (status.phase === "downloaded") {
|
||||
showToast(t("caps.updateToastDownloaded", { name: "codegraph", latest: status.latest || "" }));
|
||||
} else if (status.phase === "activated") {
|
||||
showToast(t("caps.updateToastActivated", { name: "codegraph", latest: status.latest || "" }));
|
||||
} else if (status.phase === "error") {
|
||||
showToast(t("caps.updateToastError", { name: "codegraph" }), "warn");
|
||||
}
|
||||
});
|
||||
}, [showToast, t]);
|
||||
|
||||
const [workspacePanelResizing, setWorkspacePanelResizing] = useState(false);
|
||||
const [workspacePanelMaximized, setWorkspacePanelMaximized] = useState(false);
|
||||
const [rightDockMode, setRightDockMode] = useState<RightDockMode>("context");
|
||||
|
||||
@@ -138,6 +138,18 @@ for (const selector of [
|
||||
eq(finalDeclaration(".composer-modebar", "overflow"), "hidden", "chat mode switcher contains enlarged labels");
|
||||
eq(finalDeclaration(".md table", "overflow-x"), "auto", "markdown tables scroll horizontally");
|
||||
eq(finalDeclaration(".code", "overflow"), "auto", "code blocks scroll instead of widening the layout");
|
||||
ok(
|
||||
/@media\s*\(max-width:\s*900px\)[\s\S]*?\.settings-center\s*\{[\s\S]*?grid-template-columns\s*:\s*1fr/.test(styles),
|
||||
"settings center stacks navigation before the modal is too narrow",
|
||||
);
|
||||
ok(
|
||||
/@media\s*\(max-width:\s*900px\)[\s\S]*?\.settings-field\s*\{[\s\S]*?grid-template-columns\s*:\s*1fr/.test(styles),
|
||||
"settings fields collapse to one column at the mid-width breakpoint",
|
||||
);
|
||||
ok(
|
||||
/@media\s*\(max-width:\s*760px\)[\s\S]*?\.settings-modal\s*\{[\s\S]*?width\s*:\s*100vw[\s\S]*?height\s*:\s*100vh/.test(styles),
|
||||
"settings modal only becomes fullscreen at the narrow breakpoint",
|
||||
);
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { asArray } from "../lib/array";
|
||||
import { app, onBuiltInMCPUpdate, openExternal } from "../lib/bridge";
|
||||
import { app, openExternal } from "../lib/bridge";
|
||||
import { useT } from "../lib/i18n";
|
||||
import type { BuiltInMCPUpdateStatus, CapabilitiesView, MCPServerInput, ServerView, SkillRootSkillView, SkillRootView, SkillView } from "../lib/types";
|
||||
import type { CapabilitiesView, MCPServerInput, ServerView, SkillRootSkillView, SkillRootView, SkillView } from "../lib/types";
|
||||
import { InlineConfirmButton } from "./InlineConfirmButton";
|
||||
import { ResizableDrawer } from "./ResizableDrawer";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
@@ -33,32 +33,13 @@ export function CapabilitiesPanel({
|
||||
const [expandedErrors, setExpandedErrors] = useState<Set<string>>(() => new Set());
|
||||
const [expandedServers, setExpandedServers] = useState<Set<string>>(() => new Set());
|
||||
const [expandedServerTools, setExpandedServerTools] = useState<Set<string>>(() => new Set());
|
||||
const [updateStatuses, setUpdateStatuses] = useState<Record<string, BuiltInMCPUpdateStatus>>({});
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setView(normalizeCapabilitiesView(await app.Capabilities().catch(() => ({ servers: [], skills: [], skillRoots: [] }))));
|
||||
}, []);
|
||||
const reloadUpdateStatuses = useCallback(async () => {
|
||||
setUpdateStatuses(normalizeBuiltInMCPUpdateStatuses(await app.BuiltInMCPUpdateStatuses().catch(() => [])));
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void app.BuiltInMCPUpdateStatuses()
|
||||
.then((statuses) => {
|
||||
if (!cancelled) setUpdateStatuses(normalizeBuiltInMCPUpdateStatuses(statuses));
|
||||
})
|
||||
.catch(() => {});
|
||||
const unsubscribe = onBuiltInMCPUpdate((status) => {
|
||||
setUpdateStatuses((prev) => mergeBuiltInMCPUpdateStatus(prev, status));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (tab !== "servers" || !view?.servers.some((s) => s.status === "initializing" || s.status === "deferred")) return;
|
||||
const id = window.setInterval(() => void reload(), 2500);
|
||||
@@ -83,12 +64,6 @@ export function CapabilitiesPanel({
|
||||
}
|
||||
};
|
||||
|
||||
const upgradeBuiltInMCP = async (name: string) => {
|
||||
const ok = await mutate(() => app.UpdateBuiltInMCPServer(name));
|
||||
await reloadUpdateStatuses();
|
||||
return ok;
|
||||
};
|
||||
|
||||
const summary = useMemo(() => {
|
||||
if (!view) return "";
|
||||
return t("caps.summary", {
|
||||
@@ -119,11 +94,6 @@ export function CapabilitiesPanel({
|
||||
active: servers.filter((s) => s.status !== "failed"),
|
||||
};
|
||||
}, [view]);
|
||||
const visibleUpdateStatuses = useMemo(
|
||||
() => Object.values(updateStatuses).filter((status) => isVisibleBuiltInMCPUpdateStatus(status)),
|
||||
[updateStatuses],
|
||||
);
|
||||
|
||||
const toggleSkill = useCallback((name: string) => {
|
||||
setExpandedSkills((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -204,13 +174,6 @@ export function CapabilitiesPanel({
|
||||
|
||||
{tab === "servers" ? (
|
||||
<section className="mem-section">
|
||||
{visibleUpdateStatuses.length > 0 && (
|
||||
<BuiltInMCPUpdateNotice
|
||||
statuses={visibleUpdateStatuses}
|
||||
busy={busy}
|
||||
onUpgrade={(name) => void upgradeBuiltInMCP(name)}
|
||||
/>
|
||||
)}
|
||||
<div className="cap-mcp-toolbar cap-mcp-toolbar--drawer">
|
||||
{!adding && (
|
||||
<button className="btn btn--small" disabled={busy} onClick={() => setAdding(true)}>
|
||||
@@ -225,8 +188,6 @@ export function CapabilitiesPanel({
|
||||
onToggle={toggleError}
|
||||
onRetry={(name) => void mutate(() => app.ReconnectMCPServer(name))}
|
||||
onRetryMany={(names) => void mutate(() => Promise.allSettled(names.map((name) => app.ReconnectMCPServer(name))))}
|
||||
onUpgrade={(name) => void upgradeBuiltInMCP(name)}
|
||||
updates={updateStatuses}
|
||||
onConfirmClearAuth={(name) => void mutate(() => app.ClearMCPServerAuthentication(name))}
|
||||
onConfirm={(name) => void mutate(() => app.RemoveMCPServer(name))}
|
||||
onConfirmMany={(names) => void mutate(() => Promise.allSettled(names.map((name) => app.RemoveMCPServer(name))))}
|
||||
@@ -242,7 +203,6 @@ export function CapabilitiesPanel({
|
||||
<ServerGroup
|
||||
busy={busy}
|
||||
servers={serverGroups.active}
|
||||
updates={updateStatuses}
|
||||
expanded={expandedServers}
|
||||
expandedTools={expandedServerTools}
|
||||
editing={editing}
|
||||
@@ -253,7 +213,6 @@ export function CapabilitiesPanel({
|
||||
onCancelEdit={() => setEditing(null)}
|
||||
onRetry={(name) => void mutate(() => app.ReconnectMCPServer(name))}
|
||||
onReconnect={(name) => void mutate(() => app.ReconnectMCPServer(name))}
|
||||
onUpgrade={(name) => void upgradeBuiltInMCP(name)}
|
||||
onConfirmClearAuth={(name) => void mutate(() => app.ClearMCPServerAuthentication(name))}
|
||||
onToggle={(name, on) => void mutate(() => app.SetMCPServerEnabled(name, on))}
|
||||
onUpdate={(name, input) =>
|
||||
@@ -342,84 +301,6 @@ function normalizeCapabilitiesView(view: CapabilitiesView | null | undefined): C
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBuiltInMCPUpdateStatuses(
|
||||
statuses: BuiltInMCPUpdateStatus[] | null | undefined,
|
||||
): Record<string, BuiltInMCPUpdateStatus> {
|
||||
const out: Record<string, BuiltInMCPUpdateStatus> = {};
|
||||
for (const status of asArray(statuses)) {
|
||||
const name = (status?.name || "").trim();
|
||||
if (!name) continue;
|
||||
out[name] = {
|
||||
...status,
|
||||
name,
|
||||
phase: normalizeBuiltInMCPUpdatePhase(status.phase),
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function mergeBuiltInMCPUpdateStatus(
|
||||
prev: Record<string, BuiltInMCPUpdateStatus>,
|
||||
status: BuiltInMCPUpdateStatus,
|
||||
): Record<string, BuiltInMCPUpdateStatus> {
|
||||
const name = (status?.name || "").trim();
|
||||
if (!name) return prev;
|
||||
return {
|
||||
...prev,
|
||||
[name]: {
|
||||
...status,
|
||||
name,
|
||||
phase: normalizeBuiltInMCPUpdatePhase(status.phase),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBuiltInMCPUpdatePhase(phase: string): BuiltInMCPUpdateStatus["phase"] {
|
||||
switch (phase) {
|
||||
case "current":
|
||||
case "available":
|
||||
case "downloaded":
|
||||
case "activated":
|
||||
case "skipped":
|
||||
case "error":
|
||||
return phase;
|
||||
default:
|
||||
return "skipped";
|
||||
}
|
||||
}
|
||||
|
||||
function isVisibleBuiltInMCPUpdateStatus(status: BuiltInMCPUpdateStatus): boolean {
|
||||
return status.name === "codegraph" && ["available", "downloaded", "activated", "error"].includes(status.phase);
|
||||
}
|
||||
|
||||
function BuiltInMCPUpdateNotice({
|
||||
statuses,
|
||||
busy,
|
||||
onUpgrade,
|
||||
}: {
|
||||
statuses: BuiltInMCPUpdateStatus[];
|
||||
busy: boolean;
|
||||
onUpgrade: (name: string) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const primary = statuses[0];
|
||||
if (!primary) return null;
|
||||
const canUpgrade = primary.name === "codegraph" && (primary.phase === "available" || primary.phase === "downloaded");
|
||||
return (
|
||||
<div className={`cap-update-notice cap-update-notice--${primary.phase}`}>
|
||||
<div className="cap-update-notice__text">
|
||||
<div className="cap-update-notice__title">{t("caps.updateNoticeTitle")}</div>
|
||||
<div className="cap-update-notice__summary">{builtInMCPUpdateSummary(primary, t)}</div>
|
||||
</div>
|
||||
{canUpgrade && (
|
||||
<button className="btn btn--small" disabled={busy} type="button" onClick={() => onUpgrade(primary.name)}>
|
||||
{t("caps.upgradeBuiltInMCP")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function sortServersForDisplay(servers: ServerView[]): ServerView[] {
|
||||
return [...servers].sort((a, b) => {
|
||||
const priority = serverDisplayPriority(a) - serverDisplayPriority(b);
|
||||
@@ -725,7 +606,6 @@ function skillRootBadges(root: SkillRootView, t: ReturnType<typeof useT>): Array
|
||||
|
||||
function ServerGroup({
|
||||
servers,
|
||||
updates,
|
||||
expanded,
|
||||
expandedTools,
|
||||
busy,
|
||||
@@ -735,7 +615,6 @@ function ServerGroup({
|
||||
onCancelEdit,
|
||||
onRetry,
|
||||
onReconnect,
|
||||
onUpgrade,
|
||||
onConfirmClearAuth,
|
||||
onToggle,
|
||||
onUpdate,
|
||||
@@ -743,7 +622,6 @@ function ServerGroup({
|
||||
onToggleTools,
|
||||
}: {
|
||||
servers: ServerView[];
|
||||
updates: Record<string, BuiltInMCPUpdateStatus>;
|
||||
expanded: Set<string>;
|
||||
expandedTools: Set<string>;
|
||||
busy: boolean;
|
||||
@@ -753,7 +631,6 @@ function ServerGroup({
|
||||
onCancelEdit: () => void;
|
||||
onRetry: (name: string) => void;
|
||||
onReconnect: (name: string) => void;
|
||||
onUpgrade: (name: string) => void;
|
||||
onConfirmClearAuth: (name: string) => void;
|
||||
onToggle: (name: string, on: boolean) => void;
|
||||
onUpdate: (name: string, input: MCPServerInput) => void;
|
||||
@@ -767,7 +644,6 @@ function ServerGroup({
|
||||
<ServerRow
|
||||
key={s.name}
|
||||
s={s}
|
||||
updateStatus={updates[s.name]}
|
||||
expanded={expanded.has(s.name)}
|
||||
toolsExpanded={expandedTools.has(s.name)}
|
||||
busy={busy}
|
||||
@@ -777,7 +653,6 @@ function ServerGroup({
|
||||
onCancelEdit={onCancelEdit}
|
||||
onRetry={() => onRetry(s.name)}
|
||||
onReconnect={() => onReconnect(s.name)}
|
||||
onUpgrade={() => onUpgrade(s.name)}
|
||||
onConfirmClearAuth={() => onConfirmClearAuth(s.name)}
|
||||
onToggle={(on) => onToggle(s.name, on)}
|
||||
onUpdate={(input) => onUpdate(s.name, input)}
|
||||
@@ -791,24 +666,20 @@ function ServerGroup({
|
||||
|
||||
function FailedServersNotice({
|
||||
servers,
|
||||
updates,
|
||||
expanded,
|
||||
busy,
|
||||
onToggle,
|
||||
onRetry,
|
||||
onUpgrade,
|
||||
onRetryMany,
|
||||
onConfirmClearAuth,
|
||||
onConfirm,
|
||||
onConfirmMany,
|
||||
}: {
|
||||
servers: ServerView[];
|
||||
updates: Record<string, BuiltInMCPUpdateStatus>;
|
||||
expanded: Set<string>;
|
||||
busy: boolean;
|
||||
onToggle: (name: string) => void;
|
||||
onRetry: (name: string) => void;
|
||||
onUpgrade: (name: string) => void;
|
||||
onRetryMany: (names: string[]) => void;
|
||||
onConfirmClearAuth: (name: string) => void;
|
||||
onConfirm: (name: string) => void;
|
||||
@@ -878,19 +749,13 @@ function FailedServersNotice({
|
||||
<span className="cap-dot cap-dot--failed" />
|
||||
<div className="cap-failure__text">
|
||||
<div className="cap-failure__name">{s.name}</div>
|
||||
<div className="cap-failure__summary">{s.authStatus === "required" ? t("caps.authRequiredSummary") : summarizeServerError(error, t)}</div>
|
||||
{updates[s.name] && <div className="cap-failure__summary">{builtInMCPUpdateSummary(updates[s.name], t)}</div>}
|
||||
<div className="cap-failure__summary">{s.authStatus === "required" ? t("caps.authRequiredSummary") : summarizeServerError(error)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cap-failure__actions">
|
||||
<button className="btn btn--small" disabled={busy} onClick={handlePrimaryAction}>
|
||||
{actionLabel}
|
||||
</button>
|
||||
{canUpgradeBuiltInMCP(s, updates[s.name]) && (
|
||||
<button className="btn btn--small" disabled={busy} onClick={() => onUpgrade(s.name)}>
|
||||
{t("caps.upgradeBuiltInMCP")}
|
||||
</button>
|
||||
)}
|
||||
{canClearAuth(s) && (
|
||||
<InlineConfirmButton
|
||||
label={t("caps.clearAuth")}
|
||||
@@ -935,7 +800,6 @@ function FailedServersNotice({
|
||||
|
||||
function ServerRow({
|
||||
s,
|
||||
updateStatus,
|
||||
expanded,
|
||||
toolsExpanded,
|
||||
busy,
|
||||
@@ -945,7 +809,6 @@ function ServerRow({
|
||||
onCancelEdit,
|
||||
onRetry,
|
||||
onReconnect,
|
||||
onUpgrade,
|
||||
onConfirmClearAuth,
|
||||
onToggle,
|
||||
onUpdate,
|
||||
@@ -953,7 +816,6 @@ function ServerRow({
|
||||
onToggleTools,
|
||||
}: {
|
||||
s: ServerView;
|
||||
updateStatus?: BuiltInMCPUpdateStatus;
|
||||
expanded: boolean;
|
||||
toolsExpanded: boolean;
|
||||
busy: boolean;
|
||||
@@ -963,7 +825,6 @@ function ServerRow({
|
||||
onCancelEdit: () => void;
|
||||
onRetry: () => void;
|
||||
onReconnect: () => void;
|
||||
onUpgrade: () => void;
|
||||
onConfirmClearAuth: () => void;
|
||||
onToggle: (on: boolean) => void;
|
||||
onUpdate: (input: MCPServerInput) => void;
|
||||
@@ -988,10 +849,6 @@ function ServerRow({
|
||||
if (s.authStatus === "possible" && s.status !== "failed") {
|
||||
sub = `${sub} · ${t("caps.authPossibleShort")}`;
|
||||
}
|
||||
const updateSummary = builtInMCPRowUpdateSummary(s, updateStatus, t);
|
||||
if (updateSummary) {
|
||||
sub = `${sub} · ${updateSummary}`;
|
||||
}
|
||||
const enabled = s.status === "connected" || s.status === "deferred" || s.status === "initializing";
|
||||
const handlePrimaryAction = () => {
|
||||
if (shouldOpenAuth(s)) {
|
||||
@@ -1019,11 +876,6 @@ function ServerRow({
|
||||
<span className="cap-row__name">{s.name}</span>
|
||||
<span className="cap-row__transport">{s.transport}</span>
|
||||
{s.builtIn && <span className="cap-row__builtin">{t("caps.builtIn")}</span>}
|
||||
{isVisibleBuiltInMCPUpdateStatusForServer(s, updateStatus) && (
|
||||
<span className={`cap-row__builtin cap-row__update cap-row__update--${updateStatus.phase}`}>
|
||||
{builtInMCPUpdateBadge(updateStatus, t)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="cap-row__sub">{sub}</div>
|
||||
</div>
|
||||
@@ -1053,13 +905,11 @@ function ServerRow({
|
||||
{expanded && (
|
||||
<ServerDetails
|
||||
s={s}
|
||||
updateStatus={updateStatus}
|
||||
tools={tools}
|
||||
busy={busy}
|
||||
onConfirm={onConfirm}
|
||||
onConnectNow={onRetry}
|
||||
onReconnect={onReconnect}
|
||||
onUpgrade={onUpgrade}
|
||||
onConfirmClearAuth={onConfirmClearAuth}
|
||||
toolsExpanded={toolsExpanded}
|
||||
editing={editing}
|
||||
@@ -1075,13 +925,11 @@ function ServerRow({
|
||||
|
||||
function ServerDetails({
|
||||
s,
|
||||
updateStatus,
|
||||
tools,
|
||||
busy,
|
||||
onConfirm,
|
||||
onConnectNow,
|
||||
onReconnect,
|
||||
onUpgrade,
|
||||
onConfirmClearAuth,
|
||||
toolsExpanded,
|
||||
editing,
|
||||
@@ -1091,13 +939,11 @@ function ServerDetails({
|
||||
onToggleTools,
|
||||
}: {
|
||||
s: ServerView;
|
||||
updateStatus?: BuiltInMCPUpdateStatus;
|
||||
tools: ServerView["toolList"];
|
||||
busy: boolean;
|
||||
onConfirm: () => void;
|
||||
onConnectNow: () => void;
|
||||
onReconnect: () => void;
|
||||
onUpgrade: () => void;
|
||||
onConfirmClearAuth: () => void;
|
||||
toolsExpanded: boolean;
|
||||
editing: boolean;
|
||||
@@ -1111,7 +957,6 @@ function ServerDetails({
|
||||
const canEditConfig = s.configured && !s.builtIn;
|
||||
const canConnectNow = s.status === "deferred" || s.status === "disabled";
|
||||
const canReconnect = s.status === "connected";
|
||||
const canUpgrade = canUpgradeBuiltInMCP(s, updateStatus);
|
||||
const canShowTools = s.status === "connected" && ((s.tools ?? 0) > 0 || (tools?.length ?? 0) > 0);
|
||||
const showClearAuth = canClearAuth(s);
|
||||
const authLabel = serverAuthLabel(s, t);
|
||||
@@ -1139,12 +984,6 @@ function ServerDetails({
|
||||
<span className="cap-detail__value">{authLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
{s.builtIn && (
|
||||
<div className="cap-detail cap-detail--wide">
|
||||
<span className="cap-detail__label">{t("caps.updatePolicy")}</span>
|
||||
<span className="cap-detail__value">{builtInMCPUpdatePolicy(s, updateStatus, t)}</span>
|
||||
</div>
|
||||
)}
|
||||
{command && (
|
||||
<div className="cap-detail cap-detail--wide">
|
||||
<span className="cap-detail__label">{s.transport === "stdio" ? t("caps.command") : t("caps.url")}</span>
|
||||
@@ -1169,11 +1008,6 @@ function ServerDetails({
|
||||
{t("caps.reconnect")}
|
||||
</button>
|
||||
)}
|
||||
{canUpgrade && (
|
||||
<button className="btn btn--small" disabled={busy} onClick={onUpgrade}>
|
||||
{t("caps.upgradeBuiltInMCP")}
|
||||
</button>
|
||||
)}
|
||||
{canShowTools && (
|
||||
<button className="btn btn--small" disabled={busy} onClick={onToggleTools} aria-expanded={toolsExpanded}>
|
||||
{toolsExpanded ? t("caps.hideTools") : t("caps.showTools")}
|
||||
@@ -1324,72 +1158,6 @@ function parseEnvText(env: string): Record<string, string> {
|
||||
return envMap;
|
||||
}
|
||||
|
||||
function isVisibleBuiltInMCPUpdateStatusForServer(s: ServerView, status?: BuiltInMCPUpdateStatus): status is BuiltInMCPUpdateStatus {
|
||||
return Boolean(s.builtIn && status && isVisibleBuiltInMCPUpdateStatus(status));
|
||||
}
|
||||
|
||||
function builtInMCPRowUpdateSummary(
|
||||
s: ServerView,
|
||||
status: BuiltInMCPUpdateStatus | undefined,
|
||||
t: ReturnType<typeof useT>,
|
||||
): string {
|
||||
if (!s.builtIn) return "";
|
||||
if (s.name === "codegraph" && status && status.phase !== "current" && status.phase !== "skipped") {
|
||||
return builtInMCPUpdateSummary(status, t);
|
||||
}
|
||||
if (s.name === "context7") return t("caps.updateManagedByNpxShort");
|
||||
if (s.name === "time") return t("caps.updateBundledWithAppShort");
|
||||
return "";
|
||||
}
|
||||
|
||||
function builtInMCPUpdatePolicy(
|
||||
s: ServerView,
|
||||
status: BuiltInMCPUpdateStatus | undefined,
|
||||
t: ReturnType<typeof useT>,
|
||||
): string {
|
||||
if (s.name === "codegraph") {
|
||||
if (status) return builtInMCPUpdateSummary(status, t);
|
||||
return t("caps.updateCodegraphManual");
|
||||
}
|
||||
if (s.name === "context7") return t("caps.updateManagedByNpx");
|
||||
if (s.name === "time") return t("caps.updateBundledWithApp");
|
||||
return t("caps.updateNotManaged");
|
||||
}
|
||||
|
||||
function builtInMCPUpdateSummary(status: BuiltInMCPUpdateStatus, t: ReturnType<typeof useT>): string {
|
||||
const current = status.current || "";
|
||||
const latest = status.latest || "";
|
||||
switch (status.phase) {
|
||||
case "available":
|
||||
return t("caps.updateAvailable", { current, latest });
|
||||
case "downloaded":
|
||||
return t("caps.updateDownloaded", { current, latest });
|
||||
case "activated":
|
||||
return t("caps.updateActivated", { latest });
|
||||
case "error":
|
||||
return status.err ? t("caps.updateErrorWithMessage", { err: status.err }) : t("caps.updateError");
|
||||
case "current":
|
||||
return t("caps.updateCurrent", { current });
|
||||
default:
|
||||
return t("caps.updateCodegraphManual");
|
||||
}
|
||||
}
|
||||
|
||||
function builtInMCPUpdateBadge(status: BuiltInMCPUpdateStatus, t: ReturnType<typeof useT>): string {
|
||||
switch (status.phase) {
|
||||
case "available":
|
||||
return t("caps.updateBadgeAvailable");
|
||||
case "downloaded":
|
||||
return t("caps.updateBadgeDownloaded");
|
||||
case "activated":
|
||||
return t("caps.updateBadgeActivated");
|
||||
case "error":
|
||||
return t("caps.updateBadgeError");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function serverStatusLabel(s: ServerView, t: ReturnType<typeof useT>): string {
|
||||
switch (s.status) {
|
||||
case "connected":
|
||||
@@ -1408,12 +1176,9 @@ function serverStatusLabel(s: ServerView, t: ReturnType<typeof useT>): string {
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeServerError(error: string, t: ReturnType<typeof useT>): string {
|
||||
function summarizeServerError(error: string): string {
|
||||
const normalized = error.replace(/\s+/g, " ").trim();
|
||||
const plugin = normalized.match(/plugin "([^"]+)"/i)?.[1];
|
||||
if (plugin === "codegraph" && normalized.includes("context deadline exceeded")) {
|
||||
return t("caps.codegraphWarming");
|
||||
}
|
||||
const npmCode = normalized.match(/\bnpm error code ([A-Z0-9_]+)/i)?.[1];
|
||||
const errno = normalized.match(/\berrno (-?\d+)/i)?.[1];
|
||||
const reason = npmCode
|
||||
@@ -1515,12 +1280,6 @@ function canClearAuth(s: ServerView): boolean {
|
||||
return Boolean(s.authConfigured || s.authStatus === "required" || s.authStatus === "possible" || isRemoteTransport(s.transport));
|
||||
}
|
||||
|
||||
function canUpgradeBuiltInMCP(s: ServerView, status?: BuiltInMCPUpdateStatus): boolean {
|
||||
if (!s.builtIn || s.name !== "codegraph") return false;
|
||||
if (!status) return true;
|
||||
return status.phase === "available" || status.phase === "downloaded" || status.phase === "error";
|
||||
}
|
||||
|
||||
function isRemoteTransport(transport?: string): boolean {
|
||||
const value = (transport || "").trim().toLowerCase();
|
||||
return value === "http" || value === "streamable-http" || value === "sse";
|
||||
@@ -1681,30 +1440,11 @@ export function MCPServersSettingsPage() {
|
||||
const [expandedErrors, setExpandedErrors] = useState<Set<string>>(() => new Set());
|
||||
const [expandedServers, setExpandedServers] = useState<Set<string>>(() => new Set());
|
||||
const [expandedServerTools, setExpandedServerTools] = useState<Set<string>>(() => new Set());
|
||||
const [updateStatuses, setUpdateStatuses] = useState<Record<string, BuiltInMCPUpdateStatus>>({});
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setView(normalizeCapabilitiesView(await app.Capabilities().catch(() => ({ servers: [], skills: [], skillRoots: [] }))));
|
||||
}, []);
|
||||
const reloadUpdateStatuses = useCallback(async () => {
|
||||
setUpdateStatuses(normalizeBuiltInMCPUpdateStatuses(await app.BuiltInMCPUpdateStatuses().catch(() => [])));
|
||||
}, []);
|
||||
useEffect(() => { void reload(); }, [reload]);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void app.BuiltInMCPUpdateStatuses()
|
||||
.then((statuses) => {
|
||||
if (!cancelled) setUpdateStatuses(normalizeBuiltInMCPUpdateStatuses(statuses));
|
||||
})
|
||||
.catch(() => {});
|
||||
const unsubscribe = onBuiltInMCPUpdate((status) => {
|
||||
setUpdateStatuses((prev) => mergeBuiltInMCPUpdateStatus(prev, status));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!view || !view.servers.some((s) => s.status === "initializing" || s.status === "deferred")) return;
|
||||
const id = window.setInterval(() => void reload(), 2500);
|
||||
@@ -1726,12 +1466,6 @@ export function MCPServersSettingsPage() {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const upgradeBuiltInMCP = async (name: string) => {
|
||||
const ok = await mutate(() => app.UpdateBuiltInMCPServer(name));
|
||||
await reloadUpdateStatuses();
|
||||
return ok;
|
||||
};
|
||||
|
||||
const serverGroups = useMemo(() => {
|
||||
const servers = sortServersForDisplay(view?.servers ?? []);
|
||||
return {
|
||||
@@ -1739,11 +1473,6 @@ export function MCPServersSettingsPage() {
|
||||
active: servers.filter((s) => s.status !== "failed"),
|
||||
};
|
||||
}, [view]);
|
||||
const visibleUpdateStatuses = useMemo(
|
||||
() => Object.values(updateStatuses).filter((status) => isVisibleBuiltInMCPUpdateStatus(status)),
|
||||
[updateStatuses],
|
||||
);
|
||||
|
||||
const toggleError = useCallback((name: string) => {
|
||||
setExpandedErrors((prev) => { const next = new Set(prev); if (next.has(name)) next.delete(name); else next.add(name); return next; });
|
||||
}, []);
|
||||
@@ -1764,13 +1493,6 @@ export function MCPServersSettingsPage() {
|
||||
return (
|
||||
<section className="mem-section">
|
||||
{err && serverGroups.failed.length === 0 && <div className="banner banner--error">{err}</div>}
|
||||
{visibleUpdateStatuses.length > 0 && (
|
||||
<BuiltInMCPUpdateNotice
|
||||
statuses={visibleUpdateStatuses}
|
||||
busy={busy}
|
||||
onUpgrade={(name) => void upgradeBuiltInMCP(name)}
|
||||
/>
|
||||
)}
|
||||
<div className="cap-mcp-toolbar">
|
||||
{view.servers.length > 0 ? <div className="drawer__summary">{summary}</div> : <span />}
|
||||
<div className="cap-mcp-toolbar__actions">
|
||||
@@ -1784,13 +1506,11 @@ export function MCPServersSettingsPage() {
|
||||
{serverGroups.failed.length > 0 && (
|
||||
<FailedServersNotice
|
||||
servers={serverGroups.failed}
|
||||
updates={updateStatuses}
|
||||
expanded={expandedErrors}
|
||||
busy={busy}
|
||||
onToggle={toggleError}
|
||||
onRetry={(name) => void mutate(() => app.ReconnectMCPServer(name))}
|
||||
onRetryMany={(names) => void mutate(() => Promise.allSettled(names.map((name) => app.ReconnectMCPServer(name))))}
|
||||
onUpgrade={(name) => void upgradeBuiltInMCP(name)}
|
||||
onConfirmClearAuth={(name) => void mutate(() => app.ClearMCPServerAuthentication(name))}
|
||||
onConfirm={(name) => void mutate(() => app.RemoveMCPServer(name))}
|
||||
onConfirmMany={(names) => void mutate(() => Promise.allSettled(names.map((name) => app.RemoveMCPServer(name))))}
|
||||
@@ -1805,7 +1525,6 @@ export function MCPServersSettingsPage() {
|
||||
<ServerGroup
|
||||
busy={busy}
|
||||
servers={serverGroups.active}
|
||||
updates={updateStatuses}
|
||||
expanded={expandedServers}
|
||||
expandedTools={expandedServerTools}
|
||||
editing={editing}
|
||||
@@ -1814,7 +1533,6 @@ export function MCPServersSettingsPage() {
|
||||
onCancelEdit={() => setEditing(null)}
|
||||
onRetry={(name) => void mutate(() => app.ReconnectMCPServer(name))}
|
||||
onReconnect={(name) => void mutate(() => app.ReconnectMCPServer(name))}
|
||||
onUpgrade={(name) => void upgradeBuiltInMCP(name)}
|
||||
onConfirmClearAuth={(name) => void mutate(() => app.ClearMCPServerAuthentication(name))}
|
||||
onToggle={(name, on) => void mutate(() => app.SetMCPServerEnabled(name, on))}
|
||||
onUpdate={(name, input) =>
|
||||
|
||||
@@ -19,8 +19,6 @@ import type {
|
||||
BotInstallStartResult,
|
||||
BotRuntimeStatusView,
|
||||
BotSettingsView,
|
||||
BuiltInMCPUpdateResult,
|
||||
BuiltInMCPUpdateStatus,
|
||||
CapabilitiesView,
|
||||
CheckpointMeta,
|
||||
CommandInfo,
|
||||
@@ -166,8 +164,6 @@ export interface AppBindings {
|
||||
UpdateMCPServer(name: string, input: MCPServerInput): Promise<void>;
|
||||
RemoveMCPServer(name: string): Promise<void>;
|
||||
ReconnectMCPServer(name: string): Promise<void>;
|
||||
UpdateBuiltInMCPServer(name: string): Promise<BuiltInMCPUpdateResult>;
|
||||
BuiltInMCPUpdateStatuses(): Promise<BuiltInMCPUpdateStatus[]>;
|
||||
ClearMCPServerAuthentication(name: string): Promise<void>;
|
||||
PickSkillFolder(): Promise<string>;
|
||||
AddSkillPath(path: string): Promise<void>;
|
||||
@@ -372,13 +368,6 @@ export function onUpdaterProgress(cb: (p: UpdateProgress) => void): () => void {
|
||||
};
|
||||
}
|
||||
|
||||
export function onBuiltInMCPUpdate(cb: (status: BuiltInMCPUpdateStatus) => void): () => void {
|
||||
if (realApp() && typeof window !== "undefined" && window.runtime) {
|
||||
return window.runtime.EventsOn("builtin-mcp:update", (status) => cb(status as BuiltInMCPUpdateStatus));
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
if (err && typeof err === "object" && "message" in err) {
|
||||
const msg = (err as { message?: unknown }).message;
|
||||
@@ -510,7 +499,7 @@ function bridgeBreadcrumb(method: string): string {
|
||||
if (/^(SaveProvider|AddOfficialProviderAccess|RemoveProviderAccess|DeleteProvider|SetProviderKey|ClearProviderKey|FetchProviderModels|ConnectKey)/.test(method))
|
||||
return `provider ${method}`;
|
||||
if (/^(CheckUpdate|ApplyUpdate|OpenDownloadPage)/.test(method)) return `update ${method}`;
|
||||
if (/^(AddMCPServer|UpdateMCPServer|RemoveMCPServer|ReconnectMCPServer|UpdateBuiltInMCPServer|BuiltInMCPUpdateStatuses|ClearMCPServerAuthentication|SetMCPServer)/.test(method))
|
||||
if (/^(AddMCPServer|UpdateMCPServer|RemoveMCPServer|ReconnectMCPServer|ClearMCPServerAuthentication|SetMCPServer)/.test(method))
|
||||
return `mcp ${method}`;
|
||||
if (/^(AddSkillPath|RemoveSkillPath|RefreshSkills|SetSkillEnabled|AcceptSkillSuggestion)/.test(method))
|
||||
return `skill ${method}`;
|
||||
@@ -628,54 +617,6 @@ function makeMockApp(): AppBindings {
|
||||
const t0 = Date.now();
|
||||
// Mutable so MCP add/remove/retry are observable in browser dev.
|
||||
let capServers: ServerView[] = [
|
||||
{
|
||||
name: "codegraph",
|
||||
transport: "stdio",
|
||||
status: "disabled",
|
||||
builtIn: true,
|
||||
configured: true,
|
||||
autoStart: false,
|
||||
tier: "background",
|
||||
command: "codegraph",
|
||||
args: ["serve", "--mcp"],
|
||||
tools: 0,
|
||||
prompts: 0,
|
||||
resources: 0,
|
||||
toolList: [
|
||||
{ name: "search", description: "Search symbols, files, and text in the workspace." },
|
||||
{ name: "context", description: "Fetch surrounding source context for a symbol or file." },
|
||||
{ name: "trace", description: "Follow callers and callees across the code graph." },
|
||||
{ name: "node", description: "Inspect a specific graph node." },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "time",
|
||||
transport: "stdio",
|
||||
status: "deferred",
|
||||
builtIn: true,
|
||||
configured: true,
|
||||
autoStart: true,
|
||||
tier: "lazy",
|
||||
command: "reasonix",
|
||||
args: ["builtin-mcp", "time"],
|
||||
tools: 0,
|
||||
prompts: 0,
|
||||
resources: 0,
|
||||
},
|
||||
{
|
||||
name: "context7",
|
||||
transport: "stdio",
|
||||
status: "disabled",
|
||||
builtIn: true,
|
||||
configured: true,
|
||||
autoStart: false,
|
||||
tier: "lazy",
|
||||
command: "npx",
|
||||
args: ["-y", "@upstash/context7-mcp"],
|
||||
tools: 0,
|
||||
prompts: 0,
|
||||
resources: 0,
|
||||
},
|
||||
{ name: "github", transport: "stdio", status: "connected", configured: true, autoStart: true, tier: "background", command: "npx", args: ["-y", "@modelcontextprotocol/server-github"], tools: 12, prompts: 2, resources: 0 },
|
||||
{
|
||||
name: "linear",
|
||||
@@ -703,15 +644,6 @@ function makeMockApp(): AppBindings {
|
||||
},
|
||||
{ name: "figma", transport: "http", status: "failed", configured: true, autoStart: true, tier: "background", url: "https://mcp.figma.com/mcp", authStatus: "required", authUrl: "https://mcp.figma.com/mcp", tools: 0, prompts: 0, resources: 0, error: "connect: 401 unauthorized" },
|
||||
];
|
||||
let builtInMCPUpdates: BuiltInMCPUpdateStatus[] = [
|
||||
{
|
||||
name: "codegraph",
|
||||
mode: "notify",
|
||||
current: "v0.9.7",
|
||||
latest: "v0.10.0",
|
||||
phase: "available",
|
||||
},
|
||||
];
|
||||
const capSkills: SkillView[] = [
|
||||
{ name: "explore", description: "Investigate the codebase in an isolated subagent", scope: "builtin", runAs: "subagent", enabled: true },
|
||||
{ name: "review", description: "Review the staged diff", scope: "project", runAs: "inline", enabled: false },
|
||||
@@ -2019,28 +1951,6 @@ function makeMockApp(): AppBindings {
|
||||
s.name === name ? { ...s, status: "connected", tools: s.tools || 4 } : s,
|
||||
);
|
||||
},
|
||||
async UpdateBuiltInMCPServer(name: string) {
|
||||
if (name !== "codegraph") throw new Error(`${name} is not an updatable built-in MCP server`);
|
||||
capServers = capServers.map((s) =>
|
||||
s.name === name
|
||||
? { ...s, status: s.autoStart ? "connected" : s.status, tools: s.autoStart ? s.tools || 4 : s.tools, error: undefined }
|
||||
: s,
|
||||
);
|
||||
builtInMCPUpdates = [
|
||||
{
|
||||
name,
|
||||
mode: "manual",
|
||||
current: "v0.10.0",
|
||||
latest: "v0.10.0",
|
||||
phase: "activated",
|
||||
path: "/tmp/reasonix/codegraph/mock/codegraph",
|
||||
},
|
||||
];
|
||||
return { name, version: "v0.10.0", path: "/tmp/reasonix/codegraph/mock/codegraph" };
|
||||
},
|
||||
async BuiltInMCPUpdateStatuses() {
|
||||
return builtInMCPUpdates.map((s) => ({ ...s }));
|
||||
},
|
||||
async ClearMCPServerAuthentication(name: string) {
|
||||
capServers = capServers.map((s) =>
|
||||
s.name === name
|
||||
|
||||
@@ -536,23 +536,6 @@ export interface CapabilitiesView {
|
||||
skills: SkillView[];
|
||||
skillRoots: SkillRootView[];
|
||||
}
|
||||
export interface BuiltInMCPUpdateResult {
|
||||
name: string;
|
||||
version: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export type BuiltInMCPUpdatePhase = "current" | "available" | "downloaded" | "activated" | "skipped" | "error";
|
||||
|
||||
export interface BuiltInMCPUpdateStatus {
|
||||
name: string;
|
||||
mode: string;
|
||||
current: string;
|
||||
latest: string;
|
||||
phase: BuiltInMCPUpdatePhase;
|
||||
path?: string;
|
||||
err?: string;
|
||||
}
|
||||
export interface MCPServerInput {
|
||||
name: string;
|
||||
transport: string; // stdio | http | sse
|
||||
|
||||
@@ -194,7 +194,7 @@ export function shouldReconcileStaleTurn(
|
||||
return Math.max(0, now - lastTurnActivityAt) >= timeoutMs;
|
||||
}
|
||||
|
||||
/** Mirrors Go backend's ReadOnly() + codegraph ReadOnlyToolNames(). */
|
||||
/** Mirrors Go backend's ReadOnly() hints. */
|
||||
export function isReadOnlyTool(name: string): boolean {
|
||||
switch (name) {
|
||||
case "read_file":
|
||||
@@ -206,16 +206,6 @@ export function isReadOnlyTool(name: string): boolean {
|
||||
case "waitJob":
|
||||
case "todo_write":
|
||||
case "read_skill":
|
||||
case "codegraph_callees":
|
||||
case "codegraph_callers":
|
||||
case "codegraph_context":
|
||||
case "codegraph_explore":
|
||||
case "codegraph_files":
|
||||
case "codegraph_impact":
|
||||
case "codegraph_node":
|
||||
case "codegraph_search":
|
||||
case "codegraph_status":
|
||||
case "codegraph_trace":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
|
||||
@@ -235,29 +235,6 @@ export const en = {
|
||||
"caps.status": "Status",
|
||||
"caps.refresh": "Refresh MCP status",
|
||||
"caps.reconnect": "Reconnect",
|
||||
"caps.upgradeBuiltInMCP": "Upgrade built-in MCP",
|
||||
"caps.updateNoticeTitle": "Built-in MCP update status",
|
||||
"caps.updatePolicy": "Update source",
|
||||
"caps.updateAvailable": "New version {latest} available (current {current})",
|
||||
"caps.updateDownloaded": "{latest} is pre-downloaded; upgrade to enable it",
|
||||
"caps.updateActivated": "Upgraded to {latest}; new connections will use it",
|
||||
"caps.updateCurrent": "Already current at {current}",
|
||||
"caps.updateError": "Update check failed",
|
||||
"caps.updateErrorWithMessage": "Update check failed: {err}",
|
||||
"caps.updateCodegraphManual": "Background checks run safely; CodeGraph can be upgraded manually",
|
||||
"caps.updateManagedByNpx": "Managed by npx/npm; Reasonix does not pin this runtime",
|
||||
"caps.updateManagedByNpxShort": "Updates via npx",
|
||||
"caps.updateBundledWithApp": "Updated with the Reasonix app",
|
||||
"caps.updateBundledWithAppShort": "Updates with app",
|
||||
"caps.updateNotManaged": "Independent updates are not supported",
|
||||
"caps.updateBadgeAvailable": "Update",
|
||||
"caps.updateBadgeDownloaded": "Downloaded",
|
||||
"caps.updateBadgeActivated": "Upgraded",
|
||||
"caps.updateBadgeError": "Check failed",
|
||||
"caps.updateToastAvailable": "{name} {latest} is available; upgrade it in MCP settings.",
|
||||
"caps.updateToastDownloaded": "{name} {latest} is downloaded; enable it in MCP settings.",
|
||||
"caps.updateToastActivated": "{name} upgraded to {latest}.",
|
||||
"caps.updateToastError": "{name} update check failed.",
|
||||
"caps.command": "Command",
|
||||
"caps.url": "URL",
|
||||
"caps.envKeys": "Environment",
|
||||
@@ -281,7 +258,6 @@ export const en = {
|
||||
"caps.authPossibleShort": "auth may be required",
|
||||
"caps.clearAuth": "Clear auth",
|
||||
"caps.confirmClearAuth": "Confirm clear",
|
||||
"caps.codegraphWarming": "codegraph: built-in code intelligence is still warming up or preparing the project index; retry shortly",
|
||||
"caps.deferred": "enabled; connects when this MCP is used",
|
||||
"caps.initializing": "connecting to MCP; tools will appear when ready",
|
||||
"caps.initializingShort": "Connecting",
|
||||
|
||||
@@ -151,29 +151,6 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"caps.status": "狀態",
|
||||
"caps.refresh": "重新整理 MCP 狀態",
|
||||
"caps.reconnect": "重新連線",
|
||||
"caps.upgradeBuiltInMCP": "升級內建 MCP",
|
||||
"caps.updateNoticeTitle": "內建 MCP 更新狀態",
|
||||
"caps.updatePolicy": "更新方式",
|
||||
"caps.updateAvailable": "有新版本 {latest}(目前 {current})",
|
||||
"caps.updateDownloaded": "已預先下載 {latest},可點擊升級啟用",
|
||||
"caps.updateActivated": "已升級到 {latest},新連線會使用新版",
|
||||
"caps.updateCurrent": "已是最新版本 {current}",
|
||||
"caps.updateError": "檢查更新失敗",
|
||||
"caps.updateErrorWithMessage": "檢查更新失敗:{err}",
|
||||
"caps.updateCodegraphManual": "背景會檢查更新;可手動升級 CodeGraph runtime",
|
||||
"caps.updateManagedByNpx": "由 npx/npm 管理,Reasonix 不託管固定版本",
|
||||
"caps.updateManagedByNpxShort": "由 npx 管理更新",
|
||||
"caps.updateBundledWithApp": "隨 Reasonix 應用本體更新",
|
||||
"caps.updateBundledWithAppShort": "隨應用更新",
|
||||
"caps.updateNotManaged": "目前不支援獨立升級",
|
||||
"caps.updateBadgeAvailable": "有更新",
|
||||
"caps.updateBadgeDownloaded": "已預下載",
|
||||
"caps.updateBadgeActivated": "已升級",
|
||||
"caps.updateBadgeError": "檢查失敗",
|
||||
"caps.updateToastAvailable": "{name} 有新版本 {latest},可在 MCP 管理頁升級。",
|
||||
"caps.updateToastDownloaded": "{name} {latest} 已預下載,可在 MCP 管理頁啟用。",
|
||||
"caps.updateToastActivated": "{name} 已升級到 {latest}。",
|
||||
"caps.updateToastError": "{name} 更新檢查失敗。",
|
||||
"caps.command": "命令",
|
||||
"caps.url": "URL",
|
||||
"caps.envKeys": "環境變數",
|
||||
@@ -197,7 +174,6 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"caps.authPossibleShort": "可能需要授權",
|
||||
"caps.clearAuth": "清除授權",
|
||||
"caps.confirmClearAuth": "確認清除",
|
||||
"caps.codegraphWarming": "codegraph:內建程式碼智慧仍在冷啟動或準備專案索引,稍後重試即可",
|
||||
"caps.deferred": "已啟用,用到該 MCP 時連線",
|
||||
"caps.initializing": "正在連線 MCP,工具可用後會自動出現",
|
||||
"caps.initializingShort": "連線中",
|
||||
|
||||
@@ -236,29 +236,6 @@ export const zh: Record<DictKey, string> = {
|
||||
"caps.status": "状态",
|
||||
"caps.refresh": "刷新 MCP 状态",
|
||||
"caps.reconnect": "重新连接",
|
||||
"caps.upgradeBuiltInMCP": "升级内置 MCP",
|
||||
"caps.updateNoticeTitle": "内置 MCP 更新状态",
|
||||
"caps.updatePolicy": "更新方式",
|
||||
"caps.updateAvailable": "有新版本 {latest}(当前 {current})",
|
||||
"caps.updateDownloaded": "已预下载 {latest},可点击升级启用",
|
||||
"caps.updateActivated": "已升级到 {latest},新连接会使用新版",
|
||||
"caps.updateCurrent": "已是最新版本 {current}",
|
||||
"caps.updateError": "检查更新失败",
|
||||
"caps.updateErrorWithMessage": "检查更新失败:{err}",
|
||||
"caps.updateCodegraphManual": "后台会检查更新;可手动升级 CodeGraph runtime",
|
||||
"caps.updateManagedByNpx": "由 npx/npm 管理,Reasonix 不托管固定版本",
|
||||
"caps.updateManagedByNpxShort": "由 npx 管理更新",
|
||||
"caps.updateBundledWithApp": "随 Reasonix 应用本体更新",
|
||||
"caps.updateBundledWithAppShort": "随应用更新",
|
||||
"caps.updateNotManaged": "当前不支持独立升级",
|
||||
"caps.updateBadgeAvailable": "有更新",
|
||||
"caps.updateBadgeDownloaded": "已预下载",
|
||||
"caps.updateBadgeActivated": "已升级",
|
||||
"caps.updateBadgeError": "检查失败",
|
||||
"caps.updateToastAvailable": "{name} 有新版本 {latest},可在 MCP 管理页升级。",
|
||||
"caps.updateToastDownloaded": "{name} {latest} 已预下载,可在 MCP 管理页启用。",
|
||||
"caps.updateToastActivated": "{name} 已升级到 {latest}。",
|
||||
"caps.updateToastError": "{name} 更新检查失败。",
|
||||
"caps.command": "命令",
|
||||
"caps.url": "URL",
|
||||
"caps.envKeys": "环境变量",
|
||||
@@ -282,7 +259,6 @@ export const zh: Record<DictKey, string> = {
|
||||
"caps.authPossibleShort": "可能需要授权",
|
||||
"caps.clearAuth": "清除授权",
|
||||
"caps.confirmClearAuth": "确认清除",
|
||||
"caps.codegraphWarming": "codegraph:内置代码智能仍在冷启动或准备项目索引,稍后重试即可",
|
||||
"caps.deferred": "已启用,用到该 MCP 时连接",
|
||||
"caps.initializing": "正在连接 MCP,工具可用后会自动出现",
|
||||
"caps.initializingShort": "连接中",
|
||||
|
||||
@@ -11347,12 +11347,15 @@ a[href] {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── responsive: stacked nav on narrow screens ──────────────────────────────── */
|
||||
@media (max-width: 760px) {
|
||||
/* ── responsive: stacked nav on narrow and mid screens ─────────────────────── */
|
||||
@media (max-width: 900px) {
|
||||
.settings-modal-backdrop {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.settings-modal {
|
||||
width: min(100vw, 100vw);
|
||||
height: min(100vh, 100vh);
|
||||
border-radius: 0;
|
||||
width: 100%;
|
||||
height: min(900px, calc(100vh - 32px));
|
||||
}
|
||||
|
||||
.settings-center {
|
||||
@@ -11389,7 +11392,7 @@ a[href] {
|
||||
}
|
||||
|
||||
.settings-center__content {
|
||||
padding: 14px 14px;
|
||||
padding: 18px 20px;
|
||||
}
|
||||
|
||||
.settings-page--form,
|
||||
@@ -11410,6 +11413,7 @@ a[href] {
|
||||
.settings-field {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.settings-field__control,
|
||||
@@ -11421,6 +11425,18 @@ a[href] {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.settings-inline-controls .set-seg {
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.settings-inline-controls .set-seg__btn {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.step-limit-control {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
@@ -11459,6 +11475,27 @@ a[href] {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.settings-modal-backdrop {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.settings-modal {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.settings-center__content {
|
||||
padding: 14px 14px;
|
||||
}
|
||||
|
||||
.settings-section,
|
||||
.settings-page--manager .mem-section {
|
||||
padding: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-field__control > .settings-model-picker {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
|
||||
@@ -18,8 +18,6 @@ import (
|
||||
"github.com/wailsapp/wails/v2/pkg/options/mac"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/windows"
|
||||
|
||||
"reasonix/internal/builtinmcp"
|
||||
|
||||
// Blank imports wire compile-time built-ins into their registries, exactly as
|
||||
// cmd/reasonix does — boot.Build resolves providers/tools from these registries.
|
||||
_ "reasonix/internal/provider/anthropic"
|
||||
@@ -60,10 +58,6 @@ func windowsWebview2GPUDisabled() bool {
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 && os.Args[1] == "builtin-mcp" {
|
||||
os.Exit(builtinmcp.RunCommand(os.Args[2:], os.Stdin, os.Stdout, os.Stderr, version))
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
|
||||
// Restore saved window size, or fall back to the default.
|
||||
|
||||
@@ -12,7 +12,7 @@ func TestEnsureBlankTabInheritsActiveTabSettings(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
workspace := robustTempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(workspace, "reasonix.toml"),
|
||||
[]byte("[codegraph]\nenabled = false\n"), 0o644); err != nil {
|
||||
[]byte(""), 0o644); err != nil {
|
||||
t.Fatalf("write workspace config: %v", err)
|
||||
}
|
||||
|
||||
|
||||
+5
-6
@@ -12,7 +12,7 @@ changed and how to move over.
|
||||
| Branch | [`v1`](https://github.com/esengine/DeepSeek-Reasonix/tree/v1) (maintenance only) | `main-v2` (default, active) |
|
||||
| Versions | `0.x` (up to v0.54.x) | `1.0.0`+ |
|
||||
| Install | `npm i -g reasonix` (the `latest` tag, stays on `0.x`) | `npm i -g reasonix@next` — `latest` deliberately stays on `0.x`; or a release archive / `go build` |
|
||||
| Code intelligence | embedding semantic search | bundled [CodeGraph](https://github.com/colbymchenry/codegraph) (symbol/call graph) |
|
||||
| Code intelligence | embedding semantic search + tree-sitter symbols | LSP-assisted code reading plus grep/read_file/glob; semantic index is not yet ported |
|
||||
|
||||
"v1" and "v2" are **codebase generations**, not semver: the v1 line never reached
|
||||
1.0, so the Go rewrite takes the `1.x` major.
|
||||
@@ -77,11 +77,10 @@ and DeepSeek prefix-cache–oriented design.
|
||||
|
||||
## What's different
|
||||
|
||||
- **Code intelligence**: embedding semantic search is replaced by **CodeGraph**
|
||||
(`codegraph_*` tools) — a tree-sitter symbol/call graph, no embedding service or
|
||||
API cost. New (first-run) configs start with it off; existing configs keep it
|
||||
on across upgrades. Toggle `[codegraph]` in the MCP manager or config; when
|
||||
enabled it starts in the background so chat startup is never blocked.
|
||||
- **Code intelligence**: the Go rewrite uses LSP-assisted code reading plus
|
||||
`grep` / `read_file` / `glob` for local understanding. The legacy v1 semantic
|
||||
search + tree-sitter symbol index is not bundled in v2 yet, and CodeGraph is no
|
||||
longer shipped as an internal MCP server.
|
||||
- **Plan mode** + `complete_step` (evidence-backed step sign-off).
|
||||
- **No web dashboard** — the v2 line is terminal + desktop (Wails), by design.
|
||||
- Some granular v1 tools are intentionally consolidated (e.g. file-management ops
|
||||
|
||||
@@ -141,7 +141,7 @@ func TestMcpSpecsNil(t *testing.T) {
|
||||
|
||||
func TestMcpSpecsConversion(t *testing.T) {
|
||||
in := []MCPServerSpec{
|
||||
{Name: "codegraph", Command: "codegraph", Args: []string{"--stdio"}, Env: MCPEnv{"HOME": "/tmp"}},
|
||||
{Name: "search", Command: "search-mcp", Args: []string{"--stdio"}, Env: MCPEnv{"HOME": "/tmp"}},
|
||||
}
|
||||
got, err := mcpSpecs(in, "/workspace")
|
||||
if err != nil {
|
||||
@@ -150,7 +150,7 @@ func TestMcpSpecsConversion(t *testing.T) {
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len = %d, want 1", len(got))
|
||||
}
|
||||
if got[0].Name != "codegraph" || got[0].Type != "stdio" || got[0].Command != "codegraph" {
|
||||
if got[0].Name != "search" || got[0].Type != "stdio" || got[0].Command != "search-mcp" {
|
||||
t.Errorf("spec = %+v", got[0])
|
||||
}
|
||||
if got[0].Args[0] != "--stdio" {
|
||||
|
||||
+16
-108
@@ -20,8 +20,6 @@ import (
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/builtinmcp"
|
||||
"reasonix/internal/codegraph"
|
||||
"reasonix/internal/command"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
@@ -96,7 +94,7 @@ type Options struct {
|
||||
ExtraPlugins []plugin.Spec
|
||||
// TokenMode selects how much optional context/tool surface this session exposes
|
||||
// at boot. Empty/full preserves the normal capability surface. "economy" keeps
|
||||
// the core coding tools visible and moves skills, MCP, CodeGraph, LSP, web_fetch,
|
||||
// the core coding tools visible and moves skills, MCP, LSP, web_fetch,
|
||||
// install_source, and task behind connect_tool_source.
|
||||
TokenMode string
|
||||
// SessionDir overrides where persisted chat transcripts are written. When
|
||||
@@ -251,12 +249,13 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
|
||||
// Partition configured plugins by tier so eager/lazy/background can each
|
||||
// take the path that fits them. User entries default to background: the
|
||||
// session starts immediately while enabled MCP servers warm up.
|
||||
autoStartEntries := builtinmcp.AppendEnabled(cfg.AutoStartPlugins(), cfg.Plugins, cfg.BuiltInMCP.EnabledNames(), pluginSpecNames(opts.ExtraPlugins)...)
|
||||
autoStartEntries := cfg.AutoStartPlugins()
|
||||
eagerEntries, lazyEntries, bgEntries := partitionByTier(autoStartEntries)
|
||||
extraSpecs := applyKnownPluginOverrides(opts.ExtraPlugins)
|
||||
onDemandMCPSpecs := map[string]plugin.Spec{}
|
||||
onDemandMCPNames := []string{}
|
||||
if tokenEconomy {
|
||||
for _, spec := range append(PluginSpecs(autoStartEntries), opts.ExtraPlugins...) {
|
||||
for _, spec := range append(PluginSpecs(autoStartEntries), extraSpecs...) {
|
||||
name := strings.TrimSpace(spec.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
@@ -291,61 +290,8 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
|
||||
lazySpecs := PluginSpecs(lazyEntries)
|
||||
bgSpecs := PluginSpecs(bgEntries)
|
||||
|
||||
// CodeGraph is a built-in MCP server fetched on first use. When it resolves,
|
||||
// inject it as one more stdio plugin pinned to the project root (it is
|
||||
// cwd-aware); EnsureInit only creates .codegraph/ (fast, size-independent),
|
||||
// serve's daemon then indexes in the background, so startup never blocks even
|
||||
// on a large repo. When it is not yet installed, fetch it in the background
|
||||
// (one-time, ~45MB) if auto_install is on — startup still never blocks, the
|
||||
// tools come online next session — otherwise point the user at the explicit
|
||||
// install command. A failed init or fetch is a notice, not fatal.
|
||||
//
|
||||
// CodeGraph is fixed to background startup. Legacy tier values are ignored so
|
||||
// enabling it never blocks chat startup.
|
||||
if cfg.Codegraph.Enabled && !tokenEconomy {
|
||||
bin, ok := codegraph.Resolve(cfg.Codegraph.Path)
|
||||
switch {
|
||||
case ok && !codegraph.IndexableRoot(root):
|
||||
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn,
|
||||
Text: "codegraph: project root is a filesystem root — skipped to avoid indexing the whole volume"})
|
||||
case ok:
|
||||
spec := codegraph.MCPSpec(bin, root)
|
||||
warm := codegraph.Initialized(root)
|
||||
if err := codegraph.EnsureInit(ctx, bin, root); err != nil {
|
||||
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn,
|
||||
Text: "codegraph: init failed (" + err.Error() + ") — symbol-graph tools disabled this session"})
|
||||
break
|
||||
}
|
||||
bgNotice := func() {
|
||||
if !warm {
|
||||
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo,
|
||||
Text: "codegraph: preparing code-intelligence tools in the background — tools will appear when ready"})
|
||||
}
|
||||
}
|
||||
bgSpecs = append(bgSpecs, spec)
|
||||
bgNotice()
|
||||
case cfg.Codegraph.AutoInstall:
|
||||
notify := func(msg string) { sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: msg}) }
|
||||
notify("codegraph: fetching code-intelligence runtime in the background (one-time) — symbol-graph tools available next session")
|
||||
codegraphClient, err := netclient.NewHTTPClient(proxySpec, netclient.TransportOptions{})
|
||||
if err != nil {
|
||||
notify("codegraph: install skipped (" + err.Error() + ")")
|
||||
} else {
|
||||
go func() {
|
||||
if _, err := codegraph.InstallWithClient(context.WithoutCancel(ctx), codegraphClient, nil); err != nil {
|
||||
notify("codegraph: install failed (" + err.Error() + ") — using grep/glob; retries next session")
|
||||
} else {
|
||||
notify("codegraph: installed — symbol-graph tools available next session")
|
||||
}
|
||||
}()
|
||||
}
|
||||
default:
|
||||
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo,
|
||||
Text: "codegraph: not installed — run `reasonix codegraph install` to enable symbol-graph tools"})
|
||||
}
|
||||
}
|
||||
if !tokenEconomy {
|
||||
eagerSpecs = append(eagerSpecs, opts.ExtraPlugins...)
|
||||
eagerSpecs = append(eagerSpecs, extraSpecs...)
|
||||
}
|
||||
|
||||
// Apply caller-supplied stderr override to every spec across tiers.
|
||||
@@ -392,28 +338,6 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
|
||||
registerDeferred(lazySpecs, false)
|
||||
registerDeferred(bgSpecs, true)
|
||||
|
||||
// Inject codegraph steering into the system prompt when symbol-graph tools
|
||||
// are available, so the model knows to prefer them for architecture / call-graph
|
||||
// questions over grep/read_file. Also register codegraph tool names in the
|
||||
// subagent allowed-tools list so explore/research/review can use them.
|
||||
if cfg.Codegraph.Enabled {
|
||||
prefix := plugin.ToolPrefix("codegraph")
|
||||
var cgTools []string
|
||||
for _, name := range reg.Names() {
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
cgTools = append(cgTools, name)
|
||||
}
|
||||
}
|
||||
if len(cgTools) > 0 {
|
||||
sysPrompt += "\n\n" + codegraph.SteerText
|
||||
skill.SetExtraReadTools(cgTools)
|
||||
} else {
|
||||
skill.SetExtraReadTools(nil)
|
||||
}
|
||||
} else {
|
||||
skill.SetExtraReadTools(nil)
|
||||
}
|
||||
|
||||
for _, msg := range demoteMessages {
|
||||
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: msg})
|
||||
}
|
||||
@@ -555,6 +479,7 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
|
||||
// task/skill meta-tools, to bar recursion), and an optional per-skill model.
|
||||
// Its tool activity nests under the invoking call, like `task`.
|
||||
skillRunner := func(sctx context.Context, sk skill.Skill, task string, runOpts skill.SubagentRunOptions) (string, error) {
|
||||
sk = skill.WithCodeGraphTools(sk, skill.CodeGraphReadTools(reg))
|
||||
prov, price, ctxWin := execProv, entry.Price, entry.ContextWindow
|
||||
modelRef := subagentModelRef(cfg, sk)
|
||||
effortRef := subagentEffortRef(cfg, sk)
|
||||
@@ -782,32 +707,6 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
|
||||
}
|
||||
return "enabled " + strings.Join(names, ", ") + ".", nil
|
||||
},
|
||||
codegraph: func(context.Context) (string, error) {
|
||||
if !cfg.Codegraph.Enabled {
|
||||
return "", fmt.Errorf("codegraph is disabled in config")
|
||||
}
|
||||
bin, ok := codegraph.Resolve(cfg.Codegraph.Path)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("codegraph is not installed")
|
||||
}
|
||||
if !codegraph.IndexableRoot(root) {
|
||||
return "", fmt.Errorf("codegraph: project root is a filesystem root — skipped to avoid indexing the whole volume")
|
||||
}
|
||||
if err := codegraph.EnsureInit(ctx, bin, root); err != nil {
|
||||
return "", fmt.Errorf("codegraph init: %w", err)
|
||||
}
|
||||
spec := codegraph.MCPSpec(bin, root)
|
||||
if opts.Stderr != nil {
|
||||
spec.Stderr = opts.Stderr
|
||||
}
|
||||
tools, err := pluginHost.Add(ctx, spec)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
reg.RemovePrefix(plugin.ToolPrefix(spec.Name))
|
||||
names := addTools(reg, tools)
|
||||
return "enabled codegraph tools: " + strings.Join(names, ", ") + ".", nil
|
||||
},
|
||||
mcp: func(_ context.Context, name string) (string, error) {
|
||||
spec, ok := onDemandMCPSpecs[name]
|
||||
if !ok {
|
||||
@@ -1301,7 +1200,7 @@ func PluginSpecs(entries []config.PluginEntry) []plugin.Spec {
|
||||
specs := make([]plugin.Spec, len(entries))
|
||||
for i, e := range entries {
|
||||
e = e.ExpandedPlugin() // resolve ${VAR} / ${VAR:-default} from the environment
|
||||
specs[i] = plugin.Spec{
|
||||
spec := plugin.Spec{
|
||||
Name: e.Name,
|
||||
Type: e.Type,
|
||||
Command: e.Command,
|
||||
@@ -1310,10 +1209,19 @@ func PluginSpecs(entries []config.PluginEntry) []plugin.Spec {
|
||||
URL: e.URL,
|
||||
Headers: e.Headers,
|
||||
}
|
||||
specs[i] = plugin.ApplyKnownReadOnlyOverrides(spec)
|
||||
}
|
||||
return specs
|
||||
}
|
||||
|
||||
func applyKnownPluginOverrides(specs []plugin.Spec) []plugin.Spec {
|
||||
out := make([]plugin.Spec, len(specs))
|
||||
for i, spec := range specs {
|
||||
out[i] = plugin.ApplyKnownReadOnlyOverrides(spec)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func pluginSpecNames(specs []plugin.Spec) []string {
|
||||
names := make([]string, 0, len(specs))
|
||||
for _, s := range specs {
|
||||
|
||||
+21
-381
@@ -10,10 +10,8 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -21,8 +19,6 @@ import (
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/agent/testutil"
|
||||
"reasonix/internal/builtinmcp"
|
||||
"reasonix/internal/codegraph"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/memory"
|
||||
@@ -61,9 +57,6 @@ func TestBuildFoldsProjectMemoryIntoSystemPrompt(t *testing.T) {
|
||||
writeFile(t, dir, "reasonix.toml", `
|
||||
default_model = "test-model"
|
||||
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE SYSTEM PROMPT"
|
||||
|
||||
@@ -109,9 +102,6 @@ func TestBuildRegistersUsableHistoryAndMemoryRetrievalTools(t *testing.T) {
|
||||
writeFile(t, dir, "reasonix.toml", `
|
||||
default_model = "test-model"
|
||||
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE"
|
||||
|
||||
@@ -352,9 +342,6 @@ func TestBuildSubagentSkillFailedContinuationPersistsTranscript(t *testing.T) {
|
||||
writeFile(t, dir, "reasonix.toml", `
|
||||
default_model = "test-model"
|
||||
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE"
|
||||
|
||||
@@ -413,9 +400,6 @@ func TestBuildSubagentStoreHonorsSessionDirOverride(t *testing.T) {
|
||||
writeFile(t, dir, "reasonix.toml", `
|
||||
default_model = "test-model"
|
||||
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE"
|
||||
|
||||
@@ -463,9 +447,6 @@ func TestBuildSubagentSkillUsesLiveReasoningLanguage(t *testing.T) {
|
||||
writeFile(t, dir, "reasonix.toml", `
|
||||
default_model = "test-model"
|
||||
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE"
|
||||
reasoning_language = "zh"
|
||||
@@ -509,9 +490,6 @@ func TestBuildSubagentSkillGetsForegroundOnlyBash(t *testing.T) {
|
||||
writeFile(t, dir, "reasonix.toml", `
|
||||
default_model = "test-model"
|
||||
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE"
|
||||
|
||||
@@ -689,9 +667,6 @@ func TestBuildHeadlessRunRunsTaskSubagentWithoutSessionPath(t *testing.T) {
|
||||
writeFile(t, dir, "reasonix.toml", `
|
||||
default_model = "test-model"
|
||||
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE"
|
||||
|
||||
@@ -886,9 +861,6 @@ func TestBuildHonorsSessionDirOverride(t *testing.T) {
|
||||
writeFile(t, dir, "reasonix.toml", `
|
||||
default_model = "test-model"
|
||||
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[[providers]]
|
||||
name = "test-model"
|
||||
kind = "openai"
|
||||
@@ -921,9 +893,6 @@ func TestBuildDiscoversSkills(t *testing.T) {
|
||||
writeFile(t, dir, "reasonix.toml", `
|
||||
default_model = "test-model"
|
||||
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE"
|
||||
|
||||
@@ -972,9 +941,6 @@ func TestBuildTokenFullMatchesDefaultRequestPrefix(t *testing.T) {
|
||||
writeFile(t, dir, "reasonix.toml", `
|
||||
default_model = "test-model"
|
||||
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE"
|
||||
|
||||
@@ -1019,9 +985,6 @@ func TestBuildTokenEconomyStartsWithLeanToolSurface(t *testing.T) {
|
||||
writeFile(t, dir, "reasonix.toml", `
|
||||
default_model = "test-model"
|
||||
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE"
|
||||
|
||||
@@ -1053,6 +1016,7 @@ command = "reasonix-missing-mockmcp"
|
||||
"ask",
|
||||
"bash",
|
||||
"bash_output",
|
||||
"code_index",
|
||||
"complete_step",
|
||||
"connect_tool_source",
|
||||
"edit_file",
|
||||
@@ -1117,9 +1081,6 @@ func TestBuildTokenEconomyConnectsWebFetchOnDemand(t *testing.T) {
|
||||
writeFile(t, dir, "reasonix.toml", `
|
||||
default_model = "test-model"
|
||||
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE"
|
||||
|
||||
@@ -1149,65 +1110,6 @@ model = "x"
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTokenEconomyCodegraphSetsShortDaemonIdleTimeout(t *testing.T) {
|
||||
isolateConfigHome(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
launcher := writeCodegraphHelper(t, dir)
|
||||
envOut := filepath.Join(dir, "codegraph-idle-env")
|
||||
t.Setenv("REASONIX_CODEGRAPH_HELPER_ENV_OUT", envOut)
|
||||
|
||||
registerBootTokenProfileTestProvider()
|
||||
prov := testutil.NewMock("token-economy",
|
||||
testutil.Turn{ToolCalls: []provider.ToolCall{
|
||||
{ID: "source-1", Name: "connect_tool_source", Arguments: `{"source":"codegraph"}`},
|
||||
}},
|
||||
testutil.Turn{Text: "done"},
|
||||
)
|
||||
setBootTokenProfileTestProvider(t, prov)
|
||||
writeFile(t, dir, "reasonix.toml", fmt.Sprintf(`
|
||||
default_model = "test-model"
|
||||
|
||||
[codegraph]
|
||||
enabled = true
|
||||
path = %q
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE"
|
||||
|
||||
[[providers]]
|
||||
name = "test-model"
|
||||
kind = "boot-token-profile-test"
|
||||
model = "x"
|
||||
`, launcher))
|
||||
|
||||
ctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
defer ctrl.Close()
|
||||
if err := ctrl.Run(context.Background(), "enable codegraph later"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
reqs := prov.Requests()
|
||||
if len(reqs) != 2 {
|
||||
t.Fatalf("requests = %d, want 2", len(reqs))
|
||||
}
|
||||
if requestHasToolPrefix(reqs[0], "mcp__codegraph__") {
|
||||
t.Fatalf("first request should hide codegraph; tools=%v", toolSchemaNames(reqs[0].Tools))
|
||||
}
|
||||
if !requestHasToolPrefix(reqs[1], "mcp__codegraph__") {
|
||||
t.Fatalf("second request should expose codegraph after connect_tool_source; tools=%v", toolSchemaNames(reqs[1].Tools))
|
||||
}
|
||||
got, err := os.ReadFile(envOut)
|
||||
if err != nil {
|
||||
t.Fatalf("read codegraph idle timeout env: %v", err)
|
||||
}
|
||||
if string(got) != codegraph.ReasonixDaemonIdleTimeoutMS {
|
||||
t.Fatalf("%s = %q; want %q", codegraph.DaemonIdleTimeoutEnv, got, codegraph.ReasonixDaemonIdleTimeoutMS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTokenEconomyPlanModeCanConnectWebFetch(t *testing.T) {
|
||||
isolateConfigHome(t)
|
||||
dir := robustTempDir(t)
|
||||
@@ -1224,9 +1126,6 @@ func TestBuildTokenEconomyPlanModeCanConnectWebFetch(t *testing.T) {
|
||||
writeFile(t, dir, "reasonix.toml", `
|
||||
default_model = "test-model"
|
||||
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE"
|
||||
|
||||
@@ -1278,9 +1177,6 @@ default_model = "test-model"
|
||||
[tools]
|
||||
enabled = ["read_file", "grep"]
|
||||
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE"
|
||||
|
||||
@@ -1332,9 +1228,6 @@ func TestBuildTokenEconomyConnectsSkillsOnDemand(t *testing.T) {
|
||||
writeFile(t, dir, "reasonix.toml", `
|
||||
default_model = "test-model"
|
||||
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE"
|
||||
|
||||
@@ -1404,9 +1297,6 @@ func TestBuildOmitsDisabledSkillsFromPromptAndRuntimeList(t *testing.T) {
|
||||
writeFile(t, dir, "reasonix.toml", `
|
||||
default_model = "test-model"
|
||||
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE"
|
||||
|
||||
@@ -1460,9 +1350,6 @@ func TestBuildOmitsExcludedSkillRootsFromPromptAndRuntimeList(t *testing.T) {
|
||||
writeFile(t, dir, "reasonix.toml", fmt.Sprintf(`
|
||||
default_model = "test-model"
|
||||
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE"
|
||||
|
||||
@@ -1510,9 +1397,6 @@ func TestBuildWithoutMemoryLeavesPromptUnchanged(t *testing.T) {
|
||||
writeFile(t, dir, "reasonix.toml", `
|
||||
default_model = "test-model"
|
||||
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[agent]
|
||||
system_prompt = "JUST THE BASE"
|
||||
|
||||
@@ -1554,9 +1438,6 @@ func TestBuildLanguagePolicyIsAppended(t *testing.T) {
|
||||
writeFile(t, dir, "reasonix.toml", `
|
||||
default_model = "test-model"
|
||||
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE"
|
||||
|
||||
@@ -1757,9 +1638,9 @@ func TestBuildMigratesLegacyConfigEndToEnd(t *testing.T) {
|
||||
|
||||
proj := robustTempDir(t)
|
||||
t.Chdir(proj)
|
||||
// codegraph off keeps Build offline; it merges over the migrated user config
|
||||
// without dropping the migrated plugins.
|
||||
writeFile(t, proj, "reasonix.toml", "[codegraph]\nenabled = false\n")
|
||||
// Project config merges over the migrated user config without dropping the
|
||||
// migrated plugins.
|
||||
writeFile(t, proj, "reasonix.toml", "")
|
||||
writeFile(t, filepath.Join(home, ".reasonix"), "config.json",
|
||||
`{"apiKey":"sk-e2e","lang":"zh","mcpServers":{"fs":{"command":"npx","args":["-y","server-fs"]}}}`)
|
||||
writeFile(t, filepath.Join(home, ".reasonix", "sessions"), "chat-1.events.jsonl",
|
||||
@@ -1832,7 +1713,7 @@ func TestBuildMigratesLegacySessionsFromConfigSessionDir(t *testing.T) {
|
||||
t.Setenv("AppData", filepath.Join(home, "AppData"))
|
||||
|
||||
proj := robustTempDir(t)
|
||||
writeFile(t, proj, "reasonix.toml", "[codegraph]\nenabled = false\n")
|
||||
writeFile(t, proj, "reasonix.toml", "")
|
||||
|
||||
legacyDir := config.SessionDir()
|
||||
writeFile(t, legacyDir, "custom-root.events.jsonl",
|
||||
@@ -1919,17 +1800,25 @@ func TestPartitionByTier(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltInMCPsYieldToExtraPluginNames(t *testing.T) {
|
||||
got := builtinmcp.AppendEnabled(nil, nil, []string{"time", "context7"}, pluginSpecNames([]plugin.Spec{{Name: "time"}})...)
|
||||
if len(got) != 1 || got[0].Name != "context7" {
|
||||
t.Fatalf("built-in MCP entries with extra time = %+v, want only context7", got)
|
||||
func TestPluginSpecsTrustKnownCodeGraphReadTools(t *testing.T) {
|
||||
specs := PluginSpecs([]config.PluginEntry{{Name: "codegraph"}})
|
||||
if len(specs) != 1 {
|
||||
t.Fatalf("PluginSpecs returned %d specs, want 1", len(specs))
|
||||
}
|
||||
for _, name := range []string{"codegraph_context", "codegraph_search", "context", "search"} {
|
||||
if !specs[0].ReadOnlyToolNames[name] {
|
||||
t.Fatalf("codegraph spec missing read-only override for %q: %+v", name, specs[0].ReadOnlyToolNames)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltInMCPDefaultsEnableOnlyTime(t *testing.T) {
|
||||
got := builtinmcp.AppendEnabled(nil, nil, config.Default().BuiltInMCP.EnabledNames())
|
||||
if len(got) != 1 || got[0].Name != "time" {
|
||||
t.Fatalf("default built-in MCP entries = %+v, want only time", got)
|
||||
func TestPluginSpecsDoNotTrustCodeGraphToolsForOtherServers(t *testing.T) {
|
||||
specs := PluginSpecs([]config.PluginEntry{{Name: "not-codegraph"}})
|
||||
if len(specs) != 1 {
|
||||
t.Fatalf("PluginSpecs returned %d specs, want 1", len(specs))
|
||||
}
|
||||
if specs[0].ReadOnlyToolNames["codegraph_context"] {
|
||||
t.Fatalf("non-codegraph spec should not receive codegraph read-only overrides: %+v", specs[0].ReadOnlyToolNames)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1941,9 +1830,6 @@ func TestBuildMigratesLegacyEagerTierToBackground(t *testing.T) {
|
||||
writeFile(t, dir, "reasonix.toml", `
|
||||
default_model = "test-model"
|
||||
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE"
|
||||
|
||||
@@ -1989,9 +1875,6 @@ func TestBuildMigratesLegacyLazyTierToBackground(t *testing.T) {
|
||||
writeFile(t, dir, "reasonix.toml", `
|
||||
default_model = "test-model"
|
||||
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE"
|
||||
|
||||
@@ -2029,164 +1912,6 @@ tier = "lazy"
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildColdCodegraphStartsInBackground(t *testing.T) {
|
||||
isolateConfigHome(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
launcher := writeCodegraphHelper(t, dir)
|
||||
t.Setenv("GO_WANT_HELPER_PROCESS", "1")
|
||||
|
||||
writeFile(t, dir, "reasonix.toml", fmt.Sprintf(`
|
||||
default_model = "test-model"
|
||||
|
||||
[codegraph]
|
||||
enabled = true
|
||||
path = %q
|
||||
tier = "background"
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE"
|
||||
|
||||
[[providers]]
|
||||
name = "test-model"
|
||||
kind = "openai"
|
||||
base_url = "https://example.invalid"
|
||||
model = "x"
|
||||
api_key_env = "REASONIX_TEST_KEY_UNSET"
|
||||
`, launcher))
|
||||
|
||||
var notices []event.Event
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
ctrl, err := Build(ctx, Options{
|
||||
Sink: event.FuncSink(func(e event.Event) {
|
||||
if e.Kind == event.Notice {
|
||||
notices = append(notices, e)
|
||||
}
|
||||
}),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
defer ctrl.Close()
|
||||
|
||||
if got := ctrl.Host().Failures(); len(got) != 0 {
|
||||
t.Fatalf("Host.Failures() = %+v, want empty for cold built-in codegraph background startup", got)
|
||||
}
|
||||
codegraphDir := filepath.Join(dir, ".codegraph")
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
if _, err := os.Stat(codegraphDir); err == nil {
|
||||
break
|
||||
} else if time.Now().After(deadline) {
|
||||
t.Fatalf("cold codegraph init did not create .codegraph/: %v", err)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
foundNotice := false
|
||||
for _, n := range notices {
|
||||
if strings.Contains(n.Text, "preparing code-intelligence tools in the background") {
|
||||
foundNotice = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundNotice {
|
||||
t.Fatalf("missing background warmup notice; got %+v", notices)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCodegraphSetsShortDaemonIdleTimeout(t *testing.T) {
|
||||
isolateConfigHome(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
launcher := writeCodegraphHelper(t, dir)
|
||||
envOut := filepath.Join(dir, "codegraph-idle-env")
|
||||
t.Setenv("REASONIX_CODEGRAPH_HELPER_ENV_OUT", envOut)
|
||||
|
||||
writeFile(t, dir, "reasonix.toml", fmt.Sprintf(`
|
||||
default_model = "test-model"
|
||||
|
||||
[codegraph]
|
||||
enabled = true
|
||||
path = %q
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE"
|
||||
|
||||
[[providers]]
|
||||
name = "test-model"
|
||||
kind = "openai"
|
||||
base_url = "https://example.invalid"
|
||||
model = "x"
|
||||
api_key_env = "REASONIX_TEST_KEY_UNSET"
|
||||
`, launcher))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
ctrl, err := Build(ctx, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
defer ctrl.Close()
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
var got []byte
|
||||
for {
|
||||
got, err = os.ReadFile(envOut)
|
||||
if err == nil && string(got) == codegraph.ReasonixDaemonIdleTimeoutMS {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("codegraph helper idle timeout env = %q, want %q (read error: %v)", got, codegraph.ReasonixDaemonIdleTimeoutMS, err)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWarmCodegraphIgnoresLegacyEagerTier(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("fake launcher is a POSIX-sh script")
|
||||
}
|
||||
isolateConfigHome(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
if err := os.Mkdir(filepath.Join(dir, ".codegraph"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
launcher := filepath.Join(dir, "slow-codegraph")
|
||||
writeFile(t, dir, "slow-codegraph", "#!/bin/sh\nif [ \"$1\" = serve ]; then sleep 5; fi\n")
|
||||
if err := os.Chmod(launcher, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
writeFile(t, dir, "reasonix.toml", fmt.Sprintf(`
|
||||
default_model = "test-model"
|
||||
|
||||
[codegraph]
|
||||
enabled = true
|
||||
path = %q
|
||||
tier = "eager"
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE"
|
||||
|
||||
[[providers]]
|
||||
name = "test-model"
|
||||
kind = "openai"
|
||||
base_url = "https://example.invalid"
|
||||
model = "x"
|
||||
api_key_env = "REASONIX_TEST_KEY_UNSET"
|
||||
`, launcher))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancel()
|
||||
ctrl, err := Build(ctx, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("Build should not block on warm codegraph with legacy eager tier: %v", err)
|
||||
}
|
||||
defer ctrl.Close()
|
||||
}
|
||||
|
||||
func TestBuildDefaultsToNearestGitRoot(t *testing.T) {
|
||||
isolateConfigHome(t)
|
||||
root := robustTempDir(t)
|
||||
@@ -2200,9 +1925,6 @@ func TestBuildDefaultsToNearestGitRoot(t *testing.T) {
|
||||
writeFile(t, root, "reasonix.toml", `
|
||||
default_model = "root-model"
|
||||
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE"
|
||||
|
||||
@@ -2239,9 +1961,6 @@ func TestBuildMigratesLegacyEagerBeforeStatsDemotion(t *testing.T) {
|
||||
writeFile(t, dir, "reasonix.toml", `
|
||||
default_model = "test-model"
|
||||
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE"
|
||||
|
||||
@@ -2368,82 +2087,3 @@ func TestHelperProcess(t *testing.T) {
|
||||
os.Stdout.Write(append(b, '\n'))
|
||||
}
|
||||
}
|
||||
|
||||
func writeCodegraphHelper(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, "codegraph-helper")
|
||||
if runtime.GOOS == "windows" {
|
||||
path += ".exe"
|
||||
}
|
||||
src := filepath.Join(dir, "codegraph-helper.go")
|
||||
if err := os.WriteFile(src, []byte(`package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) >= 3 && os.Args[1] == "init" {
|
||||
_ = os.MkdirAll(filepath.Join(os.Args[2], ".codegraph"), 0o755)
|
||||
return
|
||||
}
|
||||
if len(os.Args) >= 2 && os.Args[1] == "serve" {
|
||||
if out := os.Getenv("REASONIX_CODEGRAPH_HELPER_ENV_OUT"); out != "" {
|
||||
_ = os.WriteFile(out, []byte(os.Getenv("CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS")), 0o644)
|
||||
}
|
||||
}
|
||||
|
||||
in := bufio.NewReader(os.Stdin)
|
||||
for {
|
||||
line, err := in.ReadBytes('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
line = bytes.TrimSpace(line)
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ID *int `+"`json:\"id\"`"+`
|
||||
Method string `+"`json:\"method\"`"+`
|
||||
Params json.RawMessage `+"`json:\"params\"`"+`
|
||||
}
|
||||
if err := json.Unmarshal(line, &req); err != nil || req.ID == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var result any
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
result = map[string]any{
|
||||
"protocolVersion": "2024-11-05",
|
||||
"serverInfo": map[string]any{"name": "codegraph", "version": "0"},
|
||||
"capabilities": map[string]any{},
|
||||
}
|
||||
case "tools/list":
|
||||
result = map[string]any{"tools": []map[string]any{{
|
||||
"name": "search",
|
||||
"description": "Search symbols.",
|
||||
"inputSchema": map[string]any{"type": "object"},
|
||||
}}}
|
||||
}
|
||||
|
||||
resp := map[string]any{"jsonrpc": "2.0", "id": *req.ID, "result": result}
|
||||
b, _ := json.Marshal(resp)
|
||||
_, _ = os.Stdout.Write(append(b, '\n'))
|
||||
}
|
||||
}
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := exec.Command("go", "build", "-o", path, src)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("build codegraph helper: %v\n%s", err, out)
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -21,9 +21,6 @@ func TestBuildUnknownModelErrorIsActionable(t *testing.T) {
|
||||
writeFile(t, dir, "reasonix.toml", `
|
||||
default_model = "mimo"
|
||||
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[[providers]]
|
||||
name = "deepseek-flash"
|
||||
kind = "openai"
|
||||
@@ -54,9 +51,6 @@ func TestBuildNoticesMissingAPIKey(t *testing.T) {
|
||||
writeFile(t, dir, "reasonix.toml", `
|
||||
default_model = "x"
|
||||
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[[providers]]
|
||||
name = "x"
|
||||
kind = "openai"
|
||||
|
||||
@@ -17,11 +17,12 @@ const (
|
||||
TokenModeEconomy = "economy"
|
||||
)
|
||||
|
||||
const tokenEconomyPrompt = `Token economy mode is on. Keep the default tool surface lean. Optional sources are hidden behind connect_tool_source; enable skills, MCP servers, CodeGraph, LSP, web_fetch, install_source, or task only when the current request actually needs them.`
|
||||
const tokenEconomyPrompt = `Token economy mode is on. Keep the default tool surface lean. Optional sources are hidden behind connect_tool_source; enable skills, MCP servers, LSP, web_fetch, install_source, or task only when the current request actually needs them.`
|
||||
|
||||
var tokenEconomyCoreBuiltins = []string{
|
||||
"bash",
|
||||
"bash_output",
|
||||
"code_index",
|
||||
"complete_step",
|
||||
"edit_file",
|
||||
"glob",
|
||||
@@ -69,20 +70,19 @@ func tokenEconomyBuiltins(configured []string) []string {
|
||||
type toolSourceConnector struct {
|
||||
mu sync.Mutex
|
||||
|
||||
skills func(context.Context) (string, error)
|
||||
task func(context.Context) (string, error)
|
||||
install func(context.Context) (string, error)
|
||||
webFetch func(context.Context) (string, error)
|
||||
lsp func(context.Context) (string, error)
|
||||
codegraph func(context.Context) (string, error)
|
||||
mcp func(context.Context, string) (string, error)
|
||||
mcpNames []string
|
||||
skills func(context.Context) (string, error)
|
||||
task func(context.Context) (string, error)
|
||||
install func(context.Context) (string, error)
|
||||
webFetch func(context.Context) (string, error)
|
||||
lsp func(context.Context) (string, error)
|
||||
mcp func(context.Context, string) (string, error)
|
||||
mcpNames []string
|
||||
}
|
||||
|
||||
func (*toolSourceConnector) Name() string { return "connect_tool_source" }
|
||||
|
||||
func (*toolSourceConnector) Description() string {
|
||||
return "Token economy mode only: enable an optional tool source when the task needs it. Sources: skills, mcp, codegraph, lsp, web_fetch, install_source, task. For mcp, pass the configured server name; omit name to list servers. Newly enabled tools are available on the next model request."
|
||||
return "Token economy mode only: enable an optional tool source when the task needs it. Sources: skills, mcp, lsp, web_fetch, install_source, task. For mcp, pass the configured server name; omit name to list servers. Newly enabled tools are available on the next model request."
|
||||
}
|
||||
|
||||
func (*toolSourceConnector) ReadOnly() bool { return true }
|
||||
@@ -91,7 +91,7 @@ func (*toolSourceConnector) Schema() json.RawMessage {
|
||||
return json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"source":{"type":"string","description":"Tool source to enable: skills, mcp, codegraph, lsp, web_fetch, install_source, or task."},
|
||||
"source":{"type":"string","description":"Tool source to enable: skills, mcp, lsp, web_fetch, install_source, or task."},
|
||||
"name":{"type":"string","description":"For source=mcp, the configured server name. Omit to list configured MCP servers without connecting them."}
|
||||
},
|
||||
"required":["source"]
|
||||
@@ -131,8 +131,6 @@ func (t *toolSourceConnector) Execute(ctx context.Context, args json.RawMessage)
|
||||
return runSourceInstaller(ctx, "web_fetch", t.webFetch)
|
||||
case "lsp":
|
||||
return runSourceInstaller(ctx, "lsp", t.lsp)
|
||||
case "codegraph":
|
||||
return runSourceInstaller(ctx, "codegraph", t.codegraph)
|
||||
case "mcp":
|
||||
name := strings.TrimSpace(p.Name)
|
||||
if name == "" {
|
||||
@@ -158,8 +156,6 @@ func normalizeToolSource(source string) string {
|
||||
return "skills"
|
||||
case "mcp", "plugin", "plugins", "server", "servers":
|
||||
return "mcp"
|
||||
case "codegraph", "code_graph", "symbol", "symbols":
|
||||
return "codegraph"
|
||||
case "lsp", "language_server", "language-servers":
|
||||
return "lsp"
|
||||
case "web", "web_fetch", "webfetch", "fetch":
|
||||
@@ -181,9 +177,6 @@ func (t *toolSourceConnector) availableSources() []string {
|
||||
if t.mcp != nil || len(t.mcpNames) > 0 {
|
||||
out = append(out, "mcp")
|
||||
}
|
||||
if t.codegraph != nil {
|
||||
out = append(out, "codegraph")
|
||||
}
|
||||
if t.lsp != nil {
|
||||
out = append(out, "lsp")
|
||||
}
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
// Package builtinmcp defines MCP servers that ship with Reasonix without
|
||||
// requiring user configuration.
|
||||
package builtinmcp
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
TimeName = "time"
|
||||
Context7Name = "context7"
|
||||
)
|
||||
|
||||
var (
|
||||
executablePathDefault = os.Executable
|
||||
lookPathDefault = exec.LookPath
|
||||
currentExecutable = executablePathDefault
|
||||
lookPath = lookPathDefault
|
||||
)
|
||||
|
||||
// Entries returns the built-in MCP servers that are always available. They use
|
||||
// the lazy tier so startup never blocks on package installation or network.
|
||||
func Entries() []config.PluginEntry {
|
||||
return []config.PluginEntry{
|
||||
{
|
||||
Name: TimeName,
|
||||
Type: "stdio",
|
||||
Command: executablePath(),
|
||||
Args: []string{"builtin-mcp", TimeName},
|
||||
Tier: "lazy",
|
||||
},
|
||||
context7Entry(),
|
||||
}
|
||||
}
|
||||
|
||||
func executablePath() string {
|
||||
if path, err := currentExecutable(); err == nil && path != "" {
|
||||
return path
|
||||
}
|
||||
return "reasonix"
|
||||
}
|
||||
|
||||
func context7Entry() config.PluginEntry {
|
||||
command, args := context7Command()
|
||||
return config.PluginEntry{
|
||||
Name: Context7Name,
|
||||
Type: "stdio",
|
||||
Command: command,
|
||||
Args: args,
|
||||
Tier: "lazy",
|
||||
}
|
||||
}
|
||||
|
||||
func context7Command() (string, []string) {
|
||||
if _, err := lookPath("npx"); err == nil {
|
||||
return "npx", []string{"-y", "@upstash/context7-mcp"}
|
||||
}
|
||||
if _, err := lookPath("pnpm"); err == nil {
|
||||
return "pnpm", []string{"dlx", "@upstash/context7-mcp"}
|
||||
}
|
||||
if _, err := lookPath("bunx"); err == nil {
|
||||
return "bunx", []string{"@upstash/context7-mcp"}
|
||||
}
|
||||
return "npx", []string{"-y", "@upstash/context7-mcp"}
|
||||
}
|
||||
|
||||
// Entry returns one built-in MCP entry by name.
|
||||
func Entry(name string) (config.PluginEntry, bool) {
|
||||
for _, e := range Entries() {
|
||||
if e.Name == name {
|
||||
return e, true
|
||||
}
|
||||
}
|
||||
return config.PluginEntry{}, false
|
||||
}
|
||||
|
||||
// IsBuiltIn reports whether name is a Reasonix-shipped MCP server.
|
||||
func IsBuiltIn(name string) bool {
|
||||
_, ok := Entry(name)
|
||||
return ok
|
||||
}
|
||||
|
||||
// AppendMissing appends built-in MCP entries unless a configured or
|
||||
// session-scoped entry with the same name exists. Explicit user and host config
|
||||
// wins, including auto_start=false.
|
||||
func AppendMissing(out []config.PluginEntry, configured []config.PluginEntry, reservedNames ...string) []config.PluginEntry {
|
||||
return AppendEnabled(out, configured, []string{TimeName, Context7Name}, reservedNames...)
|
||||
}
|
||||
|
||||
// AppendEnabled is like AppendMissing but only appends enabled built-in names.
|
||||
func AppendEnabled(out []config.PluginEntry, configured []config.PluginEntry, enabledNames []string, reservedNames ...string) []config.PluginEntry {
|
||||
seen := make(map[string]bool, len(configured))
|
||||
for _, e := range configured {
|
||||
seen[e.Name] = true
|
||||
}
|
||||
for _, name := range reservedNames {
|
||||
seen[name] = true
|
||||
}
|
||||
enabled := make(map[string]bool, len(enabledNames))
|
||||
for _, name := range enabledNames {
|
||||
enabled[name] = true
|
||||
}
|
||||
for _, e := range Entries() {
|
||||
if enabled[e.Name] && !seen[e.Name] {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
package builtinmcp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEntries(t *testing.T) {
|
||||
currentExecutable = func() (string, error) { return "reasonix", nil }
|
||||
lookPath = func(file string) (string, error) {
|
||||
if file == "npx" {
|
||||
return "/usr/bin/npx", nil
|
||||
}
|
||||
return "", errors.New("not found")
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
currentExecutable = executablePathDefault
|
||||
lookPath = lookPathDefault
|
||||
})
|
||||
|
||||
entries := Entries()
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("Entries() length = %d, want 2", len(entries))
|
||||
}
|
||||
want := map[string][]string{
|
||||
TimeName: []string{"builtin-mcp", "time"},
|
||||
Context7Name: []string{"-y", "@upstash/context7-mcp"},
|
||||
}
|
||||
for _, e := range entries {
|
||||
args, ok := want[e.Name]
|
||||
if !ok {
|
||||
t.Fatalf("unexpected built-in MCP entry: %+v", e)
|
||||
}
|
||||
wantCommand := map[string]string{
|
||||
TimeName: "reasonix",
|
||||
Context7Name: "npx",
|
||||
}[e.Name]
|
||||
if e.Type != "stdio" || e.Command != wantCommand || e.Tier != "lazy" {
|
||||
t.Fatalf("%s type/command/tier = %q/%q/%q, want stdio/%s/lazy", e.Name, e.Type, e.Command, e.Tier, wantCommand)
|
||||
}
|
||||
if !reflect.DeepEqual(e.Args, args) {
|
||||
t.Fatalf("%s args = %+v, want %+v", e.Name, e.Args, args)
|
||||
}
|
||||
delete(want, e.Name)
|
||||
}
|
||||
if len(want) != 0 {
|
||||
t.Fatalf("missing built-in MCP entries: %+v", want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendMissingLetsUserConfigWin(t *testing.T) {
|
||||
base := Entries()[:1]
|
||||
got := AppendMissing(nil, base)
|
||||
if len(got) != 1 || got[0].Name != Context7Name {
|
||||
t.Fatalf("AppendMissing with configured time = %+v, want only context7", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendMissingLetsReservedNamesWin(t *testing.T) {
|
||||
got := AppendMissing(nil, nil, TimeName)
|
||||
if len(got) != 1 || got[0].Name != Context7Name {
|
||||
t.Fatalf("AppendMissing with reserved time = %+v, want only context7", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendEnabledOnlyAddsEnabledBuiltIns(t *testing.T) {
|
||||
got := AppendEnabled(nil, nil, []string{TimeName})
|
||||
if len(got) != 1 || got[0].Name != TimeName {
|
||||
t.Fatalf("AppendEnabled(time) = %+v, want only time", got)
|
||||
}
|
||||
if got := AppendEnabled(nil, nil, nil); len(got) != 0 {
|
||||
t.Fatalf("AppendEnabled(nil) = %+v, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext7CommandFallsBackThroughJSRunners(t *testing.T) {
|
||||
lookPath = func(file string) (string, error) {
|
||||
if file == "pnpm" {
|
||||
return "/usr/bin/pnpm", nil
|
||||
}
|
||||
return "", errors.New("not found")
|
||||
}
|
||||
t.Cleanup(func() { lookPath = lookPathDefault })
|
||||
|
||||
cmd, args := context7Command()
|
||||
if cmd != "pnpm" || !reflect.DeepEqual(args, []string{"dlx", "@upstash/context7-mcp"}) {
|
||||
t.Fatalf("context7Command = %q %+v, want pnpm dlx", cmd, args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeTimeMCPListsTools(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
in := strings.NewReader(
|
||||
`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}` + "\n" +
|
||||
`{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}` + "\n" +
|
||||
`{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}` + "\n")
|
||||
if err := ServeTimeMCP(in, &out, "test"); err != nil {
|
||||
t.Fatalf("ServeTimeMCP: %v", err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("response lines = %d (%q), want 2", len(lines), out.String())
|
||||
}
|
||||
var resp struct {
|
||||
Result struct {
|
||||
Tools []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"tools"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(lines[1]), &resp); err != nil {
|
||||
t.Fatalf("decode tools/list response: %v", err)
|
||||
}
|
||||
got := []string{resp.Result.Tools[0].Name, resp.Result.Tools[1].Name}
|
||||
want := []string{"get_current_time", "convert_time"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("time MCP tools = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
package builtinmcp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
stdtime "time"
|
||||
)
|
||||
|
||||
const protocolVersion = "2024-11-05"
|
||||
|
||||
// RunCommand runs hidden built-in MCP subcommands shared by the CLI and desktop
|
||||
// binary. It intentionally writes only MCP JSON-RPC frames to stdout.
|
||||
func RunCommand(args []string, in io.Reader, out io.Writer, errOut io.Writer, version string) int {
|
||||
if len(args) != 1 || args[0] != TimeName {
|
||||
fmt.Fprintln(errOut, "usage: reasonix builtin-mcp time")
|
||||
return 2
|
||||
}
|
||||
if serveErr := ServeTimeMCP(in, out, version); serveErr != nil {
|
||||
fmt.Fprintln(errOut, serveErr)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ServeTimeMCP serves Reasonix's dependency-free built-in time MCP over stdio.
|
||||
func ServeTimeMCP(in io.Reader, out io.Writer, version string) error {
|
||||
r := bufio.NewReader(in)
|
||||
w := bufio.NewWriter(out)
|
||||
defer w.Flush()
|
||||
|
||||
for {
|
||||
line, err := r.ReadBytes('\n')
|
||||
if len(strings.TrimSpace(string(line))) > 0 {
|
||||
if handleErr := handleTimeLine(line, w, version); handleErr != nil {
|
||||
return handleErr
|
||||
}
|
||||
if flushErr := w.Flush(); flushErr != nil {
|
||||
return flushErr
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type timeRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID *json.RawMessage `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
}
|
||||
|
||||
type timeResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id"`
|
||||
Result any `json:"result,omitempty"`
|
||||
Error *timeRPCError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type timeRPCError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func handleTimeLine(line []byte, w *bufio.Writer, version string) error {
|
||||
var req timeRequest
|
||||
if err := json.Unmarshal(line, &req); err != nil {
|
||||
return nil
|
||||
}
|
||||
if req.ID == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
resp := timeResponse{JSONRPC: "2.0", ID: *req.ID}
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
resp.Result = map[string]any{
|
||||
"protocolVersion": protocolVersion,
|
||||
"capabilities": map[string]any{
|
||||
"tools": map[string]any{},
|
||||
},
|
||||
"serverInfo": map[string]any{"name": "reasonix-time", "version": version},
|
||||
}
|
||||
case "tools/list":
|
||||
resp.Result = map[string]any{"tools": timeTools()}
|
||||
case "tools/call":
|
||||
resp.Result, resp.Error = callTimeTool(req.Params)
|
||||
default:
|
||||
resp.Error = &timeRPCError{Code: -32601, Message: "method not found: " + req.Method}
|
||||
}
|
||||
|
||||
b, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal response: %w", err)
|
||||
}
|
||||
_, err = w.Write(append(b, '\n'))
|
||||
return err
|
||||
}
|
||||
|
||||
func timeTools() []map[string]any {
|
||||
return []map[string]any{
|
||||
{
|
||||
"name": "get_current_time",
|
||||
"description": "Get the current time in a specific IANA timezone, or the system timezone when omitted.",
|
||||
"inputSchema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"timezone": map[string]any{"type": "string", "description": "IANA timezone name, for example America/New_York"},
|
||||
},
|
||||
},
|
||||
"annotations": map[string]any{"readOnlyHint": true, "title": "get_current_time"},
|
||||
},
|
||||
{
|
||||
"name": "convert_time",
|
||||
"description": "Convert a 24-hour HH:MM time between two IANA timezones using today's date in the source timezone.",
|
||||
"inputSchema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"source_timezone": map[string]any{"type": "string", "description": "Source IANA timezone name"},
|
||||
"time": map[string]any{"type": "string", "description": "Time in 24-hour HH:MM format"},
|
||||
"target_timezone": map[string]any{"type": "string", "description": "Target IANA timezone name"},
|
||||
},
|
||||
"required": []string{"source_timezone", "time", "target_timezone"},
|
||||
},
|
||||
"annotations": map[string]any{"readOnlyHint": true, "title": "convert_time"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func callTimeTool(params json.RawMessage) (any, *timeRPCError) {
|
||||
var p struct {
|
||||
Name string `json:"name"`
|
||||
Arguments map[string]any `json:"arguments"`
|
||||
}
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return nil, &timeRPCError{Code: -32602, Message: "invalid params: " + err.Error()}
|
||||
}
|
||||
var (
|
||||
text string
|
||||
err error
|
||||
)
|
||||
switch p.Name {
|
||||
case "get_current_time":
|
||||
text, err = getCurrentTime(p.Arguments)
|
||||
case "convert_time":
|
||||
text, err = convertTime(p.Arguments)
|
||||
default:
|
||||
return nil, &timeRPCError{Code: -32602, Message: "unknown tool: " + p.Name}
|
||||
}
|
||||
if err != nil {
|
||||
return timeTextResult(err.Error(), true), nil
|
||||
}
|
||||
return timeTextResult(text, false), nil
|
||||
}
|
||||
|
||||
func getCurrentTime(args map[string]any) (string, error) {
|
||||
loc, name, err := loadLocation(optionalString(args, "timezone"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
now := stdtime.Now().In(loc)
|
||||
return marshalTimeResult(map[string]any{
|
||||
"timezone": name,
|
||||
"datetime": now.Format(stdtime.RFC3339),
|
||||
"is_dst": now.IsDST(),
|
||||
})
|
||||
}
|
||||
|
||||
func convertTime(args map[string]any) (string, error) {
|
||||
sourceName := requiredString(args, "source_timezone")
|
||||
targetName := requiredString(args, "target_timezone")
|
||||
rawTime := requiredString(args, "time")
|
||||
if sourceName == "" || targetName == "" || rawTime == "" {
|
||||
return "", fmt.Errorf("source_timezone, time, and target_timezone are required")
|
||||
}
|
||||
sourceLoc, _, err := loadLocation(sourceName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
targetLoc, _, err := loadLocation(targetName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
hour, minute, err := parseHHMM(rawTime)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
today := stdtime.Now().In(sourceLoc)
|
||||
source := stdtime.Date(today.Year(), today.Month(), today.Day(), hour, minute, 0, 0, sourceLoc)
|
||||
target := source.In(targetLoc)
|
||||
_, sourceOffset := source.Zone()
|
||||
_, targetOffset := target.Zone()
|
||||
return marshalTimeResult(map[string]any{
|
||||
"source": map[string]any{
|
||||
"timezone": sourceName,
|
||||
"datetime": source.Format(stdtime.RFC3339),
|
||||
"is_dst": source.IsDST(),
|
||||
},
|
||||
"target": map[string]any{
|
||||
"timezone": targetName,
|
||||
"datetime": target.Format(stdtime.RFC3339),
|
||||
"is_dst": target.IsDST(),
|
||||
},
|
||||
"time_difference": fmt.Sprintf("%+.1fh", float64(targetOffset-sourceOffset)/3600),
|
||||
})
|
||||
}
|
||||
|
||||
func loadLocation(name string) (*stdtime.Location, string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return stdtime.Local, stdtime.Local.String(), nil
|
||||
}
|
||||
loc, err := stdtime.LoadLocation(name)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("unknown timezone %q", name)
|
||||
}
|
||||
return loc, name, nil
|
||||
}
|
||||
|
||||
func parseHHMM(raw string) (int, int, error) {
|
||||
parts := strings.Split(strings.TrimSpace(raw), ":")
|
||||
if len(parts) != 2 {
|
||||
return 0, 0, fmt.Errorf("time must use HH:MM format")
|
||||
}
|
||||
hour, err := strconv.Atoi(parts[0])
|
||||
if err != nil || hour < 0 || hour > 23 {
|
||||
return 0, 0, fmt.Errorf("hour must be between 00 and 23")
|
||||
}
|
||||
minute, err := strconv.Atoi(parts[1])
|
||||
if err != nil || minute < 0 || minute > 59 {
|
||||
return 0, 0, fmt.Errorf("minute must be between 00 and 59")
|
||||
}
|
||||
return hour, minute, nil
|
||||
}
|
||||
|
||||
func optionalString(args map[string]any, key string) string {
|
||||
if args == nil {
|
||||
return ""
|
||||
}
|
||||
v, _ := args[key].(string)
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
func requiredString(args map[string]any, key string) string {
|
||||
if args == nil {
|
||||
return ""
|
||||
}
|
||||
v, _ := args[key].(string)
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
func marshalTimeResult(v any) (string, error) {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func timeTextResult(text string, isError bool) map[string]any {
|
||||
return map[string]any{
|
||||
"content": []map[string]any{{"type": "text", "text": text}},
|
||||
"isError": isError,
|
||||
}
|
||||
}
|
||||
@@ -76,9 +76,6 @@ func TestACPFactoryLoadsSessionCwdProjectConfig(t *testing.T) {
|
||||
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
|
||||
default_model = "local"
|
||||
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[[providers]]
|
||||
name = "local"
|
||||
kind = "acp-test-provider"
|
||||
@@ -120,9 +117,6 @@ func TestACPFactoryClearsEffortOverrideForUnsupportedModel(t *testing.T) {
|
||||
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
|
||||
default_model = "reasoner/reasoning-model"
|
||||
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[[providers]]
|
||||
name = "reasoner"
|
||||
kind = "acp-test-provider"
|
||||
|
||||
@@ -209,12 +209,12 @@ func TestToolProgressStreamsThenCollapses(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestToolWorkingLineThenClears proves a dispatched tool that streams no output
|
||||
// (e.g. codegraph_context) shows a live "working · Ns" line so it doesn't look
|
||||
// (e.g. symbol_context) shows a live "working · Ns" line so it doesn't look
|
||||
// frozen, and that the line clears on the result instead of collapsing to
|
||||
// "0 lines".
|
||||
func TestToolWorkingLineThenClears(t *testing.T) {
|
||||
m := newTestChatTUI()
|
||||
m.ingestEvent(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: "c1", Name: "codegraph_context", Args: `{"q":"x"}`}})
|
||||
m.ingestEvent(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: "c1", Name: "symbol_context", Args: `{"q":"x"}`}})
|
||||
|
||||
m.tickToolRunning() // one elapsed tick fills the placeholder
|
||||
joined := strings.Join(m.transcript, "\n")
|
||||
@@ -222,7 +222,7 @@ func TestToolWorkingLineThenClears(t *testing.T) {
|
||||
t.Fatalf("a running tool should show a 'working' progress line:\n%s", joined)
|
||||
}
|
||||
|
||||
m.ingestEvent(event.Event{Kind: event.ToolResult, Tool: event.Tool{ID: "c1", Name: "codegraph_context"}})
|
||||
m.ingestEvent(event.Event{Kind: event.ToolResult, Tool: event.Tool{ID: "c1", Name: "symbol_context"}})
|
||||
joined = strings.Join(m.transcript, "\n")
|
||||
if strings.Contains(joined, "working") {
|
||||
t.Fatalf("working line should clear after the result:\n%s", joined)
|
||||
|
||||
@@ -166,7 +166,7 @@ type chatTUI struct {
|
||||
toolLineCountByID map[string]int
|
||||
// toolStreamStart / toolStreamFrame drive the "⎿ working · Ns" line shown
|
||||
// under a dispatched tool that hasn't produced output yet, so a slow tool
|
||||
// (e.g. codegraph_context) reads as making progress rather than frozen.
|
||||
// reads as making progress rather than frozen.
|
||||
toolStreamStart time.Time
|
||||
toolStreamFrame int
|
||||
transcriptDirty bool
|
||||
|
||||
+1
-18
@@ -23,7 +23,6 @@ import (
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/boot"
|
||||
"reasonix/internal/builtinmcp"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
@@ -51,9 +50,6 @@ func Run(args []string, version string) int {
|
||||
if cmd == "--acp" {
|
||||
cmd = "acp"
|
||||
}
|
||||
if cmd == "builtin-mcp" {
|
||||
return builtinmcp.RunCommand(args[1:], os.Stdin, os.Stdout, os.Stderr, version)
|
||||
}
|
||||
if shouldMigrateLegacyConfigForCLI(cmd) {
|
||||
migrateLegacyConfigForCLI()
|
||||
}
|
||||
@@ -94,9 +90,6 @@ func Run(args []string, version string) int {
|
||||
case "mcp":
|
||||
configureCLIThemeFromConfigNoProbe()
|
||||
return mcpCommand(rest)
|
||||
case "codegraph":
|
||||
configureCLIThemeFromConfigNoProbe()
|
||||
return codegraphCommand(rest)
|
||||
case "doctor":
|
||||
configureCLIThemeFromConfigNoProbe()
|
||||
return doctorCommand(rest, version)
|
||||
@@ -124,7 +117,7 @@ func Run(args []string, version string) int {
|
||||
|
||||
func shouldMigrateLegacyConfigForCLI(cmd string) bool {
|
||||
switch cmd {
|
||||
case "", "run", "chat", "code", "serve", "setup", "config", "init", "acp", "mcp", "codegraph", "doctor", "bot", "upgrade", "update":
|
||||
case "", "run", "chat", "code", "serve", "setup", "config", "init", "acp", "mcp", "doctor", "bot", "upgrade", "update":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -677,9 +670,6 @@ func confirmReconfigureExistingConfig(path string, in *bufio.Scanner, w io.Write
|
||||
|
||||
func writeDefaultConfig(path string) int {
|
||||
c := config.Default()
|
||||
// A freshly scaffolded config starts without the codegraph daemon; existing
|
||||
// configs (which never wrote [codegraph]) keep it on via the built-in default.
|
||||
c.Codegraph.Enabled = false
|
||||
if err := c.SaveTo(path); err != nil {
|
||||
fmt.Fprintln(os.Stderr, i18n.M.WriteConfigErr, err)
|
||||
return 1
|
||||
@@ -709,15 +699,8 @@ func interactiveSetup(configPath, envPath string) int {
|
||||
// Seed from the existing config when reconfiguring, so a re-run to fix a key
|
||||
// preserves the user's providers / agent settings instead of resetting to
|
||||
// defaults. First run (no file) falls back to the built-in defaults.
|
||||
_, statErr := os.Stat(configPath)
|
||||
isNewConfig := statErr != nil
|
||||
cfg := config.LoadForEdit(configPath)
|
||||
prevDefault := cfg.DefaultModel
|
||||
if isNewConfig {
|
||||
// Brand-new user: start without the codegraph daemon. A reconfigure of an
|
||||
// existing config keeps whatever the user already had.
|
||||
cfg.Codegraph.Enabled = false
|
||||
}
|
||||
|
||||
lang, err := selectLanguage()
|
||||
if err != nil {
|
||||
|
||||
@@ -976,13 +976,20 @@ func groupByFamilyKeys(ps []config.ProviderEntry, key string) []int {
|
||||
return members[key]
|
||||
}
|
||||
|
||||
func TestWriteDefaultConfigDisablesCodegraph(t *testing.T) {
|
||||
func TestWriteDefaultConfigOmitsLegacyInternalMCPSections(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "reasonix.toml")
|
||||
if rc := writeDefaultConfig(path); rc != 0 {
|
||||
t.Fatalf("writeDefaultConfig rc = %d", rc)
|
||||
}
|
||||
if c := config.LoadForEdit(path); c.Codegraph.Enabled {
|
||||
t.Fatal("a freshly scaffolded config left codegraph enabled; new users should start without it")
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(raw)
|
||||
for _, forbidden := range []string{"[codegraph]", "[builtin_mcp]", "[builtin_mcp_updates]"} {
|
||||
if strings.Contains(text, forbidden) {
|
||||
t.Fatalf("default config should omit %s:\n%s", forbidden, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"reasonix/internal/codegraph"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/netclient"
|
||||
)
|
||||
|
||||
// codegraphCommand backs `reasonix codegraph` — managing the CodeGraph
|
||||
// code-intelligence runtime that reasonix otherwise fetches lazily on first use.
|
||||
func codegraphCommand(args []string) int {
|
||||
sub := ""
|
||||
if len(args) > 0 {
|
||||
sub = args[0]
|
||||
}
|
||||
switch sub {
|
||||
case "install":
|
||||
return codegraphInstall()
|
||||
case "update":
|
||||
return codegraphUpdate()
|
||||
case "status", "":
|
||||
return codegraphStatus()
|
||||
case "help", "-h", "--help":
|
||||
codegraphUsage()
|
||||
return 0
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown codegraph subcommand %q\n\n", sub)
|
||||
codegraphUsage()
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
func codegraphInstall() int {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
client, err := netclient.NewHTTPClient(cfg.NetworkProxySpec(), netclient.TransportOptions{})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
p, err := codegraph.InstallWithClient(context.Background(), client, func(m string) { fmt.Println(m) })
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
fmt.Println("codegraph ready:", p)
|
||||
return 0
|
||||
}
|
||||
|
||||
func codegraphUpdate() int {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
client, err := netclient.NewHTTPClient(cfg.NetworkProxySpec(), netclient.TransportOptions{})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
res, err := codegraph.UpdateWithClient(context.Background(), client, func(m string) { fmt.Println(m) })
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("codegraph updated: %s (%s)\n", res.Path, res.Version)
|
||||
return 0
|
||||
}
|
||||
|
||||
func codegraphStatus() int {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("%-13s %v\n", "enabled:", cfg.Codegraph.Enabled)
|
||||
fmt.Printf("%-13s %v\n", "auto_install:", cfg.Codegraph.AutoInstall)
|
||||
fmt.Printf("%-13s %s\n", "startup:", cfg.Codegraph.ResolvedTier())
|
||||
fmt.Printf("%-13s %s\n", "version:", codegraph.Version)
|
||||
if active := codegraph.ActiveVersion(); active != "" {
|
||||
fmt.Printf("%-13s %s\n", "active:", active)
|
||||
}
|
||||
fmt.Printf("%-13s %s\n", "cache:", codegraph.CacheDir())
|
||||
if p, ok := codegraph.Resolve(cfg.Codegraph.Path); ok {
|
||||
fmt.Printf("%-13s %s\n", "resolved:", p)
|
||||
} else {
|
||||
fmt.Printf("%-13s %s\n", "resolved:", "(not installed — run `reasonix codegraph install`)")
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func codegraphUsage() {
|
||||
fmt.Print(`reasonix codegraph — manage the CodeGraph code-intelligence runtime
|
||||
|
||||
Usage:
|
||||
reasonix codegraph install download + cache the runtime for this platform
|
||||
reasonix codegraph update download latest upstream runtime and make it active
|
||||
reasonix codegraph status show config, cache dir, and resolved launcher
|
||||
|
||||
CodeGraph is fetched automatically on first use (unless [codegraph].auto_install
|
||||
is false); install uses Reasonix's pinned runtime. Update is explicit because a
|
||||
new CodeGraph release can change MCP tool schemas and prompt-cache shape.
|
||||
`)
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/codegraph"
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
@@ -192,20 +191,6 @@ func mcpList() int {
|
||||
return 1
|
||||
}
|
||||
listed := 0
|
||||
// CodeGraph is a built-in server injected by boot, not a [[plugins]] entry, so
|
||||
// report its resolved status here too. It is listed even when disabled, matching
|
||||
// the MCP manager where the user can enable it.
|
||||
codegraphMeta := fmt.Sprintf(" [enabled=%v auto_install=%v]", cfg.Codegraph.Enabled, cfg.Codegraph.AutoInstall)
|
||||
if bin, ok := codegraph.Resolve(cfg.Codegraph.Path); ok {
|
||||
fmt.Printf("%-16s (stdio, built-in)%s %s serve --mcp\n", "codegraph", codegraphMeta, bin)
|
||||
} else {
|
||||
fmt.Printf("%-16s (built-in, not installed)%s run `reasonix codegraph install`", "codegraph", codegraphMeta)
|
||||
if cfg.Codegraph.Enabled && cfg.Codegraph.AutoInstall {
|
||||
fmt.Print(" (or let auto_install fetch it on next startup)")
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
listed++
|
||||
for _, p := range cfg.Plugins {
|
||||
typ := p.Type
|
||||
if typ == "" {
|
||||
|
||||
@@ -303,9 +303,7 @@ func (m chatTUI) buildMCPSnapshot() mcpSnapshot {
|
||||
}
|
||||
configured := map[string]config.PluginEntry{}
|
||||
var configuredEntries []config.PluginEntry
|
||||
var loadedCfg *config.Config
|
||||
if cfg != nil {
|
||||
loadedCfg = cfg
|
||||
configuredEntries = append(configuredEntries, cfg.Plugins...)
|
||||
for _, p := range configuredEntries {
|
||||
configured[p.Name] = p
|
||||
@@ -316,14 +314,11 @@ func (m chatTUI) buildMCPSnapshot() mcpSnapshot {
|
||||
for _, s := range m.host.Servers() {
|
||||
v := mcpServerView{
|
||||
Name: s.Name, Transport: fallbackText(s.Transport, "stdio"), Status: "connected",
|
||||
BuiltIn: s.Name == "codegraph",
|
||||
Tools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,
|
||||
Tools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,
|
||||
ToolList: append([]plugin.ToolInfo(nil), s.ToolList...),
|
||||
}
|
||||
if p, ok := configured[s.Name]; ok {
|
||||
v = withMCPPluginConfig(v, p)
|
||||
} else if s.Name == "codegraph" && loadedCfg != nil {
|
||||
v = withMCPCodegraphConfig(v, loadedCfg.Codegraph)
|
||||
}
|
||||
snap.servers = append(snap.servers, v)
|
||||
seen[s.Name] = true
|
||||
@@ -331,13 +326,10 @@ func (m chatTUI) buildMCPSnapshot() mcpSnapshot {
|
||||
for _, f := range m.host.Failures() {
|
||||
v := mcpServerView{
|
||||
Name: f.Name, Transport: fallbackText(f.Transport, "stdio"), Status: "failed",
|
||||
BuiltIn: f.Name == "codegraph",
|
||||
Error: f.Error,
|
||||
Error: f.Error,
|
||||
}
|
||||
if p, ok := configured[f.Name]; ok {
|
||||
v = withMCPPluginConfig(v, p)
|
||||
} else if f.Name == "codegraph" && loadedCfg != nil {
|
||||
v = withMCPCodegraphConfig(v, loadedCfg.Codegraph)
|
||||
}
|
||||
snap.servers = append(snap.servers, v)
|
||||
seen[f.Name] = true
|
||||
@@ -360,15 +352,6 @@ func (m chatTUI) buildMCPSnapshot() mcpSnapshot {
|
||||
snap.servers = append(snap.servers, v)
|
||||
seen[p.Name] = true
|
||||
}
|
||||
if loadedCfg != nil && !seen["codegraph"] {
|
||||
status := "initializing"
|
||||
if m.mcpDisabled["codegraph"] || !loadedCfg.Codegraph.Enabled {
|
||||
status = "disabled"
|
||||
}
|
||||
snap.servers = append(snap.servers, withMCPCodegraphConfig(mcpServerView{
|
||||
Name: "codegraph", Status: status,
|
||||
}, loadedCfg.Codegraph))
|
||||
}
|
||||
return snap
|
||||
}
|
||||
|
||||
@@ -398,17 +381,6 @@ func withMCPPluginConfig(v mcpServerView, p config.PluginEntry) mcpServerView {
|
||||
return v
|
||||
}
|
||||
|
||||
func withMCPCodegraphConfig(v mcpServerView, c config.CodegraphConfig) mcpServerView {
|
||||
v.Name = "codegraph"
|
||||
v.Transport = "stdio"
|
||||
v.BuiltIn = true
|
||||
v.Configured = true
|
||||
v.AutoStart = c.ShouldAutoStart()
|
||||
v.Tier = c.ResolvedTier()
|
||||
v.AuthStatus = mcpdiag.AuthNone
|
||||
return v
|
||||
}
|
||||
|
||||
func visibleRange(total, sel, limit int) (int, int) {
|
||||
if limit <= 0 || total <= limit {
|
||||
return 0, total
|
||||
|
||||
@@ -50,20 +50,6 @@ func (m chatTUI) connectSelectedMCP(v mcpServerView) (tea.Model, tea.Cmd) {
|
||||
m.notice("mcp: no active session")
|
||||
return m, nil
|
||||
}
|
||||
if v.BuiltIn && v.Name == "codegraph" {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
m.notice("mcp connect: " + err.Error())
|
||||
return m, nil
|
||||
}
|
||||
if !cfg.Codegraph.Enabled {
|
||||
cfg.Codegraph.Enabled = true
|
||||
if err := cfg.Save(); err != nil {
|
||||
m.notice("mcp connect: " + err.Error())
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if v.Status == "connected" {
|
||||
m.ctrl.DisconnectMCPServer(v.Name)
|
||||
}
|
||||
@@ -91,22 +77,6 @@ func (m chatTUI) disableSelectedMCP(v mcpServerView) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
persisted := false
|
||||
if v.BuiltIn && v.Name == "codegraph" {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
m.notice("mcp disable: " + err.Error())
|
||||
return m, nil
|
||||
}
|
||||
cfg.Codegraph.Enabled = false
|
||||
if err := cfg.Save(); err != nil {
|
||||
m.notice("mcp disable: " + err.Error())
|
||||
return m, nil
|
||||
}
|
||||
persisted = true
|
||||
if h := m.ctrl.Host(); h != nil {
|
||||
h.ClearFailure(v.Name)
|
||||
}
|
||||
}
|
||||
if m.mcpDisabled == nil {
|
||||
m.mcpDisabled = map[string]bool{}
|
||||
}
|
||||
@@ -169,31 +139,6 @@ func (m chatTUI) applyMCPMode(tier string) (tea.Model, tea.Cmd) {
|
||||
m.notice("mcp mode: " + err.Error())
|
||||
return m, nil
|
||||
}
|
||||
if v.BuiltIn && v.Name == "codegraph" {
|
||||
cfg.Codegraph.Enabled = true
|
||||
cfg.Codegraph.Tier = ""
|
||||
if err := cfg.Save(); err != nil {
|
||||
m.notice("mcp mode: " + err.Error())
|
||||
return m, nil
|
||||
}
|
||||
if m.mcpDisabled != nil {
|
||||
delete(m.mcpDisabled, v.Name)
|
||||
}
|
||||
if m.ctrl != nil && !mcpConnected(m.ctrl, v.Name) {
|
||||
if _, err := m.ctrl.ConnectConfiguredMCPServer(v.Name); err != nil {
|
||||
recordMCPModeCodegraphFailure(m.ctrl, cfg.Codegraph, err)
|
||||
m.notice("saved connection mode, but connect failed: " + err.Error())
|
||||
}
|
||||
m.host = m.ctrl.Host()
|
||||
}
|
||||
m.refreshMCPManager()
|
||||
if m.mcp != nil {
|
||||
m.mcp.stage = mcpStageDetail
|
||||
m.mcp.selectName(v.Name)
|
||||
}
|
||||
m.notice("updated connection mode for " + v.Name)
|
||||
return m, nil
|
||||
}
|
||||
found := false
|
||||
var selected config.PluginEntry
|
||||
for i := range cfg.Plugins {
|
||||
@@ -250,22 +195,6 @@ func recordMCPModePluginFailure(ctrl *control.Controller, e config.PluginEntry,
|
||||
}, err)
|
||||
}
|
||||
|
||||
func recordMCPModeCodegraphFailure(ctrl *control.Controller, c config.CodegraphConfig, err error) {
|
||||
if ctrl == nil || ctrl.Host() == nil || err == nil {
|
||||
return
|
||||
}
|
||||
cmd := strings.TrimSpace(c.Path)
|
||||
if cmd == "" {
|
||||
cmd = "codegraph"
|
||||
}
|
||||
ctrl.Host().RecordFailure(plugin.Spec{
|
||||
Name: "codegraph",
|
||||
Type: "stdio",
|
||||
Command: cmd,
|
||||
Args: []string{"serve", "--mcp"},
|
||||
}, err)
|
||||
}
|
||||
|
||||
func (m chatTUI) openMCPConfig() (tea.Model, tea.Cmd) {
|
||||
path := ""
|
||||
if m.mcp != nil {
|
||||
@@ -319,7 +248,7 @@ func (m chatTUI) clearSelectedMCPAuthentication() (tea.Model, tea.Cmd) {
|
||||
|
||||
func (m chatTUI) clearMCPAuthentication(v mcpServerView) (tea.Model, tea.Cmd) {
|
||||
if v.BuiltIn {
|
||||
m.notice("codegraph is built in; it has no stored MCP authentication")
|
||||
m.notice("managed MCP servers do not store authentication")
|
||||
return m, nil
|
||||
}
|
||||
_, changed, _, err := config.ClearPluginAuthenticationInSource(v.Name)
|
||||
|
||||
@@ -70,7 +70,7 @@ func (p *mcpManager) renderList(width int) string {
|
||||
s := p.snapshot.servers[i]
|
||||
group := "User MCPs"
|
||||
if s.BuiltIn {
|
||||
group = "Built-in MCPs"
|
||||
group = "Managed MCPs"
|
||||
}
|
||||
if group != lastGroup {
|
||||
if lastGroup != "" {
|
||||
@@ -262,11 +262,7 @@ func mcpActionsFor(v mcpServerView, configPath string) []mcpActionItem {
|
||||
out = append(out, mcpActionItem{mcpActionClearAuth, "Clear authentication"})
|
||||
}
|
||||
if v.Status != "disabled" {
|
||||
label := "Disable for this session"
|
||||
if v.BuiltIn && v.Name == "codegraph" {
|
||||
label = "Disable"
|
||||
}
|
||||
out = append(out, mcpActionItem{mcpActionDisable, label})
|
||||
out = append(out, mcpActionItem{mcpActionDisable, "Disable for this session"})
|
||||
}
|
||||
if !v.BuiltIn {
|
||||
out = append(out, mcpActionItem{mcpActionRemove, "Remove server"})
|
||||
@@ -280,11 +276,7 @@ func appendMCPFailureSecondaryActions(out []mcpActionItem, v mcpServerView, conf
|
||||
}
|
||||
out = appendMCPConfigActions(out, v, configPath)
|
||||
if v.Status != "disabled" {
|
||||
label := "Disable for this session"
|
||||
if v.BuiltIn && v.Name == "codegraph" {
|
||||
label = "Disable"
|
||||
}
|
||||
out = append(out, mcpActionItem{mcpActionDisable, label})
|
||||
out = append(out, mcpActionItem{mcpActionDisable, "Disable for this session"})
|
||||
}
|
||||
if !v.BuiltIn {
|
||||
out = append(out, mcpActionItem{mcpActionRemove, "Remove server"})
|
||||
|
||||
@@ -161,7 +161,7 @@ func TestRenderMCPManagerListGroupsRuntimeAndConfiguredServers(t *testing.T) {
|
||||
p := &mcpManager{snapshot: mcpSnapshot{
|
||||
configPath: "reasonix.toml",
|
||||
servers: []mcpServerView{
|
||||
{Name: "codegraph", Transport: "stdio", Status: "connected", BuiltIn: true, Tools: 4},
|
||||
{Name: "managed-search", Transport: "stdio", Status: "connected", BuiltIn: true, Tools: 4},
|
||||
{Name: "github", Transport: "stdio", Status: "deferred", Configured: true, Tier: "lazy", Tools: 12},
|
||||
{Name: "figma", Transport: "http", Status: "failed", Configured: true, Tier: "lazy", URL: "https://mcp.figma.com", Error: "connect: 401 unauthorized"},
|
||||
},
|
||||
@@ -170,9 +170,9 @@ func TestRenderMCPManagerListGroupsRuntimeAndConfiguredServers(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"Manage MCP servers",
|
||||
"3 servers",
|
||||
"Built-in MCPs",
|
||||
"Managed MCPs",
|
||||
"User MCPs (reasonix.toml)",
|
||||
"codegraph",
|
||||
"managed-search",
|
||||
"connected",
|
||||
"github",
|
||||
"connect on use",
|
||||
@@ -453,85 +453,13 @@ func TestApplyMCPModeRecordsPluginConnectFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMCPModeRecordsCodegraphConnectFailure(t *testing.T) {
|
||||
isolateUserConfig(t)
|
||||
t.Setenv("PATH", "")
|
||||
t.Setenv("REASONIX_CACHE_DIR", t.TempDir())
|
||||
cfg := config.Default()
|
||||
cfg.Codegraph.Enabled = false
|
||||
cfg.Codegraph.Tier = "eager"
|
||||
if err := cfg.SaveTo("reasonix.toml"); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
m := newTestChatTUI()
|
||||
m.ctrl = control.New(control.Options{Host: plugin.NewHost()})
|
||||
defer m.ctrl.Close()
|
||||
m.host = m.ctrl.Host()
|
||||
m.mcp = &mcpManager{
|
||||
stage: mcpStageMode,
|
||||
name: "codegraph",
|
||||
snapshot: mcpSnapshot{configPath: "reasonix.toml", servers: []mcpServerView{{
|
||||
Name: "codegraph", Transport: "stdio", Status: "disabled", BuiltIn: true, Configured: true, Tier: "background",
|
||||
}}},
|
||||
}
|
||||
|
||||
_, _ = m.applyMCPMode("eager")
|
||||
|
||||
failures := m.ctrl.Host().Failures()
|
||||
if len(failures) != 1 || failures[0].Name != "codegraph" {
|
||||
t.Fatalf("Host.Failures() = %+v, want codegraph failure", failures)
|
||||
}
|
||||
if !strings.Contains(failures[0].Error, "not installed") {
|
||||
t.Fatalf("codegraph failure error = %q, want not installed", failures[0].Error)
|
||||
}
|
||||
v, ok := m.mcp.selectedServer()
|
||||
if !ok {
|
||||
t.Fatal("selected server missing after refresh")
|
||||
}
|
||||
if v.Status != "failed" {
|
||||
t.Fatalf("codegraph status = %q, want failed; server = %+v", v.Status, v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisableCodegraphPersistsEnabledFalse(t *testing.T) {
|
||||
isolateUserConfig(t)
|
||||
cfg := config.Default()
|
||||
cfg.Codegraph.Enabled = true
|
||||
cfg.Codegraph.Tier = "background"
|
||||
if err := cfg.SaveTo("reasonix.toml"); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
m := newTestChatTUI()
|
||||
m.ctrl = control.New(control.Options{Host: plugin.NewHost()})
|
||||
defer m.ctrl.Close()
|
||||
m.mcp = &mcpManager{
|
||||
stage: mcpStageDetail,
|
||||
name: "codegraph",
|
||||
snapshot: mcpSnapshot{configPath: "reasonix.toml", servers: []mcpServerView{{
|
||||
Name: "codegraph", Transport: "stdio", Status: "connected", BuiltIn: true, Configured: true, AutoStart: true, Tier: "background",
|
||||
}}},
|
||||
}
|
||||
|
||||
_, _ = m.disableSelectedMCP(m.mcp.snapshot.servers[0])
|
||||
|
||||
loaded, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("load config: %v", err)
|
||||
}
|
||||
if loaded.Codegraph.Enabled {
|
||||
t.Fatalf("codegraph enabled = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPManagerEscFromDetailReturnsToList(t *testing.T) {
|
||||
m := newTestChatTUI()
|
||||
m.mcp = &mcpManager{
|
||||
stage: mcpStageDetail,
|
||||
name: "codegraph",
|
||||
name: "managed-search",
|
||||
snapshot: mcpSnapshot{servers: []mcpServerView{{
|
||||
Name: "codegraph", Transport: "stdio", Status: "connected", BuiltIn: true,
|
||||
Name: "managed-search", Transport: "stdio", Status: "connected", BuiltIn: true,
|
||||
}}},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
package codegraph
|
||||
|
||||
// releaseAssetSHA256 holds the upstream SHA256SUMS values for Version. These
|
||||
// are baked into the reasonix binary so downloaded CodeGraph archives are
|
||||
// verified against trusted metadata instead of trusting whichever mirror served
|
||||
// the bytes.
|
||||
var releaseAssetSHA256 = map[string]string{
|
||||
"codegraph-darwin-arm64.tar.gz": "83a3b90bc334ab2a34240e29e9fab7ff273ab6381794aa6f3cba428397c916b5",
|
||||
"codegraph-darwin-x64.tar.gz": "74d2331161a317fa6164285a61ec480ce7893be46c1677a4b5a2932e35586b9d",
|
||||
"codegraph-linux-arm64.tar.gz": "7b4225f90ca5285cccfec099323129348c2753bcbc9910281f9b61db88fa5f37",
|
||||
"codegraph-linux-x64.tar.gz": "61805e3c9b4052db53c71241b800859095fea4f2cbd2a1844a6c2b9594b9f84a",
|
||||
"codegraph-win32-arm64.zip": "c728ada3d42701213dde26d8e94ded3ed1c7d4b568124210649ce8f9f938a31a",
|
||||
"codegraph-win32-x64.zip": "a5571d3ee54cc1caac76bf09e0f7cb350fc4dd6788a5437217eac33b71fa7a15",
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
// Package codegraph integrates the CodeGraph code-intelligence engine
|
||||
// (https://github.com/colbymchenry/codegraph) as a built-in MCP server. CodeGraph
|
||||
// indexes a project into a local symbol and call graph (tree-sitter + SQLite,
|
||||
// FTS5) and serves it over stdio MCP, giving the agent symbol search, caller /
|
||||
// callee, and change-impact tools without the per-language setup an LSP fleet
|
||||
// would need.
|
||||
//
|
||||
// CodeGraph is fetched on first use into a per-version cache (see Install) rather
|
||||
// than shipped in the reasonix binary, which keeps installs small. Resolve finds
|
||||
// the cached launcher; an explicit config path, a system-installed `codegraph` on
|
||||
// PATH, and a bundle placed beside the executable are also honored. boot injects
|
||||
// the resolved launcher as one more stdio plugin, pinned to the project root via
|
||||
// plugin.Spec.Dir (CodeGraph detects the project from its working directory).
|
||||
package codegraph
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/proc"
|
||||
)
|
||||
|
||||
const initTimeout = 30 * time.Second
|
||||
const daemonProbeTimeout = 300 * time.Millisecond
|
||||
|
||||
// DaemonIdleTimeoutEnv is CodeGraph's own root-scoped daemon idle timeout knob.
|
||||
const DaemonIdleTimeoutEnv = "CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS"
|
||||
|
||||
// ReasonixDaemonIdleTimeoutMS keeps Reasonix-launched daemons short-lived after
|
||||
// the last client disconnects while still preserving CodeGraph's shared-client
|
||||
// refcount semantics.
|
||||
const ReasonixDaemonIdleTimeoutMS = "1000"
|
||||
|
||||
type daemonInfo struct {
|
||||
PID int `json:"pid"`
|
||||
Version string `json:"version"`
|
||||
SocketPath string `json:"socketPath"`
|
||||
StartedAt int64 `json:"startedAt"`
|
||||
}
|
||||
|
||||
// SteerText is injected into the system prompt when CodeGraph tools are
|
||||
// available, so the model knows to prefer them for symbol-level questions. The
|
||||
// tool names are the model-visible registered names (mcp__codegraph__<tool>);
|
||||
// TestSteerTextNamesMatchRegisteredTools fails if they drift from the daemon's
|
||||
// actual tools so the model is never told to call a name it can't use.
|
||||
const SteerText = `## Code Intelligence (codegraph)
|
||||
You have codegraph tools for symbol-level code intelligence. For architecture questions, "how does X work", call graphs, symbol search, and impact analysis, prefer codegraph tools over grep/read_file:
|
||||
- mcp__codegraph__context — entry points + related symbols + key code in one call (USE THIS FIRST for "how does X work")
|
||||
- mcp__codegraph__search — find symbols by name (functions, types, interfaces)
|
||||
- mcp__codegraph__callers / mcp__codegraph__callees — trace call chains
|
||||
- mcp__codegraph__impact — what breaks if I change X
|
||||
- mcp__codegraph__trace — full call path between two symbols
|
||||
- mcp__codegraph__explore — walk the graph around a symbol (neighbours and relationships)
|
||||
- mcp__codegraph__node — full detail for one symbol (definition, location, signature)
|
||||
- mcp__codegraph__files — project file tree with symbol counts
|
||||
- mcp__codegraph__status — code-intelligence index build/health status
|
||||
Use grep/read_file for content search (comments, strings, config values) and when codegraph is not available.`
|
||||
|
||||
// BundleDirName is the optional directory, beside the reasonix executable, where
|
||||
// an operator can place an unpacked CodeGraph bundle for offline use. Its
|
||||
// launcher lives at <BundleDirName>/bin/codegraph, with the bundled node runtime
|
||||
// and lib/ beside it; the launcher resolves those relative to itself, so the
|
||||
// bundle is relocatable.
|
||||
const BundleDirName = "codegraph"
|
||||
|
||||
// Resolve returns the absolute path to the CodeGraph launcher. Search order:
|
||||
// 1. override — an explicit [codegraph].path from config (~ and ${VAR} expanded);
|
||||
// 2. the per-version cache populated by Install;
|
||||
// 3. a system-installed `codegraph` on PATH;
|
||||
// 4. a bundle placed beside the executable (manual/offline fallback).
|
||||
//
|
||||
// ok is false when none resolves — the caller then triggers Install (or skips the
|
||||
// feature), so the codegraph_* tools come online once the cache is populated.
|
||||
func Resolve(override string) (string, bool) {
|
||||
if override != "" {
|
||||
if p := expand(override); isExec(p) {
|
||||
return p, true
|
||||
}
|
||||
}
|
||||
if p, ok := cached(); ok {
|
||||
return p, true
|
||||
}
|
||||
if p, err := exec.LookPath("codegraph"); err == nil {
|
||||
return p, true
|
||||
}
|
||||
if p, ok := bundled(); ok {
|
||||
return p, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// bundled looks for the CodeGraph launcher unpacked beside the reasonix binary.
|
||||
// The executable path is symlink-resolved first so a launcher installed via a
|
||||
// symlink (e.g. a package manager's bin shim) still points at the real bundle.
|
||||
func bundled() (string, bool) {
|
||||
base, ok := bundledBaseDir()
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
for _, rel := range launcherNames() {
|
||||
if p := filepath.Join(base, rel); isExec(p) {
|
||||
return p, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func bundledBaseDir() (string, bool) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
if real, err := filepath.EvalSymlinks(exe); err == nil {
|
||||
exe = real
|
||||
}
|
||||
return filepath.Join(filepath.Dir(exe), BundleDirName), true
|
||||
}
|
||||
|
||||
// launcherNames are the bundle-relative launcher paths to try, per OS. The unix
|
||||
// bundle ships a POSIX-sh launcher at bin/codegraph; the Windows zip ships a
|
||||
// batch / exe shim, so try the common names there.
|
||||
func launcherNames() []string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return []string{
|
||||
filepath.Join("bin", "codegraph.cmd"),
|
||||
filepath.Join("bin", "codegraph.exe"),
|
||||
filepath.Join("bin", "codegraph.bat"),
|
||||
"codegraph.cmd",
|
||||
"codegraph.exe",
|
||||
}
|
||||
}
|
||||
return []string{filepath.Join("bin", "codegraph")}
|
||||
}
|
||||
|
||||
// EnsureInit initialises CodeGraph for root when it has not been already, by
|
||||
// running a bare `codegraph init` (no -i). That only creates the .codegraph/
|
||||
// structure — fast and independent of repo size (~100ms) — because the actual
|
||||
// indexing is done by `serve --mcp`'s daemon in the background once connected: the
|
||||
// MCP handshake returns in a few hundred ms and symbols fill in shortly after,
|
||||
// with CodeGraph flagging partial results as stale meanwhile. So startup never
|
||||
// blocks on indexing, even for a huge monorepo.
|
||||
//
|
||||
// An existing .codegraph/ is left untouched — serve re-syncs it on connect and the
|
||||
// file-watcher keeps it fresh thereafter. The init step is required because serve
|
||||
// does NOT auto-create .codegraph/: without it, it runs in a degraded, no-index
|
||||
// mode rather than building one.
|
||||
func EnsureInit(ctx context.Context, bin, root string) error {
|
||||
if root == "" {
|
||||
return nil
|
||||
}
|
||||
if Initialized(root) {
|
||||
return nil // already initialised — serve re-syncs and the watcher keeps it fresh
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, initTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, bin, "init", root)
|
||||
proc.SetProcessGroupKill(cmd) // own group so Cancel→KillTree reaps the tree off Windows (no-op on Windows)
|
||||
cmd.Cancel = func() error { proc.KillTree(cmd); return nil }
|
||||
cmd.WaitDelay = 3 * time.Second
|
||||
proc.HideWindow(cmd)
|
||||
cmd.Dir = root
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("codegraph init: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Initialized reports whether root already has CodeGraph's project state. Boot
|
||||
// uses this to keep warm projects eager while moving first-time project setup to
|
||||
// background startup, avoiding a cold MCP handshake on the app's critical path.
|
||||
func Initialized(root string) bool {
|
||||
if root == "" {
|
||||
return false
|
||||
}
|
||||
fi, err := os.Stat(filepath.Join(root, ".codegraph"))
|
||||
return err == nil && fi.IsDir()
|
||||
}
|
||||
|
||||
// IndexableRoot reports whether root is a real project directory CodeGraph can
|
||||
// safely be pinned to. A filesystem root (a Windows drive root like C:\, a UNC
|
||||
// share root, or the unix /) is rejected: serve --mcp walks its working
|
||||
// directory, so a root cwd makes it index the whole volume — C:\Windows,
|
||||
// C:\Program Files, everything — pinning gigabytes of RAM (#3747). An empty
|
||||
// root is rejected too: there is nothing to pin a cwd-aware server to.
|
||||
func IndexableRoot(root string) bool {
|
||||
root = strings.TrimSpace(root)
|
||||
if root == "" {
|
||||
return false
|
||||
}
|
||||
abs, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return filepath.Dir(abs) != abs // a filesystem root is its own parent
|
||||
}
|
||||
|
||||
func expand(p string) string {
|
||||
p = os.ExpandEnv(p)
|
||||
if strings.HasPrefix(p, "~/") {
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
p = filepath.Join(home, p[2:])
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// isExec reports whether p is an existing regular file that looks runnable. On
|
||||
// Unix it must carry an execute bit; on Windows existence is enough, since there
|
||||
// runnability is decided by extension, not a mode bit.
|
||||
func isExec(p string) bool {
|
||||
fi, err := os.Stat(p)
|
||||
if err != nil || fi.IsDir() {
|
||||
return false
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
return true
|
||||
}
|
||||
return fi.Mode()&0o111 != 0
|
||||
}
|
||||
|
||||
// DaemonPID returns the PID recorded in CodeGraph's structured daemon lockfile.
|
||||
// It returns 0, false when the lockfile doesn't exist or does not contain the
|
||||
// full pid/version/socket record written by CodeGraph's daemon.
|
||||
func DaemonPID(root string) (int, bool) {
|
||||
info, ok := readDaemonInfo(root)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return info.PID, true
|
||||
}
|
||||
|
||||
func readDaemonInfo(root string) (daemonInfo, bool) {
|
||||
pidPath := filepath.Join(root, ".codegraph", "daemon.pid")
|
||||
raw, err := os.ReadFile(pidPath)
|
||||
if err != nil {
|
||||
return daemonInfo{}, false
|
||||
}
|
||||
var info daemonInfo
|
||||
if err := json.Unmarshal(raw, &info); err != nil {
|
||||
return daemonInfo{}, false
|
||||
}
|
||||
if info.PID <= 0 || info.Version == "" || info.SocketPath == "" || info.StartedAt <= 0 {
|
||||
return daemonInfo{}, false
|
||||
}
|
||||
return info, true
|
||||
}
|
||||
|
||||
// KillDaemon terminates the CodeGraph daemon for root after verifying that the
|
||||
// recorded PID still answers on the daemon socket as the same CodeGraph process.
|
||||
// This is intentionally for explicit cleanup paths such as tests/diagnostics:
|
||||
// CodeGraph daemons are root-scoped and may be shared by other MCP clients, so a
|
||||
// normal Reasonix controller close must only close its proxy connection and let
|
||||
// CodeGraph's own refcount/idle timer own daemon lifetime.
|
||||
func KillDaemon(root string) {
|
||||
info, ok := readDaemonInfo(root)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !daemonIdentityMatches(info) {
|
||||
return
|
||||
}
|
||||
p, err := os.FindProcess(info.PID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = p.Kill()
|
||||
}
|
||||
|
||||
func daemonIdentityMatches(info daemonInfo) bool {
|
||||
if runtime.GOOS == "windows" {
|
||||
return false
|
||||
}
|
||||
conn, err := net.DialTimeout("unix", info.SocketPath, daemonProbeTimeout)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(daemonProbeTimeout))
|
||||
|
||||
line, err := bufio.NewReader(conn).ReadString('\n')
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
var hello struct {
|
||||
Codegraph string `json:"codegraph"`
|
||||
PID int `json:"pid"`
|
||||
SocketPath string `json:"socketPath"`
|
||||
Protocol int `json:"protocol"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(line)), &hello); err != nil {
|
||||
return false
|
||||
}
|
||||
return hello.Codegraph != "" &&
|
||||
hello.PID == info.PID &&
|
||||
hello.SocketPath == info.SocketPath &&
|
||||
hello.Protocol == 1
|
||||
}
|
||||
@@ -1,316 +0,0 @@
|
||||
package codegraph
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// writeExec writes an executable file at path with the given content and +x.
|
||||
func writeExec(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOverride(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("override path test uses a unix +x bit")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
bin := filepath.Join(dir, "codegraph")
|
||||
writeExec(t, bin, "#!/bin/sh\nexit 0\n")
|
||||
|
||||
got, ok := Resolve(bin)
|
||||
if !ok || got != bin {
|
||||
t.Fatalf("Resolve(%q) = %q, %v; want %q, true", bin, got, ok, bin)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOverrideMissingFallsThrough(t *testing.T) {
|
||||
// A non-existent override must not resolve to itself; with no bundle/PATH
|
||||
// match either, ok is false (a real codegraph on PATH could make this true,
|
||||
// so only assert the override itself is not returned).
|
||||
missing := filepath.Join(t.TempDir(), "nope")
|
||||
if got, _ := Resolve(missing); got == missing {
|
||||
t.Fatalf("Resolve returned the missing override path %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandTilde(t *testing.T) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
t.Skip("no home dir")
|
||||
}
|
||||
if got := expand("~/foo/bar"); got != filepath.Join(home, "foo", "bar") {
|
||||
t.Fatalf("expand(~/foo/bar) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureInitSkipsWhenPresent(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(root, ".codegraph"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// bin points at nothing runnable; EnsureInit must short-circuit before exec.
|
||||
if err := EnsureInit(context.Background(), filepath.Join(root, "no-such-bin"), root); err != nil {
|
||||
t.Fatalf("EnsureInit with existing .codegraph should be a no-op, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureInitRunsWhenAbsent(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("fake launcher is a POSIX-sh script")
|
||||
}
|
||||
root := t.TempDir()
|
||||
// A fake codegraph that creates .codegraph in its working directory — EnsureInit
|
||||
// runs it with cmd.Dir = root, so this is independent of the exact arguments.
|
||||
bin := filepath.Join(t.TempDir(), "fakecg")
|
||||
writeExec(t, bin, "#!/bin/sh\nmkdir -p .codegraph\n")
|
||||
|
||||
if err := EnsureInit(context.Background(), bin, root); err != nil {
|
||||
t.Fatalf("EnsureInit = %v", err)
|
||||
}
|
||||
if fi, err := os.Stat(filepath.Join(root, ".codegraph")); err != nil || !fi.IsDir() {
|
||||
t.Fatalf(".codegraph not created: err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureInitPropagatesFailure(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("fake launcher is a POSIX-sh script")
|
||||
}
|
||||
root := t.TempDir()
|
||||
bin := filepath.Join(t.TempDir(), "failcg")
|
||||
writeExec(t, bin, "#!/bin/sh\necho boom 1>&2\nexit 3\n")
|
||||
|
||||
if err := EnsureInit(context.Background(), bin, root); err == nil {
|
||||
t.Fatal("EnsureInit should return the init failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonPIDNoLock(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
pid, ok := DaemonPID(root)
|
||||
if ok || pid != 0 {
|
||||
t.Fatalf("DaemonPID with no lock = %d, %v; want 0, false", pid, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonPIDEmptyLock(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(root, ".codegraph"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, ".codegraph", "daemon.pid"), []byte(""), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pid, ok := DaemonPID(root)
|
||||
if ok || pid != 0 {
|
||||
t.Fatalf("DaemonPID with empty lock = %d, %v; want 0, false", pid, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonPIDRejectsLegacyPlainPID(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(root, ".codegraph"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, ".codegraph", "daemon.pid"), []byte("12345\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pid, ok := DaemonPID(root)
|
||||
if ok || pid != 0 {
|
||||
t.Fatalf("DaemonPID with legacy plain PID = %d, %v; want 0, false", pid, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonPIDStructuredLock(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeDaemonInfo(t, root, daemonInfo{
|
||||
PID: 12345,
|
||||
Version: "0.9.7",
|
||||
SocketPath: filepath.Join(root, ".codegraph", "daemon.sock"),
|
||||
StartedAt: 123456789,
|
||||
})
|
||||
pid, ok := DaemonPID(root)
|
||||
if !ok || pid != 12345 {
|
||||
t.Fatalf("DaemonPID = %d, %v; want 12345, true", pid, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonPIDRejectsPartialLock(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(root, ".codegraph"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, ".codegraph", "daemon.pid"), []byte(`{"pid":42}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pid, ok := DaemonPID(root)
|
||||
if ok || pid != 0 {
|
||||
t.Fatalf("DaemonPID with partial lock = %d, %v; want 0, false", pid, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKillDaemonNoLock(t *testing.T) {
|
||||
// Must not panic or error when there is no .codegraph directory at all.
|
||||
root := t.TempDir()
|
||||
KillDaemon(root) // no-op
|
||||
}
|
||||
|
||||
func TestKillDaemonRequiresMatchingSocketHello(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("unix process/signal semantics only")
|
||||
}
|
||||
if _, err := exec.LookPath("sleep"); err != nil {
|
||||
t.Skip("sleep command not available")
|
||||
}
|
||||
cmd := exec.Command("sleep", "30")
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Wait() }()
|
||||
t.Cleanup(func() {
|
||||
if processExists(cmd.Process.Pid) {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
})
|
||||
|
||||
root := t.TempDir()
|
||||
writeDaemonInfo(t, root, daemonInfo{
|
||||
PID: cmd.Process.Pid,
|
||||
Version: "0.9.7",
|
||||
SocketPath: filepath.Join(root, ".codegraph", "missing.sock"),
|
||||
StartedAt: 123456789,
|
||||
})
|
||||
|
||||
KillDaemon(root)
|
||||
if !processExists(cmd.Process.Pid) {
|
||||
t.Fatal("KillDaemon killed a process without a matching daemon socket hello")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKillDaemonKillsMatchingSocketDaemon(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("unix socket/process semantics only")
|
||||
}
|
||||
if _, err := exec.LookPath("sleep"); err != nil {
|
||||
t.Skip("sleep command not available")
|
||||
}
|
||||
root := shortTempDir(t)
|
||||
cgDir := filepath.Join(root, ".codegraph")
|
||||
if err := os.Mkdir(cgDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
socketPath := filepath.Join(cgDir, "daemon.sock")
|
||||
ln, err := net.Listen("unix", socketPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
cmd := exec.Command("sleep", "30")
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Wait() }()
|
||||
t.Cleanup(func() {
|
||||
if processExists(cmd.Process.Pid) {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
})
|
||||
writeDaemonInfo(t, root, daemonInfo{
|
||||
PID: cmd.Process.Pid,
|
||||
Version: "0.9.7",
|
||||
SocketPath: socketPath,
|
||||
StartedAt: 123456789,
|
||||
})
|
||||
|
||||
accepted := make(chan struct{})
|
||||
go func() {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
fmt.Fprintf(conn, `{"codegraph":"0.9.7","pid":%d,"socketPath":%q,"protocol":1}`+"\n", cmd.Process.Pid, socketPath)
|
||||
close(accepted)
|
||||
}()
|
||||
|
||||
KillDaemon(root)
|
||||
select {
|
||||
case <-accepted:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("KillDaemon did not probe the daemon socket")
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("KillDaemon did not kill the matching daemon process")
|
||||
}
|
||||
}
|
||||
|
||||
func shortTempDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir, err := os.MkdirTemp("/tmp", "reasonix-codegraph-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
||||
return dir
|
||||
}
|
||||
|
||||
func writeDaemonInfo(t *testing.T, root string, info daemonInfo) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Join(root, ".codegraph"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := json.Marshal(info)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, ".codegraph", "daemon.pid"), append(b, '\n'), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexableRootRejectsFilesystemRoots(t *testing.T) {
|
||||
if got := IndexableRoot(t.TempDir()); !got {
|
||||
t.Fatal("a real project dir must be indexable")
|
||||
}
|
||||
for _, root := range []string{"", " "} {
|
||||
if IndexableRoot(root) {
|
||||
t.Fatalf("IndexableRoot(%q) = true; want false", root)
|
||||
}
|
||||
}
|
||||
var roots []string
|
||||
if runtime.GOOS == "windows" {
|
||||
roots = []string{`C:\`, `c:\`, `\\server\share`}
|
||||
} else {
|
||||
roots = []string{"/"}
|
||||
}
|
||||
for _, root := range roots {
|
||||
if IndexableRoot(root) {
|
||||
t.Fatalf("IndexableRoot(%q) = true; want false (filesystem root)", root)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
package codegraph
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/plugin"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
// TestE2ECodegraphMCP drives the whole integration against a real CodeGraph
|
||||
// bundle: index a fixture project, connect via the MCP client pinned to that
|
||||
// project (Spec.Dir), and actually call codegraph_search. It is gated on
|
||||
// REASONIX_CODEGRAPH_E2E so the normal `go test ./...` skips it (no network, no
|
||||
// external binary), yet it still compiles every build so it can't bit-rot.
|
||||
//
|
||||
// Run it with `make e2e-codegraph` (fetches the matching bundle), or manually:
|
||||
//
|
||||
// REASONIX_CODEGRAPH_E2E=1 REASONIX_CODEGRAPH_BIN=/path/to/codegraph \
|
||||
// go test ./internal/codegraph/ -run E2E -v -count=1
|
||||
//
|
||||
// With REASONIX_CODEGRAPH_BIN unset it falls back to Resolve("") (bundle / PATH).
|
||||
func TestE2ECodegraphMCP(t *testing.T) {
|
||||
if os.Getenv("REASONIX_CODEGRAPH_E2E") == "" {
|
||||
t.Skip("set REASONIX_CODEGRAPH_E2E=1 to run the CodeGraph MCP end-to-end test")
|
||||
}
|
||||
bin := os.Getenv("REASONIX_CODEGRAPH_BIN")
|
||||
if bin == "" {
|
||||
var ok bool
|
||||
if bin, ok = Resolve(""); !ok {
|
||||
t.Fatal("REASONIX_CODEGRAPH_E2E is set but no codegraph binary found — set REASONIX_CODEGRAPH_BIN to the launcher path")
|
||||
}
|
||||
}
|
||||
t.Logf("codegraph binary: %s", bin)
|
||||
|
||||
// A fixture project carrying a known symbol the search must surface.
|
||||
root := t.TempDir()
|
||||
src := "package demo\n\n// Greet builds a greeting.\nfunc Greet(name string) string { return \"hi \" + name }\n"
|
||||
if err := os.WriteFile(filepath.Join(root, "greet.go"), []byte(src), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 1) Initialise .codegraph/ (fast, no indexing — serve's daemon does that).
|
||||
if err := EnsureInit(ctx, bin, root); err != nil {
|
||||
t.Fatalf("EnsureInit: %v", err)
|
||||
}
|
||||
if fi, err := os.Stat(filepath.Join(root, ".codegraph")); err != nil || !fi.IsDir() {
|
||||
t.Fatalf(".codegraph was not created by EnsureInit: %v", err)
|
||||
}
|
||||
|
||||
// 2) Connect through the real MCP client, pinned to the project root via Dir —
|
||||
// the same wiring boot uses for the built-in server.
|
||||
host, tools, err := plugin.StartAll(ctx, []plugin.Spec{{
|
||||
Name: "codegraph",
|
||||
Command: bin,
|
||||
Args: []string{"serve", "--mcp"},
|
||||
Dir: root,
|
||||
ReadOnlyToolNames: ReadOnlyToolNames(),
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("StartAll: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
host.Close()
|
||||
// Reap the daemon that escapes process-group kill via setsid.
|
||||
KillDaemon(root)
|
||||
}()
|
||||
if len(tools) == 0 {
|
||||
t.Fatal("codegraph exposed no MCP tools")
|
||||
}
|
||||
|
||||
// 3) Locate the search tool and actually invoke it through Tool.Execute.
|
||||
var search tool.Tool
|
||||
names := make([]string, 0, len(tools))
|
||||
for _, tl := range tools {
|
||||
names = append(names, tl.Name())
|
||||
if strings.Contains(tl.Name(), "codegraph_search") {
|
||||
search = tl
|
||||
}
|
||||
}
|
||||
if search == nil {
|
||||
t.Fatalf("no codegraph_search tool among %v", names)
|
||||
}
|
||||
if !search.ReadOnly() {
|
||||
t.Fatalf("codegraph_search should be read-only under the built-in CodeGraph override; tools=%v", names)
|
||||
}
|
||||
|
||||
// serve indexes in the background, so poll the search for a few seconds until
|
||||
// the known symbol surfaces (rather than assuming it is ready at handshake).
|
||||
var out string
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
for {
|
||||
var err error
|
||||
if out, err = search.Execute(ctx, json.RawMessage(`{"query":"Greet"}`)); err != nil {
|
||||
t.Fatalf("codegraph_search Execute: %v\nschema: %s", err, search.Schema())
|
||||
}
|
||||
if strings.Contains(out, "Greet") {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("codegraph_search never surfaced the known symbol Greet within 15s:\n%s", out)
|
||||
}
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
}
|
||||
t.Logf("e2e ok — %d tools (%v); search surfaced Greet", len(tools), names)
|
||||
}
|
||||
|
||||
// TestE2ECodegraphKillDaemon verifies that KillDaemon can reap a real CodeGraph
|
||||
// daemon that escapes the process-group kill in KillTree. The daemon does
|
||||
// setsid(2) to detach into its own process group, so the negative-PID SIGKILL
|
||||
// that KillTree sends to the launcher's group misses it. KillDaemon verifies the
|
||||
// daemon lockfile against the socket hello before sending a direct SIGKILL.
|
||||
//
|
||||
// Gated on REASONIX_CODEGRAPH_E2E=1 like the other E2E test; skipped on Windows
|
||||
// because the Job Object (JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE) already captures
|
||||
// every descendant regardless of reparenting.
|
||||
func TestE2ECodegraphKillDaemon(t *testing.T) {
|
||||
if os.Getenv("REASONIX_CODEGRAPH_E2E") == "" {
|
||||
t.Skip("set REASONIX_CODEGRAPH_E2E=1 to run the CodeGraph E2E daemon-kill test")
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("daemon escape is Unix-only; Windows Job Objects capture all descendants")
|
||||
}
|
||||
|
||||
bin := os.Getenv("REASONIX_CODEGRAPH_BIN")
|
||||
if bin == "" {
|
||||
var ok bool
|
||||
if bin, ok = Resolve(""); !ok {
|
||||
t.Fatal("REASONIX_CODEGRAPH_E2E is set but no codegraph binary found — set REASONIX_CODEGRAPH_BIN to the launcher path")
|
||||
}
|
||||
}
|
||||
t.Logf("codegraph binary: %s", bin)
|
||||
|
||||
root := t.TempDir()
|
||||
src := "package demo\n\nfunc Greet(name string) string { return \"hi \" + name }\n"
|
||||
if err := os.WriteFile(filepath.Join(root, "greet.go"), []byte(src), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := EnsureInit(ctx, bin, root); err != nil {
|
||||
t.Fatalf("EnsureInit: %v", err)
|
||||
}
|
||||
|
||||
// Start the codegraph server — this launches the MCP bridge (foreground) and
|
||||
// a daemon (background). The daemon records its PID in its lockfile on startup.
|
||||
host, _, err := plugin.StartAll(ctx, []plugin.Spec{{
|
||||
Name: "codegraph",
|
||||
Command: bin,
|
||||
Args: []string{"serve", "--mcp"},
|
||||
Dir: root,
|
||||
ReadOnlyToolNames: ReadOnlyToolNames(),
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("StartAll: %v", err)
|
||||
}
|
||||
|
||||
// Give the daemon a moment to start and write its PID lockfile.
|
||||
var daemonPID int
|
||||
var daemonOK bool
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if daemonPID, daemonOK = DaemonPID(root); daemonOK {
|
||||
break
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
if !daemonOK {
|
||||
host.Close()
|
||||
t.Fatal("daemon PID never appeared in .codegraph/daemon.pid")
|
||||
}
|
||||
t.Logf("daemon PID: %d", daemonPID)
|
||||
|
||||
// Verify the daemon is alive before we close.
|
||||
if !processExists(daemonPID) {
|
||||
host.Close()
|
||||
t.Fatalf("daemon PID %d not alive before Close", daemonPID)
|
||||
}
|
||||
t.Logf("daemon %d alive before host.Close()", daemonPID)
|
||||
|
||||
// host.Close kills the launcher via KillTree (process-group SIGKILL), but the
|
||||
// daemon has detached into its own process group via setsid and escapes.
|
||||
host.Close()
|
||||
|
||||
// Give the kernel a moment to deliver the SIGKILL and reap the launcher.
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
if processExists(daemonPID) {
|
||||
t.Logf("daemon %d survived process-group kill (as expected — it escaped via setsid)", daemonPID)
|
||||
|
||||
// Now explicitly kill the daemon via its logged PID.
|
||||
KillDaemon(root)
|
||||
|
||||
// Poll for exit with a short deadline.
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for processExists(daemonPID) {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("daemon %d still alive after KillDaemon", daemonPID)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
t.Logf("daemon %d killed by KillDaemon", daemonPID)
|
||||
} else {
|
||||
// The daemon didn't escape — process-group kill worked. This can happen
|
||||
// if codegraph is built without daemonization or if the OS doesn't
|
||||
// support setsid semantics. KillDaemon is still a safe no-op here.
|
||||
t.Logf("daemon %d was already dead after host.Close() (process-group kill succeeded)", daemonPID)
|
||||
KillDaemon(root) // no-op — best-effort is safe
|
||||
}
|
||||
}
|
||||
|
||||
// processExists reports whether a process with the given PID is currently
|
||||
// running. It sends signal 0 (null signal) on Unix; on Windows it always
|
||||
// returns false (but the daemon-kill test skips Windows anyway).
|
||||
func processExists(pid int) bool {
|
||||
p, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return p.Signal(syscall.Signal(0)) == nil
|
||||
}
|
||||
@@ -1,639 +0,0 @@
|
||||
package codegraph
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// Version is the pinned CodeGraph release fetched on first use. Keep in sync
|
||||
// with CODEGRAPH_VERSION in the Makefile and .github/workflows.
|
||||
Version = "v0.9.7"
|
||||
cgRepo = "colbymchenry/codegraph"
|
||||
|
||||
officialMirrorBase = "https://dl.reasonix.io/codegraph"
|
||||
officialMainlandMirrorBase = ""
|
||||
perSourceDownloadTimeout = 10 * time.Minute
|
||||
activeVersionFile = "active-version"
|
||||
|
||||
renameAttempts = 5
|
||||
renameBackoff = 200 * time.Millisecond
|
||||
)
|
||||
|
||||
var (
|
||||
githubAPIBase = "https://api.github.com"
|
||||
githubReleaseDownloadBase = func(version string) string {
|
||||
return fmt.Sprintf("https://github.com/%s/releases/download/%s", cgRepo, version)
|
||||
}
|
||||
)
|
||||
|
||||
type UpdateResult struct {
|
||||
Version string
|
||||
Path string
|
||||
}
|
||||
|
||||
// CacheDir is where the CodeGraph bundle is unpacked on first use:
|
||||
// <user cache>/reasonix/codegraph/<Version>. Versioned so a bump installs cleanly
|
||||
// beside the old one. REASONIX_CACHE_DIR overrides the base (relocate the cache,
|
||||
// or isolate it in tests). Empty when no cache/config dir resolves.
|
||||
func CacheDir() string {
|
||||
return CacheDirForVersion(Version)
|
||||
}
|
||||
|
||||
func CacheDirForVersion(version string) string {
|
||||
base := cacheRoot()
|
||||
if base == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(base, "codegraph", version)
|
||||
}
|
||||
|
||||
func cacheRoot() string {
|
||||
base := os.Getenv("REASONIX_CACHE_DIR")
|
||||
if base == "" {
|
||||
var err error
|
||||
if base, err = os.UserCacheDir(); err != nil {
|
||||
if base, err = os.UserConfigDir(); err != nil {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
base = filepath.Join(base, "reasonix")
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func activeVersion() string {
|
||||
base := cacheRoot()
|
||||
if base == "" {
|
||||
return ""
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(base, "codegraph", activeVersionFile))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
version := strings.TrimSpace(string(data))
|
||||
if !validVersion(version) {
|
||||
return ""
|
||||
}
|
||||
return version
|
||||
}
|
||||
|
||||
func ActiveVersion() string {
|
||||
return activeVersion()
|
||||
}
|
||||
|
||||
func NewerThanActive(version string) bool {
|
||||
if !validVersion(version) {
|
||||
return false
|
||||
}
|
||||
current := activeVersion()
|
||||
if current == "" {
|
||||
current = Version
|
||||
}
|
||||
return compareVersion(version, current) > 0
|
||||
}
|
||||
|
||||
func writeActiveVersion(version string) error {
|
||||
if !validVersion(version) {
|
||||
return fmt.Errorf("codegraph: invalid version %q", version)
|
||||
}
|
||||
base := cacheRoot()
|
||||
if base == "" {
|
||||
return fmt.Errorf("codegraph: no cache directory available")
|
||||
}
|
||||
dir := filepath.Join(base, "codegraph")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(dir, activeVersionFile), []byte(version+"\n"), 0o644)
|
||||
}
|
||||
|
||||
func validVersion(version string) bool {
|
||||
version = strings.TrimSpace(version)
|
||||
if version == "" || strings.ContainsAny(version, `/\`) || version == "." || version == ".." {
|
||||
return false
|
||||
}
|
||||
for _, r := range version {
|
||||
if !(r == '.' || r == '-' || r == '_' || r == '+' || r >= '0' && r <= '9' || r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func compareVersion(a, b string) int {
|
||||
aa, bb := versionParts(a), versionParts(b)
|
||||
n := len(aa)
|
||||
if len(bb) > n {
|
||||
n = len(bb)
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
av, bv := 0, 0
|
||||
if i < len(aa) {
|
||||
av = aa[i]
|
||||
}
|
||||
if i < len(bb) {
|
||||
bv = bb[i]
|
||||
}
|
||||
if av > bv {
|
||||
return 1
|
||||
}
|
||||
if av < bv {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func versionParts(version string) []int {
|
||||
version = strings.TrimPrefix(strings.TrimSpace(version), "v")
|
||||
fields := strings.FieldsFunc(version, func(r rune) bool {
|
||||
return r < '0' || r > '9'
|
||||
})
|
||||
out := make([]int, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
var n int
|
||||
for _, r := range f {
|
||||
n = n*10 + int(r-'0')
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// cached returns the launcher path inside CacheDir when the bundle is present.
|
||||
func cached() (string, bool) {
|
||||
if active := activeVersion(); active != "" && compareVersion(active, Version) >= 0 {
|
||||
if p, ok := cachedForVersion(active); ok {
|
||||
return p, true
|
||||
}
|
||||
}
|
||||
return cachedForVersion(Version)
|
||||
}
|
||||
|
||||
func cachedForVersion(version string) (string, bool) {
|
||||
dir := CacheDirForVersion(version)
|
||||
if dir == "" {
|
||||
return "", false
|
||||
}
|
||||
for _, rel := range launcherNames() {
|
||||
if p := filepath.Join(dir, rel); isExec(p) {
|
||||
return p, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// assetName is CodeGraph's release asset for the running platform (it names them
|
||||
// codegraph-<darwin|linux|win32>-<x64|arm64>.<tar.gz|zip>).
|
||||
func assetName() string {
|
||||
osName := map[string]string{"darwin": "darwin", "linux": "linux", "windows": "win32"}[runtime.GOOS]
|
||||
if osName == "" {
|
||||
osName = runtime.GOOS
|
||||
}
|
||||
arch := map[string]string{"amd64": "x64", "arm64": "arm64"}[runtime.GOARCH]
|
||||
if arch == "" {
|
||||
arch = runtime.GOARCH
|
||||
}
|
||||
ext := "tar.gz"
|
||||
if runtime.GOOS == "windows" {
|
||||
ext = "zip"
|
||||
}
|
||||
return fmt.Sprintf("codegraph-%s-%s.%s", osName, arch, ext)
|
||||
}
|
||||
|
||||
// Install downloads and unpacks the CodeGraph bundle into CacheDir on first use,
|
||||
// verifying it against the checksum baked into the reasonix binary, then returns
|
||||
// the launcher path.
|
||||
// It is idempotent: a present cache is returned untouched. log, if non-nil,
|
||||
// receives a couple of progress lines. The extraction is staged in a temp dir and
|
||||
// atomically renamed into place, so a cancelled or failed run leaves no partial
|
||||
// install behind.
|
||||
func Install(ctx context.Context, log func(string)) (string, error) {
|
||||
return InstallWithClient(ctx, http.DefaultClient, log)
|
||||
}
|
||||
|
||||
// InstallWithClient is Install with an explicit HTTP client, used when Reasonix
|
||||
// network proxy settings should apply.
|
||||
func InstallWithClient(ctx context.Context, client *http.Client, log func(string)) (string, error) {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
if p, ok := cachedForVersion(Version); ok {
|
||||
return p, nil
|
||||
}
|
||||
asset := assetName()
|
||||
logf(log, "codegraph: downloading %s (%s, one-time)…", asset, Version)
|
||||
want := expectedAssetSHA256(asset)
|
||||
if want == "" {
|
||||
return "", fmt.Errorf("codegraph: no embedded checksum for %s (%s)", asset, Version)
|
||||
}
|
||||
return installVersionWithClient(ctx, client, Version, want, downloadBases(), false, log)
|
||||
}
|
||||
|
||||
func UpdateWithClient(ctx context.Context, client *http.Client, log func(string)) (UpdateResult, error) {
|
||||
res, err := DownloadLatestWithClient(ctx, client, log)
|
||||
if err != nil {
|
||||
return UpdateResult{}, err
|
||||
}
|
||||
if err := writeActiveVersion(res.Version); err != nil {
|
||||
return UpdateResult{}, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func DownloadLatestWithClient(ctx context.Context, client *http.Client, log func(string)) (UpdateResult, error) {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
version, err := LatestVersionWithClient(ctx, client)
|
||||
if err != nil {
|
||||
return UpdateResult{}, err
|
||||
}
|
||||
if p, ok := cachedForVersion(version); ok {
|
||||
return UpdateResult{Version: version, Path: p}, nil
|
||||
}
|
||||
asset := assetName()
|
||||
sums, err := downloadReleaseFile(ctx, client, version, "SHA256SUMS")
|
||||
if err != nil {
|
||||
return UpdateResult{}, err
|
||||
}
|
||||
want, err := sha256For(string(sums), asset)
|
||||
if err != nil {
|
||||
return UpdateResult{}, err
|
||||
}
|
||||
logf(log, "codegraph: downloading %s (%s)…", asset, version)
|
||||
path, err := installVersionWithClient(ctx, client, version, want, []string{githubReleaseDownloadBase(version)}, true, log)
|
||||
if err != nil {
|
||||
return UpdateResult{}, err
|
||||
}
|
||||
return UpdateResult{Version: version, Path: path}, nil
|
||||
}
|
||||
|
||||
func LatestVersionWithClient(ctx context.Context, client *http.Client) (string, error) {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
url := strings.TrimRight(githubAPIBase, "/") + "/repos/" + cgRepo + "/releases/latest"
|
||||
data, err := httpGet(ctx, client, url)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var release struct {
|
||||
TagName string `json:"tag_name"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &release); err != nil {
|
||||
return "", fmt.Errorf("codegraph: parse latest release: %w", err)
|
||||
}
|
||||
version := strings.TrimSpace(release.TagName)
|
||||
if !validVersion(version) {
|
||||
return "", fmt.Errorf("codegraph: invalid latest version %q", version)
|
||||
}
|
||||
return version, nil
|
||||
}
|
||||
|
||||
func installVersionWithClient(ctx context.Context, client *http.Client, version, want string, bases []string, force bool, log func(string)) (string, error) {
|
||||
dir := CacheDirForVersion(version)
|
||||
if dir == "" {
|
||||
return "", fmt.Errorf("codegraph: no cache directory available")
|
||||
}
|
||||
if !force {
|
||||
if p, ok := cachedForVersion(version); ok {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
asset := assetName()
|
||||
data, err := downloadAssetFromBases(ctx, client, asset, want, bases, log)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
parent := filepath.Dir(dir)
|
||||
if err := os.MkdirAll(parent, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
tmp, err := os.MkdirTemp(parent, ".dl-")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer os.RemoveAll(tmp)
|
||||
|
||||
if strings.HasSuffix(asset, ".zip") {
|
||||
err = extractZip(data, tmp)
|
||||
} else {
|
||||
err = extractTarGz(data, tmp)
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("codegraph: extract: %w", err)
|
||||
}
|
||||
// The archive holds a single top-level dir (codegraph-<target>/). Promote it
|
||||
// to the versioned cache dir so the launcher lands at <dir>/bin/codegraph.
|
||||
root, err := singleChild(tmp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !force {
|
||||
if p, ok := cachedForVersion(version); ok {
|
||||
return p, nil // a concurrent session already populated dir
|
||||
}
|
||||
}
|
||||
if err := promote(root, dir); err != nil {
|
||||
if p, ok := cachedForVersion(version); ok {
|
||||
return p, nil // a concurrent winner landed during our retries
|
||||
}
|
||||
return "", fmt.Errorf("codegraph: install to %s failed: %w — the cache directory may be read-only or locked by antivirus; set REASONIX_CACHE_DIR to a writable location to relocate it", dir, err)
|
||||
}
|
||||
p, ok := cachedForVersion(version)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("codegraph: launcher not found after install (unexpected bundle layout)")
|
||||
}
|
||||
logf(log, "codegraph: installed to %s", dir)
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func downloadReleaseFile(ctx context.Context, client *http.Client, version, name string) ([]byte, error) {
|
||||
if !validVersion(version) || strings.ContainsAny(name, `/\`) || strings.TrimSpace(name) == "" {
|
||||
return nil, fmt.Errorf("codegraph: invalid release file %q for %q", name, version)
|
||||
}
|
||||
return httpGet(ctx, client, strings.TrimRight(githubReleaseDownloadBase(version), "/")+"/"+name)
|
||||
}
|
||||
|
||||
func downloadAssetFromBases(ctx context.Context, client *http.Client, asset, want string, bases []string, log func(string)) ([]byte, error) {
|
||||
var errs []string
|
||||
for _, base := range bases {
|
||||
url := strings.TrimRight(base, "/") + "/" + asset
|
||||
getCtx := ctx
|
||||
cancel := func() {}
|
||||
if perSourceDownloadTimeout > 0 {
|
||||
getCtx, cancel = context.WithTimeout(ctx, perSourceDownloadTimeout)
|
||||
}
|
||||
data, err := httpGet(getCtx, client, url)
|
||||
cancel()
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Sprintf("%s: %v", base, err))
|
||||
continue
|
||||
}
|
||||
got := fmt.Sprintf("%x", sha256.Sum256(data))
|
||||
if got != want {
|
||||
errs = append(errs, fmt.Sprintf("%s: checksum mismatch for %s", base, asset))
|
||||
continue
|
||||
}
|
||||
logf(log, "codegraph: downloaded from %s", base)
|
||||
return data, nil
|
||||
}
|
||||
return nil, fmt.Errorf("codegraph: download %s failed (%s)", asset, strings.Join(errs, "; "))
|
||||
}
|
||||
|
||||
func expectedAssetSHA256(asset string) string {
|
||||
return releaseAssetSHA256[asset]
|
||||
}
|
||||
|
||||
func downloadBases() []string {
|
||||
bases := []string{officialMirrorBase + "/" + Version}
|
||||
if strings.TrimSpace(officialMainlandMirrorBase) != "" {
|
||||
bases = append(bases, strings.TrimRight(officialMainlandMirrorBase, "/")+"/"+Version)
|
||||
}
|
||||
bases = append(bases, fmt.Sprintf("https://github.com/%s/releases/download/%s", cgRepo, Version))
|
||||
return dedupeStrings(bases)
|
||||
}
|
||||
|
||||
func dedupeStrings(values []string) []string {
|
||||
var out []string
|
||||
seen := make(map[string]bool, len(values))
|
||||
for _, v := range values {
|
||||
if !seen[v] {
|
||||
seen[v] = true
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// promote moves the freshly extracted bundle (root) into its versioned home
|
||||
// (dir). On Windows os.Rename onto an existing directory fails with "Access is
|
||||
// denied", so a stale/partial dest from an earlier interrupted install (cached()
|
||||
// already reported it incomplete) is cleared first; a just-extracted .exe can
|
||||
// also stay briefly locked by an AV scanner, so the move is retried.
|
||||
func promote(root, dir string) error {
|
||||
_ = os.RemoveAll(dir)
|
||||
var err error
|
||||
for i := 0; i < renameAttempts; i++ {
|
||||
if err = os.Rename(root, dir); err == nil {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(renameBackoff)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func httpGet(ctx context.Context, client *http.Client, url string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("GET %s: %s", url, resp.Status)
|
||||
}
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
// sha256For returns the hex checksum recorded for name in a SHA256SUMS body
|
||||
// (lines of "<hex> <name>").
|
||||
func sha256For(sums, name string) (string, error) {
|
||||
for _, line := range strings.Split(sums, "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) == 2 && f[1] == name {
|
||||
return f[0], nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("codegraph: %s not listed in SHA256SUMS", name)
|
||||
}
|
||||
|
||||
// resolveWithin returns the real path to write archive entry name under root
|
||||
// (parents created), rejecting escapes. EvalSymlinks on the parent also catches
|
||||
// the symlink-redirect variant a lexical "../" check misses: a parent component
|
||||
// an earlier entry turned into a symlink, written *through* to land outside root.
|
||||
func resolveWithin(root, name string) (string, error) {
|
||||
if !filepath.IsLocal(name) {
|
||||
return "", fmt.Errorf("unsafe path %q in archive", name)
|
||||
}
|
||||
target := filepath.Join(root, name)
|
||||
if target != root && !strings.HasPrefix(target, root+string(os.PathSeparator)) {
|
||||
return "", fmt.Errorf("unsafe path %q in archive", name)
|
||||
}
|
||||
if target == root {
|
||||
return root, nil
|
||||
}
|
||||
parent := filepath.Dir(target)
|
||||
if err := os.MkdirAll(parent, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
realParent, err := filepath.EvalSymlinks(parent)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if realParent != root && !strings.HasPrefix(realParent, root+string(os.PathSeparator)) {
|
||||
return "", fmt.Errorf("unsafe path %q in archive: escapes via symlink", name)
|
||||
}
|
||||
return filepath.Join(realParent, filepath.Base(target)), nil
|
||||
}
|
||||
|
||||
func cleanSymlinkTarget(root, linkPath, linkname string) (string, error) {
|
||||
if linkname == "" || filepath.IsAbs(linkname) {
|
||||
return "", fmt.Errorf("unsafe symlink target %q in archive", linkname)
|
||||
}
|
||||
clean := filepath.Clean(linkname)
|
||||
dest, err := filepath.EvalSymlinks(filepath.Join(filepath.Dir(linkPath), clean))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("unsafe symlink target %q in archive: %w", linkname, err)
|
||||
}
|
||||
if dest != root && !strings.HasPrefix(dest, root+string(os.PathSeparator)) {
|
||||
return "", fmt.Errorf("unsafe symlink target %q in archive", linkname)
|
||||
}
|
||||
return filepath.ToSlash(clean), nil
|
||||
}
|
||||
|
||||
// symlinkWithin rejects a symlink whose target escapes root. linkPath is the
|
||||
// symlink's already-resolved real location (from resolveWithin), so a relative
|
||||
// target is judged from where the link truly lands, not its lexical archive path.
|
||||
func symlinkWithin(root, linkPath, linkname string) error {
|
||||
dest := linkname
|
||||
if !filepath.IsAbs(dest) {
|
||||
dest = filepath.Join(filepath.Dir(linkPath), linkname)
|
||||
}
|
||||
dest = filepath.Clean(dest)
|
||||
if dest != root && !strings.HasPrefix(dest, root+string(os.PathSeparator)) {
|
||||
return fmt.Errorf("unsafe symlink %q -> %q in archive", linkPath, linkname)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractTarGz(data []byte, dir string) error {
|
||||
gz, err := gzip.NewReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer gz.Close()
|
||||
root, err := filepath.EvalSymlinks(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target, err := resolveWithin(root, hdr.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch hdr.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
case tar.TypeSymlink:
|
||||
linkname, err := cleanSymlinkTarget(root, target, hdr.Linkname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := symlinkWithin(root, target, linkname); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = os.Remove(target)
|
||||
if err := os.Symlink(linkname, target); err != nil {
|
||||
return err
|
||||
}
|
||||
case tar.TypeReg:
|
||||
if err := writeFileFromReader(target, tr, hdr.FileInfo().Mode()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func extractZip(data []byte, dir string) error {
|
||||
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
root, err := filepath.EvalSymlinks(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, f := range zr.File {
|
||||
target, err := resolveWithin(root, f.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if f.FileInfo().IsDir() {
|
||||
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = writeFileFromReader(target, rc, f.Mode())
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeFileFromReader(target string, r io.Reader, mode os.FileMode) error {
|
||||
f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode.Perm())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(f, r); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
// singleChild returns the sole entry in dir (the archive's top-level
|
||||
// codegraph-<target>/ directory).
|
||||
func singleChild(dir string) (string, error) {
|
||||
ents, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(ents) != 1 || !ents[0].IsDir() {
|
||||
return "", fmt.Errorf("codegraph: expected one top-level dir in archive, got %d entries", len(ents))
|
||||
}
|
||||
return filepath.Join(dir, ents[0].Name()), nil
|
||||
}
|
||||
|
||||
func logf(log func(string), format string, a ...any) {
|
||||
if log != nil {
|
||||
log(fmt.Sprintf(format, a...))
|
||||
}
|
||||
}
|
||||
@@ -1,394 +0,0 @@
|
||||
package codegraph
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAssetNameForCurrentPlatform(t *testing.T) {
|
||||
got := assetName()
|
||||
if !strings.HasPrefix(got, "codegraph-") {
|
||||
t.Fatalf("assetName %q lacks codegraph- prefix", got)
|
||||
}
|
||||
wantExt := ".tar.gz"
|
||||
if runtime.GOOS == "windows" {
|
||||
wantExt = ".zip"
|
||||
}
|
||||
if !strings.HasSuffix(got, wantExt) {
|
||||
t.Fatalf("assetName %q should end in %s on %s", got, wantExt, runtime.GOOS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromoteReplacesStalePartialDest(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
root := filepath.Join(parent, ".dl-x", "codegraph-x64")
|
||||
if err := os.MkdirAll(filepath.Join(root, "bin"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "bin", "codegraph"), []byte("new"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dir := filepath.Join(parent, "v0.9.7")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "stale.txt"), []byte("old"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := promote(root, dir); err != nil {
|
||||
t.Fatalf("promote over a stale dest: %v", err)
|
||||
}
|
||||
if got, err := os.ReadFile(filepath.Join(dir, "bin", "codegraph")); err != nil || string(got) != "new" {
|
||||
t.Fatalf("dest missing promoted bundle: %q %v", got, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "stale.txt")); !os.IsNotExist(err) {
|
||||
t.Fatal("stale dest content survived promote")
|
||||
}
|
||||
if _, err := os.Stat(root); !os.IsNotExist(err) {
|
||||
t.Fatal("root should have been moved into dir")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSha256For(t *testing.T) {
|
||||
sums := "abc123 codegraph-linux-x64.tar.gz\ndef456 codegraph-darwin-arm64.tar.gz\n"
|
||||
got, err := sha256For(sums, "codegraph-darwin-arm64.tar.gz")
|
||||
if err != nil || got != "def456" {
|
||||
t.Fatalf("sha256For = %q, %v; want def456", got, err)
|
||||
}
|
||||
if _, err := sha256For(sums, "codegraph-win32-x64.zip"); err == nil {
|
||||
t.Fatal("expected error for absent asset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWithinRejectsTraversal(t *testing.T) {
|
||||
root, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := resolveWithin(root, "../escape"); err == nil {
|
||||
t.Fatal("resolveWithin should reject ../escape")
|
||||
}
|
||||
if _, err := resolveWithin(root, "bin/codegraph"); err != nil {
|
||||
t.Fatalf("resolveWithin rejected a normal path: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// makeTarGz builds an in-memory .tar.gz from name->(content, mode); a mode with
|
||||
// the exec bit is preserved through extraction.
|
||||
func makeTarGz(t *testing.T, files map[string]struct {
|
||||
body string
|
||||
mode int64
|
||||
}) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(gz)
|
||||
for name, f := range files {
|
||||
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: f.mode, Size: int64(len(f.body)), Typeflag: tar.TypeReg}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tw.Write([]byte(f.body)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
tw.Close()
|
||||
gz.Close()
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestExtractTarGzPreservesExecBit(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("exec-bit semantics are POSIX")
|
||||
}
|
||||
data := makeTarGz(t, map[string]struct {
|
||||
body string
|
||||
mode int64
|
||||
}{
|
||||
"bin/codegraph": {"#!/bin/sh\n", 0o755},
|
||||
"lib/app.js": {"console.log(1)", 0o644},
|
||||
})
|
||||
dir := t.TempDir()
|
||||
if err := extractTarGz(data, dir); err != nil {
|
||||
t.Fatalf("extractTarGz: %v", err)
|
||||
}
|
||||
fi, err := os.Stat(filepath.Join(dir, "bin", "codegraph"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fi.Mode()&0o111 == 0 {
|
||||
t.Fatal("launcher lost its exec bit on extraction")
|
||||
}
|
||||
b, _ := os.ReadFile(filepath.Join(dir, "lib", "app.js"))
|
||||
if string(b) != "console.log(1)" {
|
||||
t.Fatalf("lib/app.js content = %q", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractTarGzRejectsTraversal(t *testing.T) {
|
||||
data := makeTarGz(t, map[string]struct {
|
||||
body string
|
||||
mode int64
|
||||
}{"../evil": {"x", 0o644}})
|
||||
if err := extractTarGz(data, t.TempDir()); err == nil {
|
||||
t.Fatal("extractTarGz should reject a ../ entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallReturnsCachedWithoutNetwork(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses a POSIX +x launcher")
|
||||
}
|
||||
base := t.TempDir()
|
||||
t.Setenv("REASONIX_CACHE_DIR", base)
|
||||
// Seed a fake cached launcher so Install short-circuits before any download.
|
||||
launcher := filepath.Join(CacheDir(), "bin", "codegraph")
|
||||
if err := os.MkdirAll(filepath.Dir(launcher), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(launcher, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := Install(context.Background(), nil)
|
||||
if err != nil || got != launcher {
|
||||
t.Fatalf("Install with a populated cache = %q, %v; want %q", got, err, launcher)
|
||||
}
|
||||
// Resolve should also find it (no override, cache wins).
|
||||
if p, ok := Resolve(""); !ok || p != launcher {
|
||||
t.Fatalf("Resolve = %q, %v; want %q", p, ok, launcher)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDownloadsLatestAndActivates(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses a POSIX +x launcher")
|
||||
}
|
||||
base := t.TempDir()
|
||||
t.Setenv("REASONIX_CACHE_DIR", base)
|
||||
asset := assetName()
|
||||
body := makeTarGz(t, map[string]struct {
|
||||
body string
|
||||
mode int64
|
||||
}{
|
||||
"codegraph-test/bin/codegraph": {"#!/bin/sh\n", 0o755},
|
||||
})
|
||||
sum := sha256.Sum256(body)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/repos/colbymchenry/codegraph/releases/latest":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"tag_name":"v9.9.9"}`))
|
||||
case "/download/v9.9.9/SHA256SUMS":
|
||||
fmt.Fprintf(w, "%x %s\n", sum, asset)
|
||||
case "/download/v9.9.9/" + asset:
|
||||
w.Write(body)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
oldAPIBase := githubAPIBase
|
||||
oldDownloadBase := githubReleaseDownloadBase
|
||||
githubAPIBase = srv.URL
|
||||
githubReleaseDownloadBase = func(version string) string { return srv.URL + "/download/" + version }
|
||||
t.Cleanup(func() {
|
||||
githubAPIBase = oldAPIBase
|
||||
githubReleaseDownloadBase = oldDownloadBase
|
||||
})
|
||||
|
||||
downloaded, err := DownloadLatestWithClient(context.Background(), srv.Client(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("DownloadLatestWithClient: %v", err)
|
||||
}
|
||||
if downloaded.Version != "v9.9.9" {
|
||||
t.Fatalf("downloaded version = %q, want v9.9.9", downloaded.Version)
|
||||
}
|
||||
if got := ActiveVersion(); got != "" {
|
||||
t.Fatalf("ActiveVersion after download-only = %q, want empty", got)
|
||||
}
|
||||
if p, ok := Resolve(""); ok && p == downloaded.Path {
|
||||
t.Fatalf("Resolve used download-only latest path %q before activation", p)
|
||||
}
|
||||
|
||||
res, err := UpdateWithClient(context.Background(), srv.Client(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateWithClient: %v", err)
|
||||
}
|
||||
if res.Version != "v9.9.9" {
|
||||
t.Fatalf("version = %q, want v9.9.9", res.Version)
|
||||
}
|
||||
if want := filepath.Join(CacheDirForVersion("v9.9.9"), "bin", "codegraph"); res.Path != want {
|
||||
t.Fatalf("path = %q, want %q", res.Path, want)
|
||||
}
|
||||
if got := ActiveVersion(); got != "v9.9.9" {
|
||||
t.Fatalf("ActiveVersion = %q, want v9.9.9", got)
|
||||
}
|
||||
if p, ok := Resolve(""); !ok || p != res.Path {
|
||||
t.Fatalf("Resolve = %q, %v; want active path %q", p, ok, res.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveVersionRejectsTraversal(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
t.Setenv("REASONIX_CACHE_DIR", base)
|
||||
dir := filepath.Join(base, "codegraph")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, activeVersionFile), []byte("../evil\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := ActiveVersion(); got != "" {
|
||||
t.Fatalf("ActiveVersion = %q, want empty for traversal", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadAssetUsesEmbeddedChecksumAndMirrorFallback(t *testing.T) {
|
||||
asset := assetName()
|
||||
body := []byte("verified codegraph payload")
|
||||
sum := sha256.Sum256(body)
|
||||
prev, hadPrev := releaseAssetSHA256[asset]
|
||||
releaseAssetSHA256[asset] = fmt.Sprintf("%x", sum)
|
||||
t.Cleanup(func() {
|
||||
if hadPrev {
|
||||
releaseAssetSHA256[asset] = prev
|
||||
} else {
|
||||
delete(releaseAssetSHA256, asset)
|
||||
}
|
||||
})
|
||||
|
||||
bad := httptest.NewServer(http.NotFoundHandler())
|
||||
defer bad.Close()
|
||||
good := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasSuffix(r.URL.Path, "/"+asset) {
|
||||
t.Fatalf("download path = %q, want asset suffix", r.URL.Path)
|
||||
}
|
||||
w.Write(body)
|
||||
}))
|
||||
defer good.Close()
|
||||
|
||||
got, err := downloadAssetFromBases(context.Background(), http.DefaultClient, asset, fmt.Sprintf("%x", sum), []string{bad.URL, good.URL}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("downloadAsset: %v", err)
|
||||
}
|
||||
if string(got) != string(body) {
|
||||
t.Fatalf("downloadAsset body = %q, want %q", got, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadAssetRejectsMirrorChecksumMismatch(t *testing.T) {
|
||||
asset := assetName()
|
||||
prev, hadPrev := releaseAssetSHA256[asset]
|
||||
releaseAssetSHA256[asset] = strings.Repeat("0", 64)
|
||||
t.Cleanup(func() {
|
||||
if hadPrev {
|
||||
releaseAssetSHA256[asset] = prev
|
||||
} else {
|
||||
delete(releaseAssetSHA256, asset)
|
||||
}
|
||||
})
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("tampered"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
if _, err := downloadAssetFromBases(ctx, http.DefaultClient, asset, strings.Repeat("0", 64), []string{srv.URL}, nil); err == nil {
|
||||
t.Fatal("downloadAsset accepted a checksum mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadBasesUseOfficialSourcesBeforeGitHub(t *testing.T) {
|
||||
got := downloadBases()
|
||||
want := []string{
|
||||
officialMirrorBase + "/" + Version,
|
||||
fmt.Sprintf("https://github.com/%s/releases/download/%s", cgRepo, Version),
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("downloadBases = %#v, want %#v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("downloadBases[%d] = %q, want %q (all=%#v)", i, got[i], want[i], got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPGetDetachedCtxSurvivesParentCancel(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
close(started)
|
||||
select {
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
case <-r.Context().Done():
|
||||
}
|
||||
w.Write([]byte("payload"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
var wg sync.WaitGroup
|
||||
var got []byte
|
||||
var gotErr error
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
got, gotErr = httpGet(context.WithoutCancel(ctx), http.DefaultClient, srv.URL)
|
||||
}()
|
||||
|
||||
<-started
|
||||
cancel()
|
||||
wg.Wait()
|
||||
|
||||
if gotErr != nil {
|
||||
t.Fatalf("detached download aborted after parent cancel: %v", gotErr)
|
||||
}
|
||||
if string(got) != "payload" {
|
||||
t.Fatalf("got %q, want payload", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPGetPlainCtxAbortsOnParentCancel(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
close(started)
|
||||
<-r.Context().Done()
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
var wg sync.WaitGroup
|
||||
var gotErr error
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, gotErr = httpGet(ctx, http.DefaultClient, srv.URL)
|
||||
}()
|
||||
|
||||
<-started
|
||||
cancel()
|
||||
wg.Wait()
|
||||
|
||||
if !errors.Is(gotErr, context.Canceled) {
|
||||
t.Fatalf("plain ctx should abort on parent cancel, got %v", gotErr)
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package codegraph
|
||||
|
||||
// ReadOnlyToolNames returns the CodeGraph MCP tools Reasonix treats as readers
|
||||
// when older CodeGraph runtimes omit MCP annotations.readOnlyHint metadata.
|
||||
func ReadOnlyToolNames() map[string]bool {
|
||||
return map[string]bool{
|
||||
"codegraph_callees": true,
|
||||
"codegraph_callers": true,
|
||||
"codegraph_context": true,
|
||||
"codegraph_explore": true,
|
||||
"codegraph_files": true,
|
||||
"codegraph_impact": true,
|
||||
"codegraph_node": true,
|
||||
"codegraph_search": true,
|
||||
"codegraph_status": true,
|
||||
"codegraph_trace": true,
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package codegraph
|
||||
|
||||
import "reasonix/internal/plugin"
|
||||
|
||||
// MCPSpec returns the built-in CodeGraph MCP plugin spec used by every Reasonix
|
||||
// launch path. Keeping this in one place prevents lifecycle env overrides from
|
||||
// drifting between boot, token-economy on-demand enablement, and manual MCP
|
||||
// reconnects.
|
||||
func MCPSpec(bin, root string) plugin.Spec {
|
||||
return plugin.Spec{
|
||||
Name: "codegraph",
|
||||
StripRawPrefix: "codegraph_",
|
||||
Command: bin,
|
||||
Args: []string{"serve", "--mcp"},
|
||||
Env: map[string]string{
|
||||
DaemonIdleTimeoutEnv: ReasonixDaemonIdleTimeoutMS,
|
||||
},
|
||||
Dir: root,
|
||||
ReadOnlyToolNames: ReadOnlyToolNames(),
|
||||
// The daemon walks and indexes the whole tree; below-normal priority keeps
|
||||
// it from starving the user's machine (#3797).
|
||||
LowPriority: true,
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package codegraph
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMCPSpecSetsReasonixDaemonIdleTimeout(t *testing.T) {
|
||||
spec := MCPSpec("/tmp/codegraph", "/tmp/project")
|
||||
if spec.Name != "codegraph" {
|
||||
t.Fatalf("Name = %q, want codegraph", spec.Name)
|
||||
}
|
||||
if spec.StripRawPrefix != "codegraph_" {
|
||||
t.Fatalf("StripRawPrefix = %q, want codegraph_", spec.StripRawPrefix)
|
||||
}
|
||||
if got := spec.Env[DaemonIdleTimeoutEnv]; got != ReasonixDaemonIdleTimeoutMS {
|
||||
t.Fatalf("%s = %q, want %q", DaemonIdleTimeoutEnv, got, ReasonixDaemonIdleTimeoutMS)
|
||||
}
|
||||
if !spec.LowPriority {
|
||||
t.Fatal("LowPriority = false, want true")
|
||||
}
|
||||
if !spec.ReadOnlyToolNames["codegraph_search"] {
|
||||
t.Fatal("ReadOnlyToolNames missing codegraph_search")
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package codegraph
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSteerTextNamesMatchRegisteredTools keeps the system-prompt steering names
|
||||
// in lockstep with the daemon's tools. A model-visible name is
|
||||
// "mcp__codegraph__" + the raw tool name with the spec's StripRawPrefix
|
||||
// ("codegraph_") removed — the mirror of plugin.ToolPrefix + Spec.StripRawPrefix
|
||||
// applied in boot.go. If a tool is added/renamed or the steering text drifts,
|
||||
// this fails instead of silently telling the model to call a name it can't use.
|
||||
func TestSteerTextNamesMatchRegisteredTools(t *testing.T) {
|
||||
const prefix = "mcp__codegraph__"
|
||||
|
||||
want := make(map[string]bool, len(ReadOnlyToolNames()))
|
||||
for raw := range ReadOnlyToolNames() {
|
||||
want[prefix+strings.TrimPrefix(raw, "codegraph_")] = true
|
||||
}
|
||||
|
||||
for name := range want {
|
||||
if !strings.Contains(SteerText, name) {
|
||||
t.Errorf("SteerText is missing %q — the model won't know this tool exists", name)
|
||||
}
|
||||
}
|
||||
|
||||
for _, field := range strings.Fields(SteerText) {
|
||||
if strings.HasPrefix(field, prefix) && !want[field] {
|
||||
t.Errorf("SteerText names %q, which is not a registered codegraph tool", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
package codegraph
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// tarGzWithSymlink builds a one-entry tar.gz holding a symlink named link -> dest.
|
||||
func tarGzWithSymlink(link, dest string) []byte {
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(gz)
|
||||
_ = tw.WriteHeader(&tar.Header{Name: link, Typeflag: tar.TypeSymlink, Linkname: dest, Mode: 0o777})
|
||||
tw.Close()
|
||||
gz.Close()
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func tarGzWithExistingSymlinkTarget(link, dest string) []byte {
|
||||
cleanDest := filepath.Clean(dest)
|
||||
return tarGz(
|
||||
&tar.Header{Name: cleanDest, Typeflag: tar.TypeReg, Mode: 0o644},
|
||||
&tar.Header{Name: link, Typeflag: tar.TypeSymlink, Linkname: dest, Mode: 0o777},
|
||||
)
|
||||
}
|
||||
|
||||
// TestExtractRejectsEscapingSymlink proves a symlink pointing outside the
|
||||
// extraction dir is refused — otherwise a later entry written through it escapes
|
||||
// (tar-slip via symlink), letting a malicious third-party release plant files
|
||||
// outside the cache. Relative escapes resolve the same on every host.
|
||||
func TestExtractRejectsEscapingSymlink(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, dest := range []string{"../escape", "../../etc/cron.d", "sub/../../escape"} {
|
||||
err := extractTarGz(tarGzWithSymlink("evil", dest), dir)
|
||||
if err == nil || !strings.Contains(err.Error(), "unsafe symlink") {
|
||||
t.Errorf("symlink -> %q should be rejected, got %v", dest, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tarGz builds a tar.gz from the given headers in order, each with no body.
|
||||
func tarGz(hdrs ...*tar.Header) []byte {
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(gz)
|
||||
for _, h := range hdrs {
|
||||
_ = tw.WriteHeader(h)
|
||||
}
|
||||
tw.Close()
|
||||
gz.Close()
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// TestExtractRejectsSymlinkRedirectEscape covers the variant a lexical path check
|
||||
// misses: an in-bounds symlink ("real/up" -> "..", which lands on root) is made a
|
||||
// parent of a second symlink, so the second link's real location is root/escape
|
||||
// even though its archive path is real/up/escape. Resolving the parent before
|
||||
// validating the target is what catches it (go/unsafe-unzip-symlink).
|
||||
func TestExtractRejectsSymlinkRedirectEscape(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.Symlink("probe-target", filepath.Join(dir, "probe-link")); err != nil {
|
||||
t.Skipf("symlink creation is not available in this environment: %v", err)
|
||||
}
|
||||
_ = os.Remove(filepath.Join(dir, "probe-link"))
|
||||
data := tarGz(
|
||||
&tar.Header{Name: "real/", Typeflag: tar.TypeDir, Mode: 0o755},
|
||||
&tar.Header{Name: "real/up", Typeflag: tar.TypeSymlink, Linkname: "..", Mode: 0o777},
|
||||
&tar.Header{Name: "real/up/escape", Typeflag: tar.TypeSymlink, Linkname: "..", Mode: 0o777},
|
||||
)
|
||||
err := extractTarGz(data, dir)
|
||||
if err == nil || !strings.Contains(err.Error(), "unsafe symlink") {
|
||||
t.Fatalf("symlink-redirect escape should be rejected, got %v", err)
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Dir(dir)); err == nil {
|
||||
if _, err := os.Lstat(filepath.Join(filepath.Dir(dir), "escape")); err == nil {
|
||||
t.Fatal("escape symlink was planted outside the extraction dir")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractAllowsInternalSymlink keeps legitimate in-bundle symlinks working.
|
||||
func TestExtractAllowsInternalSymlink(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
probe := filepath.Join(dir, "probe-link")
|
||||
if err := os.Symlink("probe-target", probe); err != nil {
|
||||
t.Skipf("symlink creation is not available in this environment: %v", err)
|
||||
}
|
||||
_ = os.Remove(probe)
|
||||
for _, dest := range []string{"bin/codegraph", "./node", "sub/dir/../tool"} {
|
||||
if err := extractTarGz(tarGzWithExistingSymlinkTarget("link", dest), dir); err != nil {
|
||||
t.Errorf("internal symlink -> %q should be allowed, got %v", dest, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractAllowsParentRelativeInternalSymlink(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
probe := filepath.Join(dir, "probe-link")
|
||||
if err := os.Symlink("probe-target", probe); err != nil {
|
||||
t.Skipf("symlink creation is not available in this environment: %v", err)
|
||||
}
|
||||
_ = os.Remove(probe)
|
||||
data := tarGz(
|
||||
&tar.Header{Name: "codegraph-linux-x64/lib/tool", Typeflag: tar.TypeReg, Mode: 0o755},
|
||||
&tar.Header{Name: "codegraph-linux-x64/bin/tool", Typeflag: tar.TypeSymlink, Linkname: "../lib/tool", Mode: 0o777},
|
||||
)
|
||||
if err := extractTarGz(data, dir); err != nil {
|
||||
t.Fatalf("parent-relative internal symlink should be allowed: %v", err)
|
||||
}
|
||||
link := filepath.Join(dir, "codegraph-linux-x64", "bin", "tool")
|
||||
if got, err := os.Readlink(link); err != nil || filepath.ToSlash(got) != "../lib/tool" {
|
||||
t.Fatalf("symlink target = %q, %v; want ../lib/tool", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRejectsDanglingSymlink(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
err := extractTarGz(tarGzWithSymlink("link", "missing-target"), dir)
|
||||
if err == nil || !strings.Contains(err.Error(), "unsafe symlink") {
|
||||
t.Fatalf("dangling symlink should be rejected, got %v", err)
|
||||
}
|
||||
}
|
||||
+18
-160
@@ -13,7 +13,6 @@ import (
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
|
||||
@@ -40,26 +39,23 @@ func SkillNameKey(name string) string {
|
||||
|
||||
// Config is Reasonix's runtime configuration.
|
||||
type Config struct {
|
||||
ConfigVersion int `toml:"config_version"`
|
||||
DefaultModel string `toml:"default_model"`
|
||||
Language string `toml:"language"` // ui/model language tag (e.g. "zh"); empty = auto-detect from $LANG / $REASONIX_LANG
|
||||
UI UIConfig `toml:"ui"`
|
||||
Desktop DesktopConfig `toml:"desktop"`
|
||||
Notifications NotificationsConfig `toml:"notifications"`
|
||||
Agent AgentConfig `toml:"agent"`
|
||||
Providers []ProviderEntry `toml:"providers"`
|
||||
Tools ToolsConfig `toml:"tools"`
|
||||
Permissions PermissionsConfig `toml:"permissions"`
|
||||
Sandbox SandboxConfig `toml:"sandbox"`
|
||||
Network NetworkConfig `toml:"network"`
|
||||
Plugins []PluginEntry `toml:"plugins"`
|
||||
Skills SkillsConfig `toml:"skills"`
|
||||
Codegraph CodegraphConfig `toml:"codegraph"`
|
||||
BuiltInMCP BuiltInMCPConfig `toml:"builtin_mcp"`
|
||||
BuiltInMCPUpdates BuiltInMCPUpdatesConfig `toml:"builtin_mcp_updates"`
|
||||
Statusline StatuslineConfig `toml:"statusline"`
|
||||
LSP LSPConfig `toml:"lsp"`
|
||||
Bot BotConfig `toml:"bot"`
|
||||
ConfigVersion int `toml:"config_version"`
|
||||
DefaultModel string `toml:"default_model"`
|
||||
Language string `toml:"language"` // ui/model language tag (e.g. "zh"); empty = auto-detect from $LANG / $REASONIX_LANG
|
||||
UI UIConfig `toml:"ui"`
|
||||
Desktop DesktopConfig `toml:"desktop"`
|
||||
Notifications NotificationsConfig `toml:"notifications"`
|
||||
Agent AgentConfig `toml:"agent"`
|
||||
Providers []ProviderEntry `toml:"providers"`
|
||||
Tools ToolsConfig `toml:"tools"`
|
||||
Permissions PermissionsConfig `toml:"permissions"`
|
||||
Sandbox SandboxConfig `toml:"sandbox"`
|
||||
Network NetworkConfig `toml:"network"`
|
||||
Plugins []PluginEntry `toml:"plugins"`
|
||||
Skills SkillsConfig `toml:"skills"`
|
||||
Statusline StatuslineConfig `toml:"statusline"`
|
||||
LSP LSPConfig `toml:"lsp"`
|
||||
Bot BotConfig `toml:"bot"`
|
||||
|
||||
providerSources map[string]providerSourceScope
|
||||
}
|
||||
@@ -401,121 +397,6 @@ type StatuslineConfig struct {
|
||||
Command string `toml:"command"`
|
||||
}
|
||||
|
||||
// CodegraphConfig governs the built-in CodeGraph MCP server — symbol/call-graph
|
||||
// code intelligence (tree-sitter + SQLite) that gives the agent codegraph_*
|
||||
// search / context / explore / trace / node tools. Enabled defaults to true so
|
||||
// upgrades keep it for existing configs; first-run scaffolds write enabled =
|
||||
// false so only brand-new users start without it. AutoInstall (default true)
|
||||
// lets reasonix fetch the CodeGraph runtime into its cache when CodeGraph is
|
||||
// enabled but missing; set false to require an explicit `reasonix codegraph
|
||||
// install` (e.g. for air-gapped or headless runs). Path overrides binary
|
||||
// resolution; empty resolves the cache, then a `codegraph` on PATH, then a
|
||||
// bundle beside the executable. CodeGraph always starts in the background when
|
||||
// enabled; legacy tier values are ignored and removed during config load.
|
||||
type CodegraphConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
AutoInstall bool `toml:"auto_install"`
|
||||
Path string `toml:"path"`
|
||||
Tier string `toml:"tier"`
|
||||
}
|
||||
|
||||
func (c CodegraphConfig) ShouldAutoStart() bool {
|
||||
return c.Enabled
|
||||
}
|
||||
|
||||
func (c CodegraphConfig) ResolvedTier() string {
|
||||
return "background"
|
||||
}
|
||||
|
||||
// BuiltInMCPConfig controls Reasonix-shipped MCP servers that require no user
|
||||
// server definition. They are off by default and become provider-visible only
|
||||
// after the user enables them.
|
||||
type BuiltInMCPConfig struct {
|
||||
TimeEnabled bool `toml:"time_enabled"`
|
||||
Context7Enabled bool `toml:"context7_enabled"`
|
||||
}
|
||||
|
||||
func (c BuiltInMCPConfig) Enabled(name string) bool {
|
||||
switch name {
|
||||
case "time":
|
||||
return c.TimeEnabled
|
||||
case "context7":
|
||||
return c.Context7Enabled
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (c *BuiltInMCPConfig) SetEnabled(name string, enabled bool) bool {
|
||||
switch name {
|
||||
case "time":
|
||||
c.TimeEnabled = enabled
|
||||
return true
|
||||
case "context7":
|
||||
c.Context7Enabled = enabled
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (c BuiltInMCPConfig) EnabledNames() []string {
|
||||
var out []string
|
||||
if c.TimeEnabled {
|
||||
out = append(out, "time")
|
||||
}
|
||||
if c.Context7Enabled {
|
||||
out = append(out, "context7")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const (
|
||||
BuiltInMCPUpdateModeOff = "off"
|
||||
BuiltInMCPUpdateModeNotify = "notify"
|
||||
BuiltInMCPUpdateModeDownload = "download"
|
||||
BuiltInMCPUpdateModeAutoNextSession = "auto_next_session"
|
||||
|
||||
defaultBuiltInMCPUpdateInterval = 24 * time.Hour
|
||||
)
|
||||
|
||||
// BuiltInMCPUpdatesConfig controls background update checks for Reasonix-owned
|
||||
// built-in MCP runtimes. The default is notify-only so startup never silently
|
||||
// changes provider-visible MCP tool schemas.
|
||||
type BuiltInMCPUpdatesConfig struct {
|
||||
Mode string `toml:"mode"`
|
||||
CheckInterval string `toml:"check_interval"`
|
||||
}
|
||||
|
||||
func (c BuiltInMCPUpdatesConfig) ResolvedMode() string {
|
||||
switch strings.ToLower(strings.TrimSpace(c.Mode)) {
|
||||
case BuiltInMCPUpdateModeOff:
|
||||
return BuiltInMCPUpdateModeOff
|
||||
case BuiltInMCPUpdateModeDownload:
|
||||
return BuiltInMCPUpdateModeDownload
|
||||
case BuiltInMCPUpdateModeAutoNextSession:
|
||||
return BuiltInMCPUpdateModeAutoNextSession
|
||||
default:
|
||||
return BuiltInMCPUpdateModeNotify
|
||||
}
|
||||
}
|
||||
|
||||
func (c BuiltInMCPUpdatesConfig) CheckIntervalDuration() time.Duration {
|
||||
raw := strings.TrimSpace(c.CheckInterval)
|
||||
if raw == "" {
|
||||
return defaultBuiltInMCPUpdateInterval
|
||||
}
|
||||
d, err := time.ParseDuration(raw)
|
||||
if err != nil || d <= 0 {
|
||||
return defaultBuiltInMCPUpdateInterval
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func (c BuiltInMCPUpdatesConfig) ResolvedCheckInterval() string {
|
||||
return c.CheckIntervalDuration().String()
|
||||
}
|
||||
|
||||
// BotConfig 控制多渠道 IM bot 消息网关。
|
||||
type BotConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
@@ -1275,18 +1156,6 @@ func Default() *Config {
|
||||
// so an absent [sandbox] in a user's file keeps egress (zero value would
|
||||
// wrongly deny it).
|
||||
Sandbox: SandboxConfig{Bash: "enforce", Network: true},
|
||||
// CodeGraph code-intelligence defaults on so existing configs (which never
|
||||
// wrote a [codegraph] section) keep it after an upgrade. First-run scaffolds
|
||||
// write enabled = false instead, so only brand-new users start without it.
|
||||
// AutoInstall fetches the runtime into the cache when enabled and missing.
|
||||
Codegraph: CodegraphConfig{Enabled: true, AutoInstall: true},
|
||||
// Time is dependency-free and bundled, so expose it by default. Context7
|
||||
// can invoke a package runner and remains opt-in.
|
||||
BuiltInMCP: BuiltInMCPConfig{TimeEnabled: true},
|
||||
BuiltInMCPUpdates: BuiltInMCPUpdatesConfig{
|
||||
Mode: BuiltInMCPUpdateModeNotify,
|
||||
CheckInterval: defaultBuiltInMCPUpdateInterval.String(),
|
||||
},
|
||||
// LSP tools on by default, but dormant until a language server is on PATH;
|
||||
// a missing server yields an install hint rather than an error.
|
||||
LSP: LSPConfig{Enabled: true},
|
||||
@@ -1577,10 +1446,8 @@ func LoadForRoot(root string) (*Config, error) {
|
||||
tomlSources = append(tomlSources, uc)
|
||||
}
|
||||
tomlSources = append(tomlSources, projectTOML)
|
||||
sawConfigFile := false
|
||||
for _, path := range tomlSources {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
sawConfigFile = true
|
||||
if err := migrateLegacyMCPTiersFile(path); err != nil {
|
||||
slog.Warn("config: legacy mcp tier migration failed", "path", path, "err", err)
|
||||
}
|
||||
@@ -1636,12 +1503,6 @@ func LoadForRoot(root string) (*Config, error) {
|
||||
backfillDeepSeekOfficialPrices(cfg)
|
||||
normalizeEffortConfig(cfg)
|
||||
backfillDeepSeekPro(cfg)
|
||||
// First run (no config file anywhere): keep CodeGraph off until the user opts
|
||||
// in. An existing config — even one without a [codegraph] section — keeps the
|
||||
// built-in default (on), so an upgrade never silently drops code intelligence.
|
||||
if !sawConfigFile {
|
||||
cfg.Codegraph.Enabled = false
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
@@ -1894,7 +1755,6 @@ func normalizeLegacyMCPTiers(c *Config) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.Codegraph.Tier = ""
|
||||
for i := range c.Plugins {
|
||||
c.Plugins[i].Tier = ""
|
||||
}
|
||||
@@ -1925,7 +1785,7 @@ func stripLegacyMCPTierLines(raw string) (string, bool) {
|
||||
if header := tomlSectionHeader(line); header != "" {
|
||||
section = header
|
||||
}
|
||||
if (section == "codegraph" || section == "plugins") && isTOMLKeyAssignment(line, "tier") {
|
||||
if section == "plugins" && isTOMLKeyAssignment(line, "tier") {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
@@ -1943,8 +1803,6 @@ func tomlSectionHeader(line string) string {
|
||||
trimmed = strings.TrimSpace(trimmed[:i])
|
||||
}
|
||||
switch trimmed {
|
||||
case "[codegraph]":
|
||||
return "codegraph"
|
||||
case "[[plugins]]":
|
||||
return "plugins"
|
||||
default:
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
@@ -788,70 +787,6 @@ func TestAutoStartPlugins(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodegraphDefaultEnabledForUpgrades(t *testing.T) {
|
||||
c := Default()
|
||||
if !c.Codegraph.Enabled {
|
||||
t.Fatal("default codegraph enabled = false; existing configs without a [codegraph] section would lose it on upgrade")
|
||||
}
|
||||
if !c.Codegraph.AutoInstall {
|
||||
t.Fatal("default codegraph auto_install = false, want true")
|
||||
}
|
||||
if c.Codegraph.Tier != "" {
|
||||
t.Fatalf("default codegraph tier = %q, want unset (background by default)", c.Codegraph.Tier)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltInMCPDefaultsEnableOnlyTime(t *testing.T) {
|
||||
c := Default()
|
||||
if !c.BuiltInMCP.TimeEnabled || c.BuiltInMCP.Context7Enabled {
|
||||
t.Fatalf("built-in MCP defaults = %+v, want time enabled and context7 disabled", c.BuiltInMCP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltInMCPUpdateDefaultsAreNotifyOnly(t *testing.T) {
|
||||
c := Default()
|
||||
if got := c.BuiltInMCPUpdates.ResolvedMode(); got != BuiltInMCPUpdateModeNotify {
|
||||
t.Fatalf("builtin MCP update mode = %q, want notify", got)
|
||||
}
|
||||
if got := c.BuiltInMCPUpdates.CheckIntervalDuration(); got != 24*time.Hour {
|
||||
t.Fatalf("builtin MCP update interval = %v, want 24h", got)
|
||||
}
|
||||
invalid := BuiltInMCPUpdatesConfig{Mode: "surprise", CheckInterval: "-1s"}
|
||||
if got := invalid.ResolvedMode(); got != BuiltInMCPUpdateModeNotify {
|
||||
t.Fatalf("invalid mode resolved to %q, want notify", got)
|
||||
}
|
||||
if got := invalid.CheckIntervalDuration(); got != 24*time.Hour {
|
||||
t.Fatalf("invalid interval resolved to %v, want 24h", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadForEditPreservesCodegraphWithoutSection(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "reasonix.toml")
|
||||
if err := os.WriteFile(path, []byte("default_model = \"x\"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c := LoadForEdit(path); !c.Codegraph.Enabled {
|
||||
t.Fatal("a config omitting [codegraph] disabled codegraph; an upgrade must keep it on")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFirstRunDisablesCodegraph(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("USERPROFILE", t.TempDir())
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
t.Setenv("AppData", t.TempDir())
|
||||
t.Chdir(t.TempDir())
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Codegraph.Enabled {
|
||||
t.Fatal("first run (no config file anywhere) left codegraph enabled; new users should start without it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginResolvedTierDefaultsToBackground(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
|
||||
@@ -42,10 +42,6 @@ func TestLoadForEditMigratesLegacyMCPTiers(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "reasonix.toml")
|
||||
body := `
|
||||
[codegraph]
|
||||
enabled = true
|
||||
tier = "eager"
|
||||
|
||||
[[plugins]]
|
||||
name = "playwright"
|
||||
command = "npx"
|
||||
@@ -62,9 +58,6 @@ model = "m"
|
||||
}
|
||||
|
||||
cfg := LoadForEdit(path)
|
||||
if cfg.Codegraph.Tier != "" {
|
||||
t.Fatalf("codegraph tier = %q, want migrated empty", cfg.Codegraph.Tier)
|
||||
}
|
||||
if len(cfg.Plugins) != 1 || cfg.Plugins[0].Tier != "" {
|
||||
t.Fatalf("plugins after migration = %+v, want empty tier", cfg.Plugins)
|
||||
}
|
||||
@@ -75,7 +68,7 @@ model = "m"
|
||||
if strings.Contains(string(updated), "\ntier") {
|
||||
t.Fatalf("legacy tier lines should be removed from file:\n%s", updated)
|
||||
}
|
||||
if !strings.Contains(string(updated), `command = "npx"`) || !strings.Contains(string(updated), `[codegraph]`) {
|
||||
if !strings.Contains(string(updated), `command = "npx"`) || !strings.Contains(string(updated), `name = "local"`) {
|
||||
t.Fatalf("migration should preserve ordinary config:\n%s", updated)
|
||||
}
|
||||
}
|
||||
@@ -324,28 +324,6 @@ func RenderTOMLForScope(c *Config, scope RenderScope) string {
|
||||
b.WriteString("[tools.background_jobs]\n")
|
||||
fmt.Fprintf(&b, "stalled_warning_seconds = %d # warn once per background job after this many quiet seconds; 0 disables\n\n", c.BackgroundJobStalledWarningSeconds())
|
||||
|
||||
b.WriteString("[codegraph]\n")
|
||||
fmt.Fprintf(&b, "enabled = %v # built-in MCP server; off by default for first-run sessions\n", c.Codegraph.Enabled)
|
||||
fmt.Fprintf(&b, "auto_install = %v # fetch the runtime when CodeGraph is enabled but missing\n", c.Codegraph.AutoInstall)
|
||||
if c.Codegraph.Path != "" {
|
||||
fmt.Fprintf(&b, "path = %q # optional launcher override\n", c.Codegraph.Path)
|
||||
} else {
|
||||
b.WriteString("# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
b.WriteString("[builtin_mcp]\n")
|
||||
fmt.Fprintf(&b, "time_enabled = %v # built-in Time MCP; off until manually enabled\n", c.BuiltInMCP.TimeEnabled)
|
||||
fmt.Fprintf(&b, "context7_enabled = %v # built-in Context7 MCP; off until manually enabled\n", c.BuiltInMCP.Context7Enabled)
|
||||
b.WriteString("\n")
|
||||
|
||||
if scope != RenderScopeProject {
|
||||
b.WriteString("[builtin_mcp_updates]\n")
|
||||
fmt.Fprintf(&b, "mode = %q # off|notify|download|auto_next_session; auto never hot-swaps active sessions\n", c.BuiltInMCPUpdates.ResolvedMode())
|
||||
fmt.Fprintf(&b, "check_interval = %q # minimum interval between desktop startup background checks\n", c.BuiltInMCPUpdates.ResolvedCheckInterval())
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
renderLSPConfig(&b, c.LSP)
|
||||
|
||||
b.WriteString("[skills]\n")
|
||||
|
||||
@@ -96,8 +96,6 @@ func TestRenderTOMLRoundTrips(t *testing.T) {
|
||||
orig.Skills.ExcludedPaths = []string{"~/.agents/skills"}
|
||||
orig.Skills.DisabledSkills = []string{"review", "explore"}
|
||||
orig.Skills.MaxDepth = 2
|
||||
orig.Codegraph = CodegraphConfig{Enabled: true, AutoInstall: false, Path: "/opt/codegraph", Tier: "background"}
|
||||
orig.BuiltInMCPUpdates = BuiltInMCPUpdatesConfig{Mode: BuiltInMCPUpdateModeDownload, CheckInterval: "12h"}
|
||||
orig.Bot.ToolApprovalMode = "auto"
|
||||
orig.Bot.Connections = []BotConnectionConfig{{
|
||||
ID: "feishu-lark",
|
||||
@@ -238,24 +236,6 @@ func TestRenderTOMLRoundTrips(t *testing.T) {
|
||||
if got.Agent.SystemPrompt != orig.Agent.SystemPrompt {
|
||||
t.Errorf("system_prompt mismatch:\n got %q\nwant %q", got.Agent.SystemPrompt, orig.Agent.SystemPrompt)
|
||||
}
|
||||
if !got.Codegraph.Enabled {
|
||||
t.Error("codegraph.enabled = false, want true")
|
||||
}
|
||||
if got.Codegraph.AutoInstall {
|
||||
t.Error("codegraph.auto_install = true, want false")
|
||||
}
|
||||
if got.Codegraph.Path != "/opt/codegraph" {
|
||||
t.Errorf("codegraph.path = %q, want /opt/codegraph", got.Codegraph.Path)
|
||||
}
|
||||
if got.Codegraph.Tier != "" {
|
||||
t.Errorf("codegraph.tier = %q, want migrated empty", got.Codegraph.Tier)
|
||||
}
|
||||
if got.BuiltInMCPUpdates.ResolvedMode() != BuiltInMCPUpdateModeDownload {
|
||||
t.Errorf("builtin_mcp_updates.mode = %q, want download", got.BuiltInMCPUpdates.ResolvedMode())
|
||||
}
|
||||
if got.BuiltInMCPUpdates.ResolvedCheckInterval() != "12h0m0s" {
|
||||
t.Errorf("builtin_mcp_updates.check_interval = %q, want 12h0m0s", got.BuiltInMCPUpdates.ResolvedCheckInterval())
|
||||
}
|
||||
if !got.LSP.Enabled {
|
||||
t.Error("lsp.enabled = false, want true")
|
||||
}
|
||||
@@ -463,14 +443,14 @@ func TestScopedRenderSeparatesUserAndProjectConfig(t *testing.T) {
|
||||
c.Desktop.CheckUpdates = boolPtr(false)
|
||||
|
||||
user := RenderTOMLForScope(c, RenderScopeUser)
|
||||
for _, want := range []string{"config_version = 3", "[desktop]", `theme = "dark"`, `close_behavior = "background"`, `status_bar_style = "text"`, `check_updates = false`, "[notifications]", "[builtin_mcp_updates]"} {
|
||||
for _, want := range []string{"config_version = 3", "[desktop]", `theme = "dark"`, `close_behavior = "background"`, `status_bar_style = "text"`, `check_updates = false`, "[notifications]"} {
|
||||
if !strings.Contains(user, want) {
|
||||
t.Fatalf("user render missing %q:\n%s", want, user)
|
||||
}
|
||||
}
|
||||
|
||||
project := RenderTOMLForScope(c, RenderScopeProject)
|
||||
for _, forbidden := range []string{"[desktop]", "[notifications]", "[builtin_mcp_updates]", "close_behavior =", "check_updates ="} {
|
||||
for _, forbidden := range []string{"[desktop]", "[notifications]", "close_behavior =", "check_updates ="} {
|
||||
if strings.Contains(project, forbidden) {
|
||||
t.Fatalf("project render should not contain %q:\n%s", forbidden, project)
|
||||
}
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
package control
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/plugin"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
func TestConnectCodegraphMCPServerForRootPinsRootAndStripsPrefix(t *testing.T) {
|
||||
projectRoot := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(projectRoot, ".codegraph"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantRoot, err := filepath.EvalSymlinks(projectRoot)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cwdLog := filepath.Join(t.TempDir(), "cwd")
|
||||
t.Setenv("REASONIX_TEST_CODEGRAPH_MCP", "1")
|
||||
t.Setenv("REASONIX_TEST_CODEGRAPH_CWD_FILE", cwdLog)
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.Codegraph.Enabled = true
|
||||
cfg.Codegraph.Path = os.Args[0]
|
||||
reg := tool.NewRegistry()
|
||||
c := New(Options{Host: plugin.NewHost(), Registry: reg})
|
||||
defer c.Close()
|
||||
|
||||
if _, err := c.ConnectCodegraphMCPServerForRoot(cfg, projectRoot); err != nil {
|
||||
t.Fatalf("ConnectCodegraphMCPServerForRoot: %v", err)
|
||||
}
|
||||
if _, ok := reg.Get("mcp__codegraph__context"); !ok {
|
||||
t.Fatalf("stripped codegraph tool missing; names=%v", reg.Names())
|
||||
}
|
||||
if _, ok := reg.Get("mcp__codegraph__codegraph_context"); ok {
|
||||
t.Fatalf("raw codegraph prefix leaked into visible tool names; names=%v", reg.Names())
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
c.DisconnectMCPServer("codegraph")
|
||||
})
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
if data, err := os.ReadFile(cwdLog); err == nil {
|
||||
got, err := filepath.EvalSymlinks(string(bytes.TrimSpace(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != wantRoot {
|
||||
t.Fatalf("codegraph cwd = %q, want %q", got, wantRoot)
|
||||
}
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("helper did not record cwd at %s", cwdLog)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func runCodegraphMCPHelper() {
|
||||
if path := os.Getenv("REASONIX_TEST_CODEGRAPH_CWD_FILE"); path != "" {
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
_ = os.WriteFile(path, []byte(cwd+"\n"), 0o644)
|
||||
}
|
||||
}
|
||||
in := bufio.NewReader(os.Stdin)
|
||||
for {
|
||||
line, err := in.ReadBytes('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
line = bytes.TrimSpace(line)
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
var req struct {
|
||||
ID *int `json:"id"`
|
||||
Method string `json:"method"`
|
||||
}
|
||||
if err := json.Unmarshal(line, &req); err != nil || req.ID == nil {
|
||||
continue
|
||||
}
|
||||
var result any
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
result = map[string]any{
|
||||
"protocolVersion": "2024-11-05",
|
||||
"serverInfo": map[string]any{"name": "codegraph", "version": "test"},
|
||||
"capabilities": map[string]any{},
|
||||
}
|
||||
case "tools/list":
|
||||
result = map[string]any{"tools": []map[string]any{{
|
||||
"name": "codegraph_context",
|
||||
"description": "context",
|
||||
"inputSchema": map[string]any{"type": "object"},
|
||||
}}}
|
||||
default:
|
||||
result = map[string]any{}
|
||||
}
|
||||
resp := map[string]any{"jsonrpc": "2.0", "id": *req.ID, "result": result}
|
||||
b, _ := json.Marshal(resp)
|
||||
os.Stdout.Write(append(b, '\n'))
|
||||
}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
package control
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/codegraph"
|
||||
"reasonix/internal/plugin"
|
||||
)
|
||||
|
||||
func TestConnectConfiguredCodegraphSetsShortDaemonIdleTimeout(t *testing.T) {
|
||||
isolateControlConfigHome(t)
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
launcher := writeControlCodegraphHelper(t, dir)
|
||||
envOut := filepath.Join(dir, "codegraph-idle-env")
|
||||
t.Setenv("REASONIX_CODEGRAPH_HELPER_ENV_OUT", envOut)
|
||||
writeControlFile(t, dir, "reasonix.toml", `
|
||||
[codegraph]
|
||||
enabled = true
|
||||
path = "`+escapeTOMLPath(launcher)+`"
|
||||
`)
|
||||
|
||||
ctrl := New(Options{Host: plugin.NewHost()})
|
||||
defer ctrl.Close()
|
||||
if _, err := ctrl.ConnectConfiguredMCPServer("codegraph"); err != nil {
|
||||
t.Fatalf("ConnectConfiguredMCPServer(codegraph): %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(envOut)
|
||||
if err != nil {
|
||||
t.Fatalf("read codegraph idle timeout env: %v", err)
|
||||
}
|
||||
if string(got) != codegraph.ReasonixDaemonIdleTimeoutMS {
|
||||
t.Fatalf("%s = %q; want %q", codegraph.DaemonIdleTimeoutEnv, got, codegraph.ReasonixDaemonIdleTimeoutMS)
|
||||
}
|
||||
if !ctrl.DisconnectMCPServer("codegraph") {
|
||||
t.Fatal("DisconnectMCPServer(codegraph) returned false")
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
func isolateControlConfigHome(t *testing.T) {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
xdg := filepath.Join(home, ".config")
|
||||
appData := filepath.Join(home, "AppData")
|
||||
if err := os.MkdirAll(xdg, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(appData, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", xdg)
|
||||
t.Setenv("AppData", appData)
|
||||
}
|
||||
|
||||
func writeControlCodegraphHelper(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, "codegraph-helper")
|
||||
if runtime.GOOS == "windows" {
|
||||
path += ".exe"
|
||||
}
|
||||
src := filepath.Join(dir, "codegraph-helper.go")
|
||||
if err := os.WriteFile(src, []byte(`package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) >= 3 && os.Args[1] == "init" {
|
||||
_ = os.MkdirAll(filepath.Join(os.Args[2], ".codegraph"), 0o755)
|
||||
return
|
||||
}
|
||||
if len(os.Args) >= 2 && os.Args[1] == "serve" {
|
||||
if out := os.Getenv("REASONIX_CODEGRAPH_HELPER_ENV_OUT"); out != "" {
|
||||
_ = os.WriteFile(out, []byte(os.Getenv("CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS")), 0o644)
|
||||
}
|
||||
}
|
||||
|
||||
in := bufio.NewReader(os.Stdin)
|
||||
for {
|
||||
line, err := in.ReadBytes('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
line = bytes.TrimSpace(line)
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ID *int `+"`json:\"id\"`"+`
|
||||
Method string `+"`json:\"method\"`"+`
|
||||
Params json.RawMessage `+"`json:\"params\"`"+`
|
||||
}
|
||||
if err := json.Unmarshal(line, &req); err != nil || req.ID == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var result any
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
result = map[string]any{
|
||||
"protocolVersion": "2024-11-05",
|
||||
"serverInfo": map[string]any{"name": "codegraph", "version": "0"},
|
||||
"capabilities": map[string]any{},
|
||||
}
|
||||
case "tools/list":
|
||||
result = map[string]any{"tools": []map[string]any{{
|
||||
"name": "search",
|
||||
"description": "Search symbols.",
|
||||
"inputSchema": map[string]any{"type": "object"},
|
||||
}}}
|
||||
}
|
||||
|
||||
resp := map[string]any{"jsonrpc": "2.0", "id": *req.ID, "result": result}
|
||||
b, _ := json.Marshal(resp)
|
||||
_, _ = os.Stdout.Write(append(b, '\n'))
|
||||
}
|
||||
}
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := exec.CommandContext(context.Background(), "go", "build", "-o", path, src)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("build codegraph helper: %v\n%s", err, out)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func writeControlFile(t *testing.T, dir, name, body string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func escapeTOMLPath(path string) string {
|
||||
escaped := ""
|
||||
for _, r := range path {
|
||||
if r == '\\' || r == '"' {
|
||||
escaped += "\\"
|
||||
}
|
||||
escaped += string(r)
|
||||
}
|
||||
return escaped
|
||||
}
|
||||
@@ -31,9 +31,7 @@ import (
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/billing"
|
||||
"reasonix/internal/builtinmcp"
|
||||
"reasonix/internal/checkpoint"
|
||||
"reasonix/internal/codegraph"
|
||||
"reasonix/internal/command"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/diff"
|
||||
@@ -2369,7 +2367,7 @@ func (c *Controller) ConnectMCPServer(e config.PluginEntry) (int, error) {
|
||||
|
||||
func (c *Controller) connectMCPServer(e config.PluginEntry) (int, error) {
|
||||
exp := e.ExpandedPlugin()
|
||||
return c.connectMCPSpec(plugin.Spec{
|
||||
return c.connectMCPSpec(plugin.ApplyKnownReadOnlyOverrides(plugin.Spec{
|
||||
Name: exp.Name,
|
||||
Type: exp.Type,
|
||||
Command: exp.Command,
|
||||
@@ -2377,7 +2375,7 @@ func (c *Controller) connectMCPServer(e config.PluginEntry) (int, error) {
|
||||
Env: exp.Env,
|
||||
URL: exp.URL,
|
||||
Headers: exp.Headers,
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
func (c *Controller) connectMCPSpec(s plugin.Spec) (int, error) {
|
||||
@@ -2488,62 +2486,9 @@ func (c *Controller) ConnectConfiguredMCPServer(name string) (int, error) {
|
||||
return c.connectMCPServer(p)
|
||||
}
|
||||
}
|
||||
if name == "codegraph" {
|
||||
return c.connectCodegraphMCPServer(cfg)
|
||||
}
|
||||
if p, ok := builtinmcp.Entry(name); ok {
|
||||
return c.connectMCPServer(p)
|
||||
}
|
||||
return 0, fmt.Errorf("no configured MCP server named %q", name)
|
||||
}
|
||||
|
||||
// ConnectCodegraphMCPServer connects the built-in CodeGraph server using an
|
||||
// already-resolved config. Desktop uses this after saving user-level settings so
|
||||
// a stale project config cannot override the just-applied choice.
|
||||
func (c *Controller) ConnectCodegraphMCPServer(cfg *config.Config) (int, error) {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return c.ConnectCodegraphMCPServerForRoot(cfg, cwd)
|
||||
}
|
||||
|
||||
// ConnectCodegraphMCPServerForRoot connects CodeGraph pinned to root. Desktop
|
||||
// project tabs use this after hot updates so a reconnect keeps the same project
|
||||
// scope as boot-time CodeGraph startup.
|
||||
func (c *Controller) ConnectCodegraphMCPServerForRoot(cfg *config.Config, root string) (int, error) {
|
||||
return c.connectCodegraphMCPServerForRoot(cfg, root)
|
||||
}
|
||||
|
||||
func (c *Controller) connectCodegraphMCPServer(cfg *config.Config) (int, error) {
|
||||
return c.ConnectCodegraphMCPServer(cfg)
|
||||
}
|
||||
|
||||
func (c *Controller) connectCodegraphMCPServerForRoot(cfg *config.Config, root string) (int, error) {
|
||||
if !cfg.Codegraph.Enabled {
|
||||
return 0, fmt.Errorf("codegraph is disabled in config")
|
||||
}
|
||||
bin, ok := codegraph.Resolve(cfg.Codegraph.Path)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("codegraph is not installed")
|
||||
}
|
||||
root = strings.TrimSpace(root)
|
||||
if root == "" {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
root = cwd
|
||||
}
|
||||
if !codegraph.IndexableRoot(root) {
|
||||
return 0, fmt.Errorf("codegraph: refusing to index %q — a filesystem root would index the whole volume", root)
|
||||
}
|
||||
if err := codegraph.EnsureInit(c.pluginCtx, bin, root); err != nil {
|
||||
return 0, fmt.Errorf("codegraph init: %w", err)
|
||||
}
|
||||
return c.connectMCPSpec(codegraph.MCPSpec(bin, root))
|
||||
}
|
||||
|
||||
// RemoveMCPServer disconnects a live MCP server — its tools vanish from the next
|
||||
// turn — and removes it from the config file. It reports whether a live server was
|
||||
// disconnected; an error only when the name is neither connected nor in config (or
|
||||
|
||||
@@ -286,6 +286,11 @@ func TestDisconnectMCPServerRemovesLazyPlaceholder(t *testing.T) {
|
||||
|
||||
func TestRemoveMCPServerRemovesUnconnectedLazyPlaceholder(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
||||
t.Setenv("AppData", filepath.Join(home, "AppData", "Roaming"))
|
||||
t.Chdir(dir)
|
||||
if err := os.WriteFile("reasonix.toml", []byte(`
|
||||
[[plugins]]
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
package control
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/goleak"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
if os.Getenv("REASONIX_TEST_CODEGRAPH_MCP") == "1" {
|
||||
runCodegraphMCPHelper()
|
||||
os.Exit(0)
|
||||
}
|
||||
goleak.VerifyTestMain(m)
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/codegraph"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/netclient"
|
||||
"reasonix/internal/sandbox"
|
||||
@@ -29,7 +28,6 @@ type Report struct {
|
||||
Config ConfigReport `json:"config"`
|
||||
Providers []ProviderReport `json:"providers"`
|
||||
Plugins []PluginReport `json:"plugins,omitempty"`
|
||||
Codegraph CodegraphReport `json:"codegraph"`
|
||||
LSP LSPReport `json:"lsp"`
|
||||
Sessions SessionsReport `json:"sessions"`
|
||||
Sandbox SandboxReport `json:"sandbox"`
|
||||
@@ -63,15 +61,6 @@ type PluginReport struct {
|
||||
Target string `json:"target,omitempty"`
|
||||
}
|
||||
|
||||
type CodegraphReport struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
AutoInstall bool `json:"auto_install"`
|
||||
Version string `json:"version"`
|
||||
CacheDir string `json:"cache_dir,omitempty"`
|
||||
Resolved bool `json:"resolved"`
|
||||
Path string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
type LSPReport struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Servers int `json:"servers"`
|
||||
@@ -129,12 +118,6 @@ func Collect(opts Options) Report {
|
||||
UserPath: redactHome(config.UserConfigPath()),
|
||||
DefaultModel: cfg.DefaultModel,
|
||||
},
|
||||
Codegraph: CodegraphReport{
|
||||
Enabled: cfg.Codegraph.Enabled,
|
||||
AutoInstall: cfg.Codegraph.AutoInstall,
|
||||
Version: codegraph.Version,
|
||||
CacheDir: redactHome(codegraph.CacheDir()),
|
||||
},
|
||||
LSP: LSPReport{
|
||||
Enabled: cfg.LSP.Enabled,
|
||||
Servers: len(cfg.LSP.Servers),
|
||||
@@ -160,10 +143,6 @@ func Collect(opts Options) Report {
|
||||
Warnings: warnings,
|
||||
}
|
||||
report.Sessions.Dir = redactHome(report.Sessions.Dir)
|
||||
if p, ok := codegraph.Resolve(cfg.Codegraph.Path); ok {
|
||||
report.Codegraph.Resolved = true
|
||||
report.Codegraph.Path = redactHome(p)
|
||||
}
|
||||
for i := range cfg.Providers {
|
||||
p := cfg.Providers[i]
|
||||
models := p.ModelList()
|
||||
@@ -233,16 +212,6 @@ func RenderText(r Report) string {
|
||||
}
|
||||
}
|
||||
|
||||
resolved := "missing"
|
||||
if r.Codegraph.Resolved {
|
||||
resolved = "resolved"
|
||||
}
|
||||
fmt.Fprintf(&b, "\ncodegraph\n")
|
||||
fmt.Fprintf(&b, " enabled %v\n", r.Codegraph.Enabled)
|
||||
fmt.Fprintf(&b, " auto_install %v\n", r.Codegraph.AutoInstall)
|
||||
fmt.Fprintf(&b, " version %s\n", r.Codegraph.Version)
|
||||
fmt.Fprintf(&b, " resolved %s\n", resolved)
|
||||
|
||||
fmt.Fprintf(&b, "\nlsp\n")
|
||||
fmt.Fprintf(&b, " enabled %v\n", r.LSP.Enabled)
|
||||
fmt.Fprintf(&b, " servers %d configured overrides\n", r.LSP.Servers)
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
var skipEntryNames = map[string]bool{
|
||||
".codex": true,
|
||||
".codegraph": true,
|
||||
".DS_Store": true,
|
||||
".git": true,
|
||||
".npm": true,
|
||||
|
||||
@@ -87,12 +87,12 @@ func (t *installSourceTool) applySkillRoot(req request, act *action) error {
|
||||
// copyDir uses O_EXCL so any race that slips through the Lstat check still
|
||||
// loses atomically.
|
||||
func (t *installSourceTool) applyCopySkill(req request, act *action) error {
|
||||
canonical, err := t.skillCanonicalPath(act.skill.Name, req.Scope)
|
||||
canonical, err := t.skillCanonicalPath(act.skill.Name, act.Scope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targetDir := filepath.Dir(canonical)
|
||||
conflicts, err := t.skillConflictTargets(act.skill.Name, req.Scope)
|
||||
conflicts, err := t.skillConflictTargets(act.skill.Name, act.Scope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -115,7 +115,7 @@ func (t *installSourceTool) applyCopySkill(req request, act *action) error {
|
||||
}
|
||||
act.Target = canonical
|
||||
act.CanonicalPath = canonical
|
||||
return t.verifySkill(req.Scope, act.skill.Name, act)
|
||||
return t.verifySkill(act.Scope, act.skill.Name, act)
|
||||
}
|
||||
|
||||
// applyLinkSkill creates a symlink in the skills dir pointing at the source.
|
||||
@@ -123,7 +123,7 @@ func (t *installSourceTool) applyCopySkill(req request, act *action) error {
|
||||
// plan was approved: a link-mode skill should not become a backdoor to arbitrary
|
||||
// host files.
|
||||
func (t *installSourceTool) applyLinkSkill(req request, act *action) error {
|
||||
canonical, err := t.skillCanonicalPath(act.skill.Name, req.Scope)
|
||||
canonical, err := t.skillCanonicalPath(act.skill.Name, act.Scope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -131,7 +131,7 @@ func (t *installSourceTool) applyLinkSkill(req request, act *action) error {
|
||||
if act.skill.IsDir {
|
||||
target = filepath.Dir(canonical)
|
||||
}
|
||||
conflicts, err := t.skillConflictTargets(act.skill.Name, req.Scope)
|
||||
conflicts, err := t.skillConflictTargets(act.skill.Name, act.Scope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -153,7 +153,7 @@ func (t *installSourceTool) applyLinkSkill(req request, act *action) error {
|
||||
}
|
||||
act.Target = target
|
||||
act.CanonicalPath = canonical
|
||||
return t.verifySkill(req.Scope, act.skill.Name, act)
|
||||
return t.verifySkill(act.Scope, act.skill.Name, act)
|
||||
}
|
||||
|
||||
// isLinkTargetSafe reports whether a symlink source is allowed. The link
|
||||
|
||||
@@ -119,7 +119,7 @@ func (*installSourceTool) Schema() json.RawMessage {
|
||||
"source":{"type":"string","description":"URL, local file/folder path, .mcp.json path, or package name to install from. Ignored when op=uninstall (use name instead)."},
|
||||
"kind":{"type":"string","enum":["auto","skill","mcp"],"description":"Capability kind. Defaults to auto."},
|
||||
"apply":{"type":"boolean","description":"false (default) only returns an install plan; true performs the planned writes/connects. Ignored for op=uninstall."},
|
||||
"scope":{"type":"string","enum":["project","global"],"description":"Where to persist config or copy skills. Defaults to project when a workspace exists, otherwise global."},
|
||||
"scope":{"type":"string","enum":["project","global"],"description":"Where to persist config or copy skills. MCP installs default to global so every project can use them; project-root .mcp.json imports default to project; skills default to project when a workspace exists, otherwise global."},
|
||||
"mode":{"type":"string","enum":["auto","copy","link","register"],"description":"Skill install mode. auto registers multi-skill roots and copies single skills into the canonical <skill-name>/SKILL.md layout; copy copies skill files/folders; link creates symlinks; register adds a skill root to [skills].paths."},
|
||||
"name":{"type":"string","description":"Optional override for the installed MCP server or single skill name. Required for op=uninstall when removing by name."},
|
||||
"transport":{"type":"string","enum":["auto","stdio","http","sse"],"description":"MCP transport override. URL sources default to http unless --sse-like; package sources default to stdio."},
|
||||
@@ -127,7 +127,7 @@ func (*installSourceTool) Schema() json.RawMessage {
|
||||
"args":{"type":"array","items":{"type":"string"},"description":"Optional stdio MCP args override."},
|
||||
"env":{"type":"object","additionalProperties":{"type":"string"},"description":"Environment variables for stdio MCP servers."},
|
||||
"headers":{"type":"object","additionalProperties":{"type":"string"},"description":"HTTP headers for remote MCP servers. Prefer ${VAR} placeholders for secrets."},
|
||||
"tier":{"type":"string","enum":["lazy","background","eager"],"description":"Persisted MCP startup tier. Defaults to lazy."},
|
||||
"tier":{"type":"string","enum":["lazy","background","eager"],"description":"Persisted MCP startup tier. Defaults to background."},
|
||||
"replace":{"type":"boolean","description":"Allow replacing an existing MCP config entry with the same name. Skills still refuse to overwrite existing files."},
|
||||
"strict":{"type":"boolean","description":"Skill install strictness. true (default) requires name+description frontmatter; false copies the file as-is (use only for files you trust)."},
|
||||
"planId":{"type":"string","description":"Optional. Echoed from a previous planned response to confirm the host is approving the same plan."}
|
||||
@@ -158,7 +158,7 @@ func (t *installSourceTool) Execute(ctx context.Context, raw json.RawMessage) (s
|
||||
return "", errors.New("install_source: op=uninstall requires a non-empty name")
|
||||
}
|
||||
req.Kind = normalizeKind(req.Kind)
|
||||
req.Scope = t.normalizeScope(req.Scope)
|
||||
req.Scope, req.scopeExplicit = t.normalizeScope(req.Scope)
|
||||
req.Mode = normalizeMode(req.Mode)
|
||||
req.Transport = normalizeTransport(req.Transport)
|
||||
if norm, ok := normalizeTier(req.Tier); ok {
|
||||
@@ -195,6 +195,7 @@ func (t *installSourceTool) Execute(ctx context.Context, raw json.RawMessage) (s
|
||||
for i := range actions {
|
||||
actions[i].Status = "planned"
|
||||
}
|
||||
scope := commonActionScope(actions)
|
||||
out := response{
|
||||
OK: true,
|
||||
Status: "planned",
|
||||
@@ -203,7 +204,7 @@ func (t *installSourceTool) Execute(ctx context.Context, raw json.RawMessage) (s
|
||||
Source: req.Source,
|
||||
Kind: summarizeKind(actions),
|
||||
Kinds: kindCounts(actions),
|
||||
Scope: req.Scope,
|
||||
Scope: scope,
|
||||
Mode: req.Mode,
|
||||
PlanID: planID,
|
||||
Actions: publicActions(actions),
|
||||
@@ -278,7 +279,7 @@ func (t *installSourceTool) executeApply(ctx context.Context, req request, actio
|
||||
Source: req.Source,
|
||||
Kind: summarizeKind(actions),
|
||||
Kinds: kindCounts(actions),
|
||||
Scope: req.Scope,
|
||||
Scope: commonActionScope(actions),
|
||||
Mode: req.Mode,
|
||||
PlanID: planID,
|
||||
Actions: publicActions(actions),
|
||||
@@ -294,6 +295,9 @@ func (t *installSourceTool) executeApply(ctx context.Context, req request, actio
|
||||
// of the install they authorized.
|
||||
func (t *installSourceTool) executeUninstall(req request) string {
|
||||
scope := req.Scope
|
||||
if scope == "" {
|
||||
scope = "global"
|
||||
}
|
||||
actions := []action{}
|
||||
cfgPath := t.configPath(scope)
|
||||
cfg := config.LoadForEdit(cfgPath)
|
||||
@@ -461,16 +465,64 @@ func (t *installSourceTool) configPath(scope string) string {
|
||||
return filepath.Join(t.root, "reasonix.toml")
|
||||
}
|
||||
|
||||
func (t *installSourceTool) normalizeScope(scope string) string {
|
||||
func (t *installSourceTool) normalizeScope(scope string) (string, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(scope)) {
|
||||
case "project":
|
||||
return "project", true
|
||||
case "global":
|
||||
return "global"
|
||||
return "global", true
|
||||
default:
|
||||
if strings.TrimSpace(t.root) != "" {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func (t *installSourceTool) installScope(req request, kind, source string) string {
|
||||
if req.scopeExplicit && req.Scope != "" {
|
||||
return req.Scope
|
||||
}
|
||||
if kind == "mcp" {
|
||||
if t.isProjectMCPJSONSource(source) {
|
||||
return "project"
|
||||
}
|
||||
return "global"
|
||||
}
|
||||
if strings.TrimSpace(t.root) != "" {
|
||||
return "project"
|
||||
}
|
||||
return "global"
|
||||
}
|
||||
|
||||
func (t *installSourceTool) isProjectMCPJSONSource(source string) bool {
|
||||
if isURL(source) || !strings.EqualFold(filepath.Base(source), ".mcp.json") {
|
||||
return false
|
||||
}
|
||||
root := strings.TrimSpace(t.root)
|
||||
if root == "" {
|
||||
return false
|
||||
}
|
||||
sourceAbs, sourceErr := filepath.Abs(source)
|
||||
rootAbs, rootErr := filepath.Abs(root)
|
||||
if sourceErr != nil || rootErr != nil {
|
||||
return false
|
||||
}
|
||||
rel, err := filepath.Rel(filepath.Clean(rootAbs), filepath.Clean(sourceAbs))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rel == ".mcp.json" || (!strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != "..")
|
||||
}
|
||||
|
||||
func commonActionScope(actions []action) string {
|
||||
if len(actions) == 0 {
|
||||
return ""
|
||||
}
|
||||
scope := actions[0].Scope
|
||||
for _, action := range actions[1:] {
|
||||
if action.Scope != scope {
|
||||
return "mixed"
|
||||
}
|
||||
}
|
||||
return scope
|
||||
}
|
||||
|
||||
func (t *installSourceTool) resolvePath(p string) string {
|
||||
@@ -519,7 +571,7 @@ func computePlanID(req request, actions []action) string {
|
||||
Op: req.Op,
|
||||
Source: req.Source,
|
||||
Kind: req.Kind,
|
||||
Scope: req.Scope,
|
||||
Scope: commonActionScope(actions),
|
||||
Mode: req.Mode,
|
||||
Name: req.Name,
|
||||
Transport: req.Transport,
|
||||
|
||||
@@ -19,6 +19,20 @@ import (
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
dir, err := os.MkdirTemp("", "reasonix-installsource-test-*")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
_ = os.Setenv("HOME", dir)
|
||||
_ = os.Setenv("XDG_CONFIG_HOME", filepath.Join(dir, ".config"))
|
||||
_ = os.Setenv("AppData", filepath.Join(dir, "AppData"))
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(dir)
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// --- shared helpers ---------------------------------------------------------
|
||||
|
||||
// execInstall marshals args, calls Execute, and unmarshals the response.
|
||||
@@ -433,11 +447,41 @@ func TestPlanLocalMCPJSON(t *testing.T) {
|
||||
if resp.Actions[1].Name != "remote" || resp.Actions[1].Transport != "http" {
|
||||
t.Fatalf("second action = %+v", resp.Actions[1])
|
||||
}
|
||||
for _, action := range resp.Actions {
|
||||
if action.Scope != "global" || action.ConfigPath != config.UserConfigPath() {
|
||||
t.Fatalf("local .mcp.json outside project action scope/path = %q %q, want global %q", action.Scope, action.ConfigPath, config.UserConfigPath())
|
||||
}
|
||||
}
|
||||
if resp.Kinds.MCP != 2 {
|
||||
t.Errorf("Kinds.MCP = %d, want 2", resp.Kinds.MCP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanProjectMCPJSONDefaultsProject(t *testing.T) {
|
||||
project := t.TempDir()
|
||||
home := t.TempDir()
|
||||
mcpPath := filepath.Join(project, ".mcp.json")
|
||||
writeFile(t, mcpPath, `{
|
||||
"mcpServers": {
|
||||
"projectfs": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "."] }
|
||||
}
|
||||
}`)
|
||||
|
||||
tl := NewTool(Options{ProjectRoot: project, HomeDir: home})
|
||||
resp := execInstall(t, tl, map[string]any{
|
||||
"source": mcpPath,
|
||||
"kind": "mcp",
|
||||
})
|
||||
|
||||
if !resp.OK || len(resp.Actions) != 1 {
|
||||
t.Fatalf("response = %+v", resp)
|
||||
}
|
||||
wantPath := filepath.Join(project, "reasonix.toml")
|
||||
if resp.Scope != "project" || resp.Actions[0].Scope != "project" || resp.Actions[0].ConfigPath != wantPath {
|
||||
t.Fatalf("project .mcp.json scope/path = response %q action %q path %q, want project %q", resp.Scope, resp.Actions[0].Scope, resp.Actions[0].ConfigPath, wantPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanMCPJSONUnknownTierProducesWarning(t *testing.T) {
|
||||
entries, warnings, err := parseMCPJSON([]byte(`{
|
||||
"mcpServers": {
|
||||
@@ -461,6 +505,32 @@ func TestPlanMCPJSONUnknownTierProducesWarning(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanMCPJSONDefaultTierIsBackground(t *testing.T) {
|
||||
entries, warnings, err := parseMCPJSON([]byte(`{
|
||||
"mcpServers": {
|
||||
"x": { "command": "node" }
|
||||
}
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatalf("parseMCPJSON: %v", err)
|
||||
}
|
||||
if len(warnings) != 0 {
|
||||
t.Fatalf("warnings = %v, want none", warnings)
|
||||
}
|
||||
if len(entries) != 1 || entries[0].Tier != "background" {
|
||||
t.Fatalf("entries = %+v, want default tier background", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeTierDefaultBackgroundUnknownLazy(t *testing.T) {
|
||||
if got, ok := normalizeTier(""); got != "background" || !ok {
|
||||
t.Fatalf("normalizeTier(empty) = %q, %v; want background, true", got, ok)
|
||||
}
|
||||
if got, ok := normalizeTier("absurd"); got != "lazy" || ok {
|
||||
t.Fatalf("normalizeTier(absurd) = %q, %v; want lazy, false", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanMCPJSONSplitsPastedCommandLine(t *testing.T) {
|
||||
project := t.TempDir()
|
||||
home := t.TempDir()
|
||||
@@ -549,6 +619,36 @@ func TestApplyRemoteMCPURLConnectsAndPersists(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRemoteMCPURLDefaultsGlobal(t *testing.T) {
|
||||
project := t.TempDir()
|
||||
home := t.TempDir()
|
||||
stub := &stubConnector{toolCount: 1}
|
||||
tl := NewTool(Options{
|
||||
ProjectRoot: project,
|
||||
HomeDir: home,
|
||||
ConnectMCP: stub.connector(),
|
||||
})
|
||||
|
||||
resp := execInstall(t, tl, map[string]any{
|
||||
"source": "https://global.example.com/mcp",
|
||||
"kind": "mcp",
|
||||
"apply": true,
|
||||
"name": "global-default",
|
||||
})
|
||||
|
||||
if !resp.OK || resp.Scope != "global" || resp.Actions[0].ConfigPath != config.UserConfigPath() {
|
||||
t.Fatalf("response = %+v, want global user config %q", resp, config.UserConfigPath())
|
||||
}
|
||||
userCfg := config.LoadForEdit(config.UserConfigPath())
|
||||
if p, ok := findPlugin(userCfg.Plugins, "global-default"); !ok || p.URL != "https://global.example.com/mcp" {
|
||||
t.Fatalf("global config plugins = %+v, want global-default", userCfg.Plugins)
|
||||
}
|
||||
projectCfg := config.LoadForEdit(filepath.Join(project, "reasonix.toml"))
|
||||
if _, ok := findPlugin(projectCfg.Plugins, "global-default"); ok {
|
||||
t.Fatalf("project config should not receive default-global MCP: %+v", projectCfg.Plugins)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMCPRejectsDuplicateByDefault(t *testing.T) {
|
||||
project := t.TempDir()
|
||||
home := t.TempDir()
|
||||
@@ -569,6 +669,7 @@ func TestApplyMCPRejectsDuplicateByDefault(t *testing.T) {
|
||||
"kind": "mcp",
|
||||
"apply": true,
|
||||
"name": "dup",
|
||||
"scope": "project",
|
||||
})
|
||||
|
||||
if resp.OK {
|
||||
@@ -599,6 +700,7 @@ func TestApplyMCPReplaceOverwritesExisting(t *testing.T) {
|
||||
"apply": true,
|
||||
"replace": true,
|
||||
"name": "editable",
|
||||
"scope": "project",
|
||||
})
|
||||
|
||||
if !resp.OK {
|
||||
@@ -652,6 +754,7 @@ func TestApplyMCPReplaceDisconnectsLiveServerBeforeConnect(t *testing.T) {
|
||||
"apply": true,
|
||||
"replace": true,
|
||||
"name": "live",
|
||||
"scope": "project",
|
||||
})
|
||||
|
||||
if !resp.OK {
|
||||
@@ -689,6 +792,7 @@ func TestApplyMCPRollsBackOnSaveFailure(t *testing.T) {
|
||||
"kind": "mcp",
|
||||
"apply": true,
|
||||
"name": "ghost",
|
||||
"scope": "project",
|
||||
})
|
||||
|
||||
if resp.OK {
|
||||
@@ -710,6 +814,7 @@ func TestApplyConnectFailureDoesNotPersist(t *testing.T) {
|
||||
"kind": "mcp",
|
||||
"apply": true,
|
||||
"name": "broken",
|
||||
"scope": "project",
|
||||
})
|
||||
|
||||
if resp.OK {
|
||||
@@ -739,6 +844,9 @@ func TestPackageActionUsesNpx(t *testing.T) {
|
||||
if len(resp.Actions[0].Args) != 2 || resp.Actions[0].Args[0] != "-y" || resp.Actions[0].Args[1] != "@example/mcp-pkg" {
|
||||
t.Errorf("args = %v, want [-y @example/mcp-pkg]", resp.Actions[0].Args)
|
||||
}
|
||||
if resp.Actions[0].Scope != "global" || resp.Actions[0].ConfigPath != config.UserConfigPath() {
|
||||
t.Errorf("scope/path = %q %q, want global %q", resp.Actions[0].Scope, resp.Actions[0].ConfigPath, config.UserConfigPath())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanURLBlobRewritesToRaw(t *testing.T) {
|
||||
@@ -762,6 +870,9 @@ func TestPlanURLRemoteEndpointAuto(t *testing.T) {
|
||||
if resp.Actions[0].Transport != "http" {
|
||||
t.Errorf("transport = %q, want http", resp.Actions[0].Transport)
|
||||
}
|
||||
if resp.Actions[0].Scope != "global" || resp.Actions[0].ConfigPath != config.UserConfigPath() {
|
||||
t.Errorf("scope/path = %q %q, want global %q", resp.Actions[0].Scope, resp.Actions[0].ConfigPath, config.UserConfigPath())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanURLRemoteMCPHostAuto(t *testing.T) {
|
||||
@@ -1215,6 +1326,23 @@ func TestComputePlanIDStable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanIDUsesResolvedActionScope(t *testing.T) {
|
||||
reqOmitted := request{Op: "install", Source: "https://mcp.example.com/mcp", Kind: "mcp"}
|
||||
reqGlobal := request{Op: "install", Source: "https://mcp.example.com/mcp", Kind: "mcp", Scope: "global", scopeExplicit: true}
|
||||
actions := []action{{
|
||||
Kind: "mcp",
|
||||
Action: "install_mcp_server",
|
||||
Name: "example",
|
||||
URL: "https://mcp.example.com/mcp",
|
||||
Transport: "http",
|
||||
Scope: "global",
|
||||
ConfigPath: config.UserConfigPath(),
|
||||
}}
|
||||
if got, want := computePlanID(reqOmitted, actions), computePlanID(reqGlobal, actions); got != want {
|
||||
t.Fatalf("planId with omitted scope = %q, explicit global = %q; want same resolved plan", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// --- local executable -------------------------------------------------------
|
||||
|
||||
func TestPlanLocalExecutableDetected(t *testing.T) {
|
||||
@@ -1267,12 +1395,22 @@ func TestApplyLocalExecutableHonorsCommandOverride(t *testing.T) {
|
||||
if len(stub.connected) != 1 || stub.connected[0].Command != "node" || len(stub.connected[0].Args) != 1 || stub.connected[0].Args[0] != server {
|
||||
t.Fatalf("connected entry = %+v, want node [%s]", stub.connected, server)
|
||||
}
|
||||
cfg := config.LoadForEdit(filepath.Join(project, "reasonix.toml"))
|
||||
if len(cfg.Plugins) != 1 || cfg.Plugins[0].Command != "node" || len(cfg.Plugins[0].Args) != 1 || cfg.Plugins[0].Args[0] != server {
|
||||
t.Fatalf("persisted plugins = %+v, want node [%s]", cfg.Plugins, server)
|
||||
cfg := config.LoadForEdit(config.UserConfigPath())
|
||||
p, ok := findPlugin(cfg.Plugins, "wrapped")
|
||||
if !ok || p.Command != "node" || len(p.Args) != 1 || p.Args[0] != server {
|
||||
t.Fatalf("persisted plugins = %+v, want wrapped node [%s]", cfg.Plugins, server)
|
||||
}
|
||||
}
|
||||
|
||||
func findPlugin(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {
|
||||
for _, entry := range entries {
|
||||
if entry.Name == name {
|
||||
return entry, true
|
||||
}
|
||||
}
|
||||
return config.PluginEntry{}, false
|
||||
}
|
||||
|
||||
func writeLocalExecutable(t *testing.T, dir, name string) string {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
// caller decides whether apply=true actually runs cfg.UpsertPlugin +
|
||||
// SaveTo + connectMCP.
|
||||
func (t *installSourceTool) mcpEntryAction(req request, e config.PluginEntry, source string) action {
|
||||
scope := t.installScope(req, "mcp", source)
|
||||
var normalizedCommand bool
|
||||
e, normalizedCommand = config.NormalizePluginCommandLine(e)
|
||||
// Tier comes from the call (req.Tier) or the entry; either way we
|
||||
@@ -28,8 +29,8 @@ func (t *installSourceTool) mcpEntryAction(req request, e config.PluginEntry, so
|
||||
Action: "install_mcp_server",
|
||||
Name: e.Name,
|
||||
Source: source,
|
||||
ConfigPath: t.configPath(req.Scope),
|
||||
Scope: req.Scope,
|
||||
ConfigPath: t.configPath(scope),
|
||||
Scope: scope,
|
||||
Transport: pluginTransport(e),
|
||||
URL: e.URL,
|
||||
Command: e.Command,
|
||||
|
||||
@@ -264,8 +264,10 @@ func normalizeTier(tier string) (string, bool) {
|
||||
return "eager", true
|
||||
case "background":
|
||||
return "background", true
|
||||
case "", "lazy":
|
||||
case "lazy":
|
||||
return "lazy", true
|
||||
case "":
|
||||
return "background", true
|
||||
default:
|
||||
return "lazy", false
|
||||
}
|
||||
|
||||
@@ -23,21 +23,22 @@ const (
|
||||
|
||||
// skillAction builds the DTO for a single-skill install (copy or link).
|
||||
func (t *installSourceTool) skillAction(req request, cand skillCandidate, mode string) action {
|
||||
scope := t.installScope(req, "skill", cand.SourcePath)
|
||||
actionName := "copy_skill"
|
||||
if mode == "link" {
|
||||
actionName = "link_skill"
|
||||
}
|
||||
canonical, _ := t.skillCanonicalPath(cand.Name, req.Scope)
|
||||
root, _ := t.skillInstallRoot(req.Scope)
|
||||
canonical, _ := t.skillCanonicalPath(cand.Name, scope)
|
||||
root, _ := t.skillInstallRoot(scope)
|
||||
a := action{
|
||||
Kind: "skill",
|
||||
Action: actionName,
|
||||
Name: cand.Name,
|
||||
Source: cand.SourcePath,
|
||||
Target: canonical,
|
||||
Scope: req.Scope,
|
||||
Scope: scope,
|
||||
Mode: mode,
|
||||
ConfigPath: t.configPath(req.Scope),
|
||||
ConfigPath: t.configPath(scope),
|
||||
Skills: []string{cand.Name},
|
||||
SkillCount: 1,
|
||||
Layout: "canonical_dir",
|
||||
@@ -76,14 +77,15 @@ func skillActionRisk(mode string, cand skillCandidate) (RiskLevel, []string) {
|
||||
|
||||
// skillRootAction builds the DTO for registering a whole skill directory.
|
||||
func (t *installSourceTool) skillRootAction(req request, path string, names []string) action {
|
||||
scope := t.installScope(req, "skill", path)
|
||||
return action{
|
||||
Kind: "skill",
|
||||
Action: "register_skill_root",
|
||||
Name: "",
|
||||
Source: path,
|
||||
Target: path,
|
||||
ConfigPath: t.configPath(req.Scope),
|
||||
Scope: req.Scope,
|
||||
ConfigPath: t.configPath(scope),
|
||||
Scope: scope,
|
||||
Mode: "register",
|
||||
Skills: names,
|
||||
SkillCount: len(names),
|
||||
|
||||
@@ -28,6 +28,8 @@ type request struct {
|
||||
// PlanID is echoed back on a confirm-apply call so the host can refuse
|
||||
// to apply a plan that does not match the one it approved.
|
||||
PlanID string `json:"planId"`
|
||||
|
||||
scopeExplicit bool
|
||||
}
|
||||
|
||||
// response is the JSON shape returned to the model. Status is one of
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package plugin
|
||||
|
||||
import "strings"
|
||||
|
||||
// ApplyKnownReadOnlyOverrides fills compatibility read-only hints for MCP
|
||||
// servers whose read surfaces are stable but older runtimes may omit MCP
|
||||
// annotations. It does not make the server built-in or change startup behavior.
|
||||
func ApplyKnownReadOnlyOverrides(s Spec) Spec {
|
||||
if isCodeGraphSpecName(s.Name) {
|
||||
s.ReadOnlyToolNames = mergeReadOnlyToolNames(s.ReadOnlyToolNames, codeGraphReadOnlyToolNames())
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func isCodeGraphSpecName(name string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(name), "codegraph")
|
||||
}
|
||||
|
||||
func mergeReadOnlyToolNames(existing map[string]bool, extra map[string]bool) map[string]bool {
|
||||
out := make(map[string]bool, len(existing)+len(extra))
|
||||
for name, ok := range existing {
|
||||
out[name] = ok
|
||||
}
|
||||
for name, ok := range extra {
|
||||
if ok {
|
||||
out[name] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func codeGraphReadOnlyToolNames() map[string]bool {
|
||||
base := []string{
|
||||
"callees",
|
||||
"callers",
|
||||
"context",
|
||||
"explore",
|
||||
"files",
|
||||
"impact",
|
||||
"node",
|
||||
"search",
|
||||
"status",
|
||||
"trace",
|
||||
}
|
||||
out := make(map[string]bool, len(base)*2)
|
||||
for _, name := range base {
|
||||
out[name] = true
|
||||
out["codegraph_"+name] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -47,17 +47,18 @@ type Spec struct {
|
||||
// the terminal so child logs cannot corrupt interactive UIs.
|
||||
Stderr io.Writer
|
||||
// ReadOnlyToolNames marks trusted raw MCP tool names as read-only even when
|
||||
// the server omits annotations.readOnlyHint. It is for first-party adapters
|
||||
// with known semantics; user-configured plugins should rely on MCP metadata.
|
||||
// the server omits annotations.readOnlyHint. It is for known compatibility
|
||||
// overrides where the tool semantics are stable; other user-configured
|
||||
// plugins should rely on MCP metadata.
|
||||
ReadOnlyToolNames map[string]bool
|
||||
// StripRawPrefix, when non-empty, removes this prefix from each MCP tool's
|
||||
// raw name before namespacing. For example, StripRawPrefix="codegraph_" turns
|
||||
// "codegraph_context" into "context", yielding "mcp__codegraph__context"
|
||||
// instead of the redundant "mcp__codegraph__codegraph_context". The original
|
||||
// raw name is preserved for MCP protocol calls.
|
||||
// raw name before namespacing. For example, StripRawPrefix="server_" turns
|
||||
// "server_search" into "search", yielding "mcp__search__search" instead of
|
||||
// the redundant "mcp__search__server_search". The original raw name is
|
||||
// preserved for MCP protocol calls.
|
||||
StripRawPrefix string
|
||||
// LowPriority runs a stdio subprocess below normal scheduling priority, for
|
||||
// background indexers (CodeGraph) that must not starve the user's machine.
|
||||
// background indexers that must not starve the user's machine.
|
||||
LowPriority bool
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,20 @@ func TestSpecReadOnlyToolNamesMarksUnhintedToolsReadOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyKnownReadOnlyOverridesMarksCodeGraphReadTools(t *testing.T) {
|
||||
got := ApplyKnownReadOnlyOverrides(Spec{Name: "codegraph", ReadOnlyToolNames: map[string]bool{"custom": true}})
|
||||
for _, name := range []string{"custom", "codegraph_context", "codegraph_search", "context", "search"} {
|
||||
if !got.ReadOnlyToolNames[name] {
|
||||
t.Fatalf("codegraph read-only override missing %q: %+v", name, got.ReadOnlyToolNames)
|
||||
}
|
||||
}
|
||||
|
||||
other := ApplyKnownReadOnlyOverrides(Spec{Name: "not-codegraph"})
|
||||
if other.ReadOnlyToolNames["codegraph_context"] {
|
||||
t.Fatalf("non-codegraph spec should not receive codegraph overrides: %+v", other.ReadOnlyToolNames)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartAvailableKeepsGoodServers(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
|
||||
// KillTree kills cmd's whole process group. StartTracked (and SetProcessGroupKill
|
||||
// for children started outside it) put the child in its own group via Setpgid, so
|
||||
// the negative-pid signal reaches every descendant — a launcher whose sub-daemon
|
||||
// survives the parent (e.g. codegraph's bundled node runtime) included, where a
|
||||
// plain Process.Kill would only hit the direct child and orphan the grandchild.
|
||||
// the negative-pid signal reaches every descendant, including a launcher whose
|
||||
// sub-daemon survives the parent, where a plain Process.Kill would only hit the
|
||||
// direct child and orphan the grandchild.
|
||||
func KillTree(cmd *exec.Cmd) {
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
return
|
||||
|
||||
@@ -53,7 +53,7 @@ func TestKillTrackedTerminatesChild(t *testing.T) {
|
||||
|
||||
// A launcher (sh) that backgrounds a grandchild (sleep) and stays alive: with
|
||||
// the child in its own process group, KillTracked's negative-pid kill must reap
|
||||
// the grandchild too, not just sh — the codegraph node-daemon leak this guards.
|
||||
// the grandchild too, not just sh.
|
||||
func TestKillTrackedReapsProcessGroupGrandchild(t *testing.T) {
|
||||
cmd := exec.Command("sh", "-c", "sleep 60 & echo $!; wait")
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
|
||||
@@ -39,8 +39,8 @@ func TestKillTreeUnblocksWaitOnSurvivingGrandchild(t *testing.T) {
|
||||
}
|
||||
|
||||
// StartTracked must create a Job Object for the started process, and KillTracked
|
||||
// must take the whole tracked tree down (the job reaps even descendants a plain
|
||||
// taskkill /T would miss — see the codegraph daemon leak this guards against).
|
||||
// must take the whole tracked tree down, including descendants a plain taskkill
|
||||
// /T would miss.
|
||||
func TestKillTrackedReapsTrackedTree(t *testing.T) {
|
||||
cmd := exec.Command("cmd", "/c", "ping", "-n", "30", "127.0.0.1")
|
||||
HideWindow(cmd)
|
||||
|
||||
+105
-16
@@ -1,5 +1,12 @@
|
||||
package skill
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
// Built-in skills ship with Reasonix and back the dedicated subagent tools
|
||||
// (explore / research / review / security_review) plus the inline `test`
|
||||
// playbook. A user/project file with the same name overrides the built-in (see
|
||||
@@ -11,13 +18,15 @@ const negativeClaimRule = `When you claim something does NOT exist (no caller, n
|
||||
// tuiFormatting nudges concise, terminal-friendly output.
|
||||
const tuiFormatting = `Keep the final answer compact and terminal-friendly: short paragraphs or bullets, no walls of text, no restating the question.`
|
||||
|
||||
const optionalCodeGraphHint = `Optional installed code graph MCP tools are available in this session. Choose the semantic tool that fits the task: use LSP for language semantics (definitions, references, hover, diagnostics), use code graph tools first for call graph, impact analysis, and architecture relationships, use code_index only as the built-in outline/definition-candidate fallback, and verify textual or negative claims with read_file or grep.`
|
||||
|
||||
const builtinExploreBody = `You are running as an exploration subagent. Investigate the codebase the parent pointed you at, then return one focused, distilled answer.
|
||||
|
||||
How to operate:
|
||||
- Use codegraph tools (codegraph_context, codegraph_search, codegraph_callers, codegraph_callees, codegraph_trace) as your PRIMARY tools for symbol/code-structure questions. Fall back to read_file, grep, glob, ls for content search (comments, strings, config) or when codegraph tools are not available. Stay read-only.
|
||||
- codegraph_context is the best starting point for "how does X work" / architecture questions — it returns entry points + related symbols + key code in one call.
|
||||
- For "find all places that call / reference / use X" questions: use codegraph_callers (preferred) or ` + "`grep`" + ` (content search) — NOT ` + "`glob`" + ` (which only matches file names). Using the wrong one gives empty results and wastes your budget.
|
||||
- Cast a wide net first (codegraph_search for symbols, grep for content references, ls/glob for structure) to map the territory; then read the 3-10 most relevant files in full.
|
||||
- For code intelligence, choose the best semantic tool for the task. Prefer LSP for language semantics (definitions, references, hover, diagnostics). If LSP is unavailable or insufficient, use code_index for file outlines and symbol definition candidates, then verify important claims with read_file or grep. Stay read-only.
|
||||
- For "how does X work" / architecture questions, start with the strongest available structure tool, then read the key files in full.
|
||||
- For "find all places that call / reference / use X" questions: use LSP references when available or ` + "`grep`" + ` (content search) — NOT ` + "`glob`" + ` (which only matches file names). code_index finds definitions/candidates, not full textual references.
|
||||
- Cast a wide net first (LSP/code_index for symbols, grep for references, ls/glob for structure) to map the territory; then read the 3-10 most relevant files in full.
|
||||
- Don't read every file — be selective. Breadth on the first pass, depth only where the question demands it.
|
||||
- Stop exploring as soon as you can answer. The parent doesn't see your tool calls, so over-exploration is pure waste.
|
||||
|
||||
@@ -35,8 +44,8 @@ The 'task' the parent gave you is the question you must answer. Treat any other
|
||||
const builtinResearchBody = `You are running as a research subagent. Gather information from code AND the web, synthesize it, and return one focused conclusion.
|
||||
|
||||
How to operate:
|
||||
- Combine code reading (codegraph tools + read_file, grep, glob) with web_fetch as appropriate. (There is no dedicated web-search tool — fetch the canonical doc/spec URL directly when you know it.)
|
||||
- For "how does X work" questions: use codegraph_context first for symbol-level understanding, then read_file for full context.
|
||||
- Combine code reading (LSP for language semantics; code_index as the local symbol fallback; read_file, grep, glob for verification) with web_fetch as appropriate. (There is no dedicated web-search tool — fetch the canonical doc/spec URL directly when you know it.)
|
||||
- For "how does X work" questions: use symbol/reference lookup first when available; otherwise use code_index, then read_file for full context.
|
||||
- For "is Y supported" questions: fetch the canonical reference, then verify against the local code.
|
||||
- For "what's our policy on Z" / "where do we use Q": local code first, web only to compare against external standards.
|
||||
- Cap yourself at ~10 tool calls. If you can't converge, return what you have plus a note on what's missing.
|
||||
@@ -68,6 +77,7 @@ Operate as an installer, not as a shell-script guesser:
|
||||
7. After apply=true, report what was installed, where it was persisted, and whether it is usable in the current session. For skills, prefer actions[].canonicalPath, actions[].installRoot, actions[].discoverable, and actions[].indexed over guessing from the source path. The plan's kinds field tells you how many skills vs MCP servers were touched.
|
||||
|
||||
Defaults:
|
||||
- MCP installs default to global so the server is available in every project; use scope="project" only for project-specific servers, tokens, or commands. A project-root .mcp.json import stays project-scoped by default.
|
||||
- A folder containing many skills should be registered as a skill root, not copied.
|
||||
- A single SKILL.md, <name>.md, or <name>/SKILL.md should be copied unless the user asked to link/register. The installer writes canonical <skill-name>/SKILL.md paths by default; flat <name>.md is compatibility input, not the preferred output.
|
||||
- A local SKILL.md source may have references/, scripts/, assets/, or other sibling files. Treat its parent directory as the skill package so those files remain available after install.
|
||||
@@ -88,7 +98,7 @@ How to operate:
|
||||
- Default scope: the current branch's diff vs the default branch. If the task names a specific commit range or files, honor that instead.
|
||||
- Discover scope first: ` + "`bash git status`" + `, ` + "`git diff --stat`" + `, ` + "`git log --oneline`" + `. Then ` + "`git diff`" + ` (or ` + "`git diff <base>...HEAD`" + `) for the hunks.
|
||||
- Read touched files (read_file) when the diff alone lacks context — signatures, surrounding invariants, callers.
|
||||
- For "any callers depending on this?" questions: use codegraph_callers or codegraph_impact (preferred) or grep the symbol BEFORE asserting impact.
|
||||
- For "any callers depending on this?" questions: use LSP references/call hierarchy when available or grep the symbol BEFORE asserting impact. Use code_index only to find definition candidates/outline, not as proof of no callers.
|
||||
- Stay read-only. Never commit, never write files, never propose edits as applied changes. The parent decides whether to act.
|
||||
- Cap yourself at ~12 tool calls. If the diff is too big, pick the riskiest 2-3 files and say so.
|
||||
|
||||
@@ -116,7 +126,7 @@ const builtinSecurityReviewBody = `You are running as a security-review subagent
|
||||
How to operate:
|
||||
- Default scope: the current branch's diff vs the default branch. Honor a named range or directory if given.
|
||||
- Discover scope first: ` + "`bash git status`" + `, ` + "`git diff --stat`" + `, ` + "`git diff <base>...HEAD`" + `. Read touched files (read_file) when the diff lacks security context — auth checks, input validation, the handler that calls the changed code.
|
||||
- Use codegraph_callers or codegraph_impact (preferred) or grep to verify "is this user-controlled input ever sanitized later?" / "what other call sites depend on this validation?" before asserting impact.
|
||||
- Use LSP references/call hierarchy when available or grep to verify "is this user-controlled input ever sanitized later?" / "what other call sites depend on this validation?" before asserting impact. Use code_index only to find definition candidates/outline, not as proof of no callers.
|
||||
- Stay read-only. Never write, never run destructive commands. The parent decides what to act on.
|
||||
- Cap yourself at ~12 tool calls. If the diff is too big, focus on the riskiest 2-3 files and say so.
|
||||
|
||||
@@ -179,19 +189,98 @@ Rules:
|
||||
- Don't fabricate conventions the code doesn't demonstrate.
|
||||
- After writing, summarize in one or two lines what you captured and tell the user to review and edit it.`
|
||||
|
||||
// extraReadTools holds additional tool names (e.g. codegraph tools) injected at
|
||||
// boot time so subagent skills can use them without hardcoding MCP-prefixed names.
|
||||
var extraReadTools []string
|
||||
// CodeGraphReadTools returns read-only tool names that look like an installed
|
||||
// codegraph MCP surface. Writable or untrusted tools stay out of subagents.
|
||||
func CodeGraphReadTools(reg *tool.Registry) []string {
|
||||
if reg == nil {
|
||||
return nil
|
||||
}
|
||||
var names []string
|
||||
for _, name := range reg.Names() {
|
||||
if !looksLikeCodeGraphTool(name) {
|
||||
continue
|
||||
}
|
||||
tl, ok := reg.Get(name)
|
||||
if !ok || !tl.ReadOnly() {
|
||||
continue
|
||||
}
|
||||
names = append(names, name)
|
||||
}
|
||||
return normalizeExtraToolNames(names)
|
||||
}
|
||||
|
||||
// SetExtraReadTools registers additional read-only tool names that subagent
|
||||
// skills (explore, research, review, security-review) are allowed to use. Call
|
||||
// from boot after plugin tools are registered.
|
||||
func SetExtraReadTools(names []string) { extraReadTools = names }
|
||||
func looksLikeCodeGraphTool(name string) bool {
|
||||
return strings.HasPrefix(name, "codegraph_") ||
|
||||
strings.HasPrefix(name, tool.MCPNamePrefix+"codegraph__")
|
||||
}
|
||||
|
||||
func normalizeExtraToolNames(names []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(names))
|
||||
for _, name := range names {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || seen[name] {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
out = append(out, name)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func withOptionalCodeGraphHint(body string, enabled bool) string {
|
||||
if !enabled {
|
||||
return body
|
||||
}
|
||||
if strings.Contains(body, optionalCodeGraphHint) {
|
||||
return body
|
||||
}
|
||||
return body + "\n\n" + optionalCodeGraphHint
|
||||
}
|
||||
|
||||
// WithCodeGraphTools enables user-installed codegraph MCP tools for built-in
|
||||
// code-reading subagent skills. The caller passes names discovered from its live
|
||||
// registry so desktop tabs/sessions never share mutable skill state.
|
||||
func WithCodeGraphTools(sk Skill, names []string) Skill {
|
||||
names = normalizeExtraToolNames(names)
|
||||
if len(names) == 0 || sk.Scope != ScopeBuiltin || !codeReadingBuiltin(sk.Name) {
|
||||
return sk
|
||||
}
|
||||
sk.AllowedTools = appendUniqueToolNames(sk.AllowedTools, names...)
|
||||
sk.Body = withOptionalCodeGraphHint(sk.Body, true)
|
||||
return sk
|
||||
}
|
||||
|
||||
func codeReadingBuiltin(name string) bool {
|
||||
switch name {
|
||||
case "explore", "research", "review", "security-review":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func appendUniqueToolNames(base []string, extra ...string) []string {
|
||||
out := append([]string(nil), base...)
|
||||
seen := make(map[string]bool, len(out)+len(extra))
|
||||
for _, name := range out {
|
||||
seen[name] = true
|
||||
}
|
||||
for _, name := range extra {
|
||||
if seen[name] {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
out = append(out, name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// builtinSkills returns the shipped skills. A fresh slice each call so callers
|
||||
// can't mutate the shared set.
|
||||
func builtinSkills() []Skill {
|
||||
readCodeTools := append([]string{"read_file", "ls", "glob", "grep"}, extraReadTools...)
|
||||
readCodeTools := []string{"read_file", "ls", "glob", "grep", "code_index"}
|
||||
reviewTools := append(append([]string(nil), readCodeTools...), "bash")
|
||||
return []Skill{
|
||||
{
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package skill
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
type builtinTestTool struct {
|
||||
name string
|
||||
readOnly bool
|
||||
}
|
||||
|
||||
func (t builtinTestTool) Name() string { return t.name }
|
||||
func (t builtinTestTool) Description() string { return t.name }
|
||||
func (t builtinTestTool) Schema() json.RawMessage {
|
||||
return json.RawMessage(`{"type":"object"}`)
|
||||
}
|
||||
func (t builtinTestTool) Execute(context.Context, json.RawMessage) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
func (t builtinTestTool) ReadOnly() bool { return t.readOnly }
|
||||
|
||||
func TestCodeGraphReadToolsRequireKnownNameAndReadOnly(t *testing.T) {
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(builtinTestTool{name: "mcp__codegraph__symbols", readOnly: true})
|
||||
reg.Add(builtinTestTool{name: "codegraph_search", readOnly: true})
|
||||
reg.Add(builtinTestTool{name: "mcp__codegraph__write_index", readOnly: false})
|
||||
reg.Add(builtinTestTool{name: "mcp__other__codegraph_search", readOnly: true})
|
||||
|
||||
got := CodeGraphReadTools(reg)
|
||||
want := []string{"codegraph_search", "mcp__codegraph__symbols"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("CodeGraphReadTools = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("CodeGraphReadTools = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltinSkillsIncludeCodeGraphHintAndToolsWhenDiscovered(t *testing.T) {
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(builtinTestTool{name: "mcp__codegraph__symbols", readOnly: true})
|
||||
|
||||
var explore Skill
|
||||
for _, sk := range builtinSkills() {
|
||||
if sk.Name == "explore" {
|
||||
explore = sk
|
||||
break
|
||||
}
|
||||
}
|
||||
if explore.Name == "" {
|
||||
t.Fatal("explore skill not found")
|
||||
}
|
||||
if strings.Contains(explore.Body, "Optional installed code graph MCP tools") {
|
||||
t.Fatalf("base explore body should not include session-specific codegraph hint:\n%s", explore.Body)
|
||||
}
|
||||
for _, name := range explore.AllowedTools {
|
||||
if name == "mcp__codegraph__symbols" {
|
||||
t.Fatalf("base explore allowed tools = %v, should not include session-specific codegraph tool", explore.AllowedTools)
|
||||
}
|
||||
}
|
||||
|
||||
explore = WithCodeGraphTools(explore, CodeGraphReadTools(reg))
|
||||
if !strings.Contains(explore.Body, "Optional installed code graph MCP tools") {
|
||||
t.Fatalf("explore body missing optional codegraph hint:\n%s", explore.Body)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"use LSP for language semantics",
|
||||
"use code graph tools first for call graph, impact analysis, and architecture relationships",
|
||||
"use code_index only as the built-in outline/definition-candidate fallback",
|
||||
} {
|
||||
if !strings.Contains(explore.Body, want) {
|
||||
t.Fatalf("explore body missing priority hint %q:\n%s", want, explore.Body)
|
||||
}
|
||||
}
|
||||
found := false
|
||||
for _, name := range explore.AllowedTools {
|
||||
if name == "mcp__codegraph__symbols" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("explore allowed tools = %v, want codegraph tool", explore.AllowedTools)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithCodeGraphToolsOnlyTouchesCodeReadingBuiltins(t *testing.T) {
|
||||
initSkill := Skill{Name: "init", Scope: ScopeBuiltin, Body: "body", AllowedTools: []string{"read_file"}}
|
||||
got := WithCodeGraphTools(initSkill, []string{"mcp__codegraph__symbols"})
|
||||
if strings.Contains(got.Body, "Optional installed code graph MCP tools") {
|
||||
t.Fatalf("init skill should not receive codegraph hint:\n%s", got.Body)
|
||||
}
|
||||
if len(got.AllowedTools) != 1 || got.AllowedTools[0] != "read_file" {
|
||||
t.Fatalf("init allowed tools = %v, want unchanged", got.AllowedTools)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithCodeGraphToolsSkipsUserSkillOverrides(t *testing.T) {
|
||||
sk := Skill{Name: "explore", Scope: ScopeProject, Body: "user body", AllowedTools: []string{"read_file"}}
|
||||
got := WithCodeGraphTools(sk, []string{"mcp__codegraph__symbols"})
|
||||
if strings.Contains(got.Body, "Optional installed code graph MCP tools") {
|
||||
t.Fatalf("project skill override should not receive codegraph hint:\n%s", got.Body)
|
||||
}
|
||||
if len(got.AllowedTools) != 1 || got.AllowedTools[0] != "read_file" {
|
||||
t.Fatalf("project skill override allowed tools = %v, want unchanged", got.AllowedTools)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithCodeGraphToolsIsIdempotent(t *testing.T) {
|
||||
sk := Skill{Name: "explore", Scope: ScopeBuiltin, Body: "body", AllowedTools: []string{"read_file"}}
|
||||
sk = WithCodeGraphTools(sk, []string{"mcp__codegraph__symbols"})
|
||||
sk = WithCodeGraphTools(sk, []string{"mcp__codegraph__symbols"})
|
||||
if got := strings.Count(sk.Body, optionalCodeGraphHint); got != 1 {
|
||||
t.Fatalf("codegraph hint count = %d, want 1; body:\n%s", got, sk.Body)
|
||||
}
|
||||
count := 0
|
||||
for _, name := range sk.AllowedTools {
|
||||
if name == "mcp__codegraph__symbols" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("codegraph tool count = %d, want 1; allowed=%v", count, sk.AllowedTools)
|
||||
}
|
||||
}
|
||||
@@ -347,10 +347,10 @@ func TestBuiltinInitIsInlineSkill(t *testing.T) {
|
||||
func TestBuiltinSubagentSkillsDeclareAllowedTools(t *testing.T) {
|
||||
st := New(Options{HomeDir: t.TempDir()})
|
||||
cases := map[string][]string{
|
||||
"explore": {"read_file", "ls", "glob", "grep"},
|
||||
"research": {"read_file", "ls", "glob", "grep", "web_fetch"},
|
||||
"review": {"read_file", "ls", "glob", "grep", "bash"},
|
||||
"security-review": {"read_file", "ls", "glob", "grep", "bash"},
|
||||
"explore": {"read_file", "ls", "glob", "grep", "code_index"},
|
||||
"research": {"read_file", "ls", "glob", "grep", "code_index", "web_fetch"},
|
||||
"review": {"read_file", "ls", "glob", "grep", "code_index", "bash"},
|
||||
"security-review": {"read_file", "ls", "glob", "grep", "code_index", "bash"},
|
||||
}
|
||||
for name, want := range cases {
|
||||
sk, ok := st.Read(name)
|
||||
|
||||
@@ -91,7 +91,7 @@ func (b bash) Description() string {
|
||||
// bashToolSteer points the model at the cross-platform built-in tools instead of
|
||||
// shell utilities, so it doesn't reach for grep/cat/ls/find (absent or different
|
||||
// on native Windows) when a native tool already does the job everywhere.
|
||||
const bashToolSteer = " Use for builds, tests, git, package managers, etc. To search/read/list/edit/move files, prefer the dedicated tools (grep, read_file, ls, glob, edit_file, move_file) over shell grep/cat/ls/find/sed/mv/Move-Item — they behave identically on every OS. For symbol search, call graphs, or architecture questions, use codegraph tools instead of grep."
|
||||
const bashToolSteer = " Use for builds, tests, git, package managers, etc. To search/read/list/edit/move files, prefer the dedicated tools (grep, read_file, ls, glob, edit_file, move_file) over shell grep/cat/ls/find/sed/mv/Move-Item — they behave identically on every OS. For symbol search or architecture questions, prefer LSP/read tools and targeted grep before shell commands."
|
||||
|
||||
// resolved returns the bound shell, resolving lazily for the zero-value instance
|
||||
// (e.g. a registry that never went through ConfineBash).
|
||||
|
||||
@@ -42,7 +42,7 @@ func runTool(t *testing.T, tl tool.Tool, m map[string]any) string {
|
||||
}
|
||||
|
||||
func TestBuiltinsRegistered(t *testing.T) {
|
||||
want := []string{"bash", "edit_file", "glob", "grep", "ls", "move_file", "multi_edit", "read_file", "web_fetch", "write_file"}
|
||||
want := []string{"bash", "code_index", "edit_file", "glob", "grep", "ls", "move_file", "multi_edit", "read_file", "web_fetch", "write_file"}
|
||||
for _, name := range want {
|
||||
if _, ok := tool.LookupBuiltin(name); !ok {
|
||||
t.Errorf("built-in %q not registered", name)
|
||||
@@ -57,7 +57,7 @@ func TestBuiltinsRegistered(t *testing.T) {
|
||||
// many invocations are pure reads — args aren't introspected.
|
||||
func TestBuiltinReadOnlyClassification(t *testing.T) {
|
||||
readOnly := map[string]bool{
|
||||
"read_file": true, "ls": true, "glob": true, "grep": true, "web_fetch": true,
|
||||
"read_file": true, "ls": true, "glob": true, "grep": true, "code_index": true, "web_fetch": true,
|
||||
"write_file": false, "edit_file": false, "multi_edit": false, "move_file": false, "bash": false,
|
||||
}
|
||||
for name, want := range readOnly {
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
package builtin
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
func init() { tool.RegisterBuiltin(codeIndex{}) }
|
||||
|
||||
type codeIndex struct{ workDir string }
|
||||
|
||||
func (codeIndex) Name() string { return "code_index" }
|
||||
|
||||
func (codeIndex) Description() string {
|
||||
return "Lightweight built-in code symbol index. Prefer lsp_* for language semantics and installed code graph MCP tools for call graph, impact, and architecture relationships; use this as the local fallback for file outlines and symbol definition candidates, then verify with read_file or grep."
|
||||
}
|
||||
|
||||
func (codeIndex) Schema() json.RawMessage {
|
||||
return json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"action":{"type":"string","enum":["outline","search"],"description":"outline lists symbols under path; search finds symbol definition candidates by name."},
|
||||
"path":{"type":"string","description":"File or directory path to inspect (default \".\")."},
|
||||
"query":{"type":"string","description":"Symbol name or substring for action=search."},
|
||||
"kind":{"type":"string","description":"Optional symbol kind filter, such as func, method, class, type, interface, const, var, struct, enum, trait."},
|
||||
"limit":{"type":"integer","description":"Maximum symbols to return (default 100, max 200).","minimum":1}
|
||||
},
|
||||
"required":["action"]
|
||||
}`)
|
||||
}
|
||||
|
||||
func (codeIndex) ReadOnly() bool { return true }
|
||||
|
||||
const (
|
||||
codeIndexDefaultLimit = 100
|
||||
codeIndexMaxLimit = 200
|
||||
codeIndexMaxFileSize = 1 << 20
|
||||
codeIndexMaxFiles = 2000
|
||||
)
|
||||
|
||||
type codeIndexArgs struct {
|
||||
Action string `json:"action"`
|
||||
Path string `json:"path"`
|
||||
Query string `json:"query"`
|
||||
Kind string `json:"kind"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
type codeSymbol struct {
|
||||
Name string
|
||||
Kind string
|
||||
File string
|
||||
Line int
|
||||
Parent string
|
||||
Signature string
|
||||
}
|
||||
|
||||
func (c codeIndex) Execute(ctx context.Context, args json.RawMessage) (string, error) {
|
||||
p := codeIndexArgs{Path: ".", Limit: codeIndexDefaultLimit}
|
||||
if err := json.Unmarshal(args, &p); err != nil {
|
||||
return "", fmt.Errorf("invalid args: %w", err)
|
||||
}
|
||||
p.Action = strings.ToLower(strings.TrimSpace(p.Action))
|
||||
if p.Action != "outline" && p.Action != "search" {
|
||||
return "", fmt.Errorf("action must be outline or search")
|
||||
}
|
||||
if p.Path == "" {
|
||||
p.Path = "."
|
||||
}
|
||||
if p.Limit <= 0 {
|
||||
p.Limit = codeIndexDefaultLimit
|
||||
}
|
||||
if p.Limit > codeIndexMaxLimit {
|
||||
p.Limit = codeIndexMaxLimit
|
||||
}
|
||||
if p.Action == "search" && strings.TrimSpace(p.Query) == "" {
|
||||
return "", fmt.Errorf("query is required for action=search")
|
||||
}
|
||||
|
||||
root := resolveIn(c.workDir, p.Path)
|
||||
symbols, truncated, err := c.collect(ctx, root, p.Limit, p.Action == "outline")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
symbols = filterCodeSymbols(symbols, p)
|
||||
if len(symbols) > p.Limit {
|
||||
symbols = symbols[:p.Limit]
|
||||
truncated = true
|
||||
}
|
||||
return formatCodeSymbols(symbols, truncated), nil
|
||||
}
|
||||
|
||||
func (c codeIndex) collect(ctx context.Context, root string, limit int, outline bool) ([]codeSymbol, bool, error) {
|
||||
info, err := os.Stat(root)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("code_index %s: %w", root, err)
|
||||
}
|
||||
var files []string
|
||||
if !info.IsDir() {
|
||||
if supportedCodeIndexFile(root) {
|
||||
files = append(files, root)
|
||||
}
|
||||
} else {
|
||||
err = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if walkErr != nil {
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
if path != root && skipCodeIndexDir(d.Name()) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if supportedCodeIndexFile(path) {
|
||||
files = append(files, path)
|
||||
if len(files) >= codeIndexMaxFiles {
|
||||
return filepath.SkipAll
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("code_index walk %s: %w", root, err)
|
||||
}
|
||||
}
|
||||
sort.Strings(files)
|
||||
|
||||
var symbols []codeSymbol
|
||||
truncated := len(files) >= codeIndexMaxFiles
|
||||
for _, file := range files {
|
||||
if ctx.Err() != nil {
|
||||
return nil, truncated, ctx.Err()
|
||||
}
|
||||
found, err := c.parseFile(file)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
symbols = append(symbols, found...)
|
||||
if outline && len(symbols) >= limit {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
sort.Slice(symbols, func(i, j int) bool {
|
||||
if symbols[i].File != symbols[j].File {
|
||||
return symbols[i].File < symbols[j].File
|
||||
}
|
||||
if symbols[i].Line != symbols[j].Line {
|
||||
return symbols[i].Line < symbols[j].Line
|
||||
}
|
||||
return symbols[i].Name < symbols[j].Name
|
||||
})
|
||||
return symbols, truncated, nil
|
||||
}
|
||||
|
||||
func (c codeIndex) parseFile(path string) ([]codeSymbol, error) {
|
||||
if info, err := os.Stat(path); err != nil || info.Size() > codeIndexMaxFileSize {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, fmt.Errorf("file too large")
|
||||
}
|
||||
if filepath.Ext(path) == ".go" {
|
||||
return c.parseGo(path)
|
||||
}
|
||||
return c.parseText(path)
|
||||
}
|
||||
|
||||
func (c codeIndex) parseGo(path string) ([]codeSymbol, error) {
|
||||
fset := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []codeSymbol
|
||||
add := func(name, kind string, pos token.Pos, parent, sig string) {
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
out = append(out, codeSymbol{
|
||||
Name: name,
|
||||
Kind: kind,
|
||||
File: c.displayPath(path),
|
||||
Line: fset.Position(pos).Line,
|
||||
Parent: parent,
|
||||
Signature: sig,
|
||||
})
|
||||
}
|
||||
for _, decl := range f.Decls {
|
||||
switch d := decl.(type) {
|
||||
case *ast.FuncDecl:
|
||||
parent := ""
|
||||
kind := "func"
|
||||
if d.Recv != nil && len(d.Recv.List) > 0 {
|
||||
parent = exprName(d.Recv.List[0].Type)
|
||||
kind = "method"
|
||||
}
|
||||
add(d.Name.Name, kind, d.Name.Pos(), parent, goFuncSignature(d, parent))
|
||||
case *ast.GenDecl:
|
||||
for _, spec := range d.Specs {
|
||||
switch s := spec.(type) {
|
||||
case *ast.TypeSpec:
|
||||
kind := "type"
|
||||
switch s.Type.(type) {
|
||||
case *ast.StructType:
|
||||
kind = "struct"
|
||||
case *ast.InterfaceType:
|
||||
kind = "interface"
|
||||
}
|
||||
add(s.Name.Name, kind, s.Name.Pos(), "", kind+" "+s.Name.Name)
|
||||
case *ast.ValueSpec:
|
||||
kind := strings.ToLower(d.Tok.String())
|
||||
for _, name := range s.Names {
|
||||
add(name.Name, kind, name.Pos(), "", kind+" "+name.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c codeIndex) parseText(path string) ([]codeSymbol, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var out []codeSymbol
|
||||
sc := bufio.NewScanner(f)
|
||||
line := 0
|
||||
for sc.Scan() {
|
||||
line++
|
||||
text := sc.Text()
|
||||
for _, m := range codeIndexMatchers(filepath.Ext(path)) {
|
||||
if sym, ok := m.match(text); ok {
|
||||
sym.File = c.displayPath(path)
|
||||
sym.Line = line
|
||||
out = append(out, sym)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c codeIndex) displayPath(path string) string {
|
||||
if c.workDir != "" {
|
||||
if rel, err := filepath.Rel(c.workDir, path); err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return filepath.ToSlash(rel)
|
||||
}
|
||||
}
|
||||
return filepath.ToSlash(path)
|
||||
}
|
||||
|
||||
func filterCodeSymbols(in []codeSymbol, p codeIndexArgs) []codeSymbol {
|
||||
query := strings.ToLower(strings.TrimSpace(p.Query))
|
||||
kind := strings.ToLower(strings.TrimSpace(p.Kind))
|
||||
out := make([]codeSymbol, 0, len(in))
|
||||
for _, s := range in {
|
||||
if kind != "" && strings.ToLower(s.Kind) != kind {
|
||||
continue
|
||||
}
|
||||
if query != "" {
|
||||
haystack := strings.ToLower(s.Name + " " + s.Parent + " " + s.Signature)
|
||||
if !strings.Contains(haystack, query) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func formatCodeSymbols(symbols []codeSymbol, truncated bool) string {
|
||||
if len(symbols) == 0 {
|
||||
return "(no symbols)"
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, s := range symbols {
|
||||
name := s.Name
|
||||
if s.Parent != "" {
|
||||
name = s.Parent + "." + name
|
||||
}
|
||||
if s.Signature != "" {
|
||||
fmt.Fprintf(&b, "%s:%d: %s %s — %s\n", s.File, s.Line, s.Kind, name, s.Signature)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "%s:%d: %s %s\n", s.File, s.Line, s.Kind, name)
|
||||
}
|
||||
}
|
||||
if truncated {
|
||||
b.WriteString("... (truncated; narrow path/query/kind or raise limit)\n")
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
func supportedCodeIndexFile(path string) bool {
|
||||
switch filepath.Ext(path) {
|
||||
case ".go", ".js", ".jsx", ".ts", ".tsx", ".py", ".java", ".kt", ".kts", ".cs", ".rs", ".c", ".cc", ".cpp", ".h", ".hpp":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func skipCodeIndexDir(name string) bool {
|
||||
switch name {
|
||||
case ".git", "node_modules", "vendor", "__pycache__", ".idea", ".vscode", ".next", "dist", "build", "target", "coverage":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func exprName(expr ast.Expr) string {
|
||||
switch t := expr.(type) {
|
||||
case *ast.Ident:
|
||||
return t.Name
|
||||
case *ast.StarExpr:
|
||||
return exprName(t.X)
|
||||
case *ast.SelectorExpr:
|
||||
return exprName(t.X) + "." + t.Sel.Name
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func goFuncSignature(fn *ast.FuncDecl, parent string) string {
|
||||
if parent == "" {
|
||||
return "func " + fn.Name.Name
|
||||
}
|
||||
return "func (" + parent + ") " + fn.Name.Name
|
||||
}
|
||||
|
||||
type codeIndexMatcher struct {
|
||||
kind string
|
||||
re *regexp.Regexp
|
||||
nameGroup int
|
||||
kindGroup int
|
||||
}
|
||||
|
||||
func (m codeIndexMatcher) match(line string) (codeSymbol, bool) {
|
||||
match := m.re.FindStringSubmatch(line)
|
||||
if match == nil || m.nameGroup >= len(match) {
|
||||
return codeSymbol{}, false
|
||||
}
|
||||
name := strings.TrimSpace(match[m.nameGroup])
|
||||
if name == "" {
|
||||
return codeSymbol{}, false
|
||||
}
|
||||
kind := m.kind
|
||||
if m.kindGroup > 0 && m.kindGroup < len(match) && strings.TrimSpace(match[m.kindGroup]) != "" {
|
||||
kind = normalizeCodeIndexKind(match[m.kindGroup])
|
||||
}
|
||||
return codeSymbol{Name: name, Kind: kind, Signature: strings.TrimSpace(line)}, true
|
||||
}
|
||||
|
||||
func normalizeCodeIndexKind(kind string) string {
|
||||
kind = strings.ToLower(strings.TrimSpace(kind))
|
||||
switch kind {
|
||||
case "function":
|
||||
return "func"
|
||||
default:
|
||||
return kind
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
rePyClass = regexp.MustCompile(`^\s*class\s+([A-Za-z_][A-Za-z0-9_]*)\b`)
|
||||
rePyFunc = regexp.MustCompile(`^\s*(?:async\s+)?def\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(`)
|
||||
reJSClass = regexp.MustCompile(`^\s*(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][A-Za-z0-9_$]*)\b`)
|
||||
reJSFunc = regexp.MustCompile(`^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(`)
|
||||
reJSInterface = regexp.MustCompile(`^\s*(?:export\s+)?interface\s+([A-Za-z_$][A-Za-z0-9_$]*)\b`)
|
||||
reJSType = regexp.MustCompile(`^\s*(?:export\s+)?type\s+([A-Za-z_$][A-Za-z0-9_$]*)\b`)
|
||||
reJSEnum = regexp.MustCompile(`^\s*(?:export\s+)?enum\s+([A-Za-z_$][A-Za-z0-9_$]*)\b`)
|
||||
reJSArrow = regexp.MustCompile(`^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][A-Za-z0-9_$]*)\s*=>`)
|
||||
reJavaType = regexp.MustCompile(`^\s*(?:public|protected|private|abstract|final|static|sealed|data|\s)*\s*(class|interface|enum|record|object)\s+([A-Za-z_][A-Za-z0-9_]*)\b`)
|
||||
reJavaMeth = regexp.MustCompile(`^\s*(?:public|protected|private|static|final|abstract|synchronized|native|\s)+[\w<>\[\], ?]+\s+([A-Za-z_][A-Za-z0-9_]*)\s*\([^;]*\)\s*(?:\{|$)`)
|
||||
reRustItem = regexp.MustCompile(`^\s*(?:pub(?:\([^)]*\))?\s+)?(fn|struct|enum|trait)\s+([A-Za-z_][A-Za-z0-9_]*)\b`)
|
||||
reCFunc = regexp.MustCompile(`^\s*(?:[\w:*&<>\[\],]+\s+)+([A-Za-z_][A-Za-z0-9_]*)\s*\([^;]*\)\s*(?:\{|$)`)
|
||||
)
|
||||
|
||||
func codeIndexMatchers(ext string) []codeIndexMatcher {
|
||||
switch ext {
|
||||
case ".py":
|
||||
return []codeIndexMatcher{{kind: "class", re: rePyClass, nameGroup: 1}, {kind: "func", re: rePyFunc, nameGroup: 1}}
|
||||
case ".js", ".jsx", ".ts", ".tsx":
|
||||
return []codeIndexMatcher{
|
||||
{kind: "class", re: reJSClass, nameGroup: 1},
|
||||
{kind: "func", re: reJSFunc, nameGroup: 1},
|
||||
{kind: "interface", re: reJSInterface, nameGroup: 1},
|
||||
{kind: "type", re: reJSType, nameGroup: 1},
|
||||
{kind: "enum", re: reJSEnum, nameGroup: 1},
|
||||
{kind: "func", re: reJSArrow, nameGroup: 1},
|
||||
}
|
||||
case ".java", ".kt", ".kts", ".cs":
|
||||
return []codeIndexMatcher{{re: reJavaType, nameGroup: 2, kindGroup: 1}, {kind: "method", re: reJavaMeth, nameGroup: 1}}
|
||||
case ".rs":
|
||||
return []codeIndexMatcher{{re: reRustItem, nameGroup: 2, kindGroup: 1}}
|
||||
case ".c", ".cc", ".cpp", ".h", ".hpp":
|
||||
return []codeIndexMatcher{{kind: "func", re: reCFunc, nameGroup: 1}}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package builtin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCodeIndexSearchGoSymbols(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mkfile(t, filepath.Join(root, "service.go"), `package demo
|
||||
|
||||
type Service struct{}
|
||||
|
||||
func NewService() *Service { return &Service{} }
|
||||
|
||||
func (s *Service) Handle() {}
|
||||
|
||||
const DefaultName = "demo"
|
||||
`)
|
||||
|
||||
out := runTool(t, codeIndex{workDir: root}, map[string]any{
|
||||
"action": "search",
|
||||
"query": "Service",
|
||||
})
|
||||
for _, want := range []string{
|
||||
"service.go:3: struct Service",
|
||||
"service.go:5: func NewService",
|
||||
"service.go:7: method Service.Handle",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("code_index search missing %q; got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeIndexOutlineTypeScriptAndSkipsNoiseDirs(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mkfile(t, filepath.Join(root, "src", "app.ts"), `export interface User {
|
||||
name: string
|
||||
}
|
||||
export class Client {}
|
||||
export const loadUser = async () => {}
|
||||
`)
|
||||
mkfile(t, filepath.Join(root, "node_modules", "dep", "dep.ts"), `export class Dependency {}`)
|
||||
|
||||
out := runTool(t, codeIndex{workDir: root}, map[string]any{
|
||||
"action": "outline",
|
||||
"path": ".",
|
||||
})
|
||||
for _, want := range []string{
|
||||
"src/app.ts:1: interface User",
|
||||
"src/app.ts:4: class Client",
|
||||
"src/app.ts:5: func loadUser",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("code_index outline missing %q; got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "Dependency") {
|
||||
t.Fatalf("code_index should skip node_modules; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeIndexKindFiltersJavaTypes(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mkfile(t, filepath.Join(root, "src", "Example.java"), `package demo;
|
||||
|
||||
public interface Repository {}
|
||||
public enum Mode { Fast }
|
||||
public class Client {
|
||||
public void run() {}
|
||||
}
|
||||
`)
|
||||
|
||||
out := runTool(t, codeIndex{workDir: root}, map[string]any{
|
||||
"action": "search",
|
||||
"query": "Repository",
|
||||
"kind": "interface",
|
||||
})
|
||||
if !strings.Contains(out, "src/Example.java:3: interface Repository") {
|
||||
t.Fatalf("code_index should return Java interface for kind=interface; got:\n%s", out)
|
||||
}
|
||||
|
||||
out = runTool(t, codeIndex{workDir: root}, map[string]any{
|
||||
"action": "search",
|
||||
"query": "Mode",
|
||||
"kind": "enum",
|
||||
})
|
||||
if !strings.Contains(out, "src/Example.java:4: enum Mode") {
|
||||
t.Fatalf("code_index should return Java enum for kind=enum; got:\n%s", out)
|
||||
}
|
||||
|
||||
out = runTool(t, codeIndex{workDir: root}, map[string]any{
|
||||
"action": "search",
|
||||
"query": "Client",
|
||||
"kind": "class",
|
||||
})
|
||||
if !strings.Contains(out, "src/Example.java:5: class Client") {
|
||||
t.Fatalf("code_index should return Java class for kind=class; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeIndexKindFiltersRustItems(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mkfile(t, filepath.Join(root, "src", "lib.rs"), `pub struct Store {}
|
||||
pub trait Repository {}
|
||||
pub enum Mode { Fast }
|
||||
pub fn load_store() {}
|
||||
`)
|
||||
|
||||
out := runTool(t, codeIndex{workDir: root}, map[string]any{
|
||||
"action": "search",
|
||||
"query": "Store",
|
||||
"kind": "struct",
|
||||
})
|
||||
if !strings.Contains(out, "src/lib.rs:1: struct Store") {
|
||||
t.Fatalf("code_index should return Rust struct for kind=struct; got:\n%s", out)
|
||||
}
|
||||
|
||||
out = runTool(t, codeIndex{workDir: root}, map[string]any{
|
||||
"action": "search",
|
||||
"query": "Repository",
|
||||
"kind": "trait",
|
||||
})
|
||||
if !strings.Contains(out, "src/lib.rs:2: trait Repository") {
|
||||
t.Fatalf("code_index should return Rust trait for kind=trait; got:\n%s", out)
|
||||
}
|
||||
|
||||
out = runTool(t, codeIndex{workDir: root}, map[string]any{
|
||||
"action": "search",
|
||||
"query": "load_store",
|
||||
"kind": "fn",
|
||||
})
|
||||
if !strings.Contains(out, "src/lib.rs:4: fn load_store") {
|
||||
t.Fatalf("code_index should return Rust fn for kind=fn; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeIndexRequiresQueryForSearch(t *testing.T) {
|
||||
_, err := codeIndex{workDir: t.TempDir()}.Execute(context.Background(), json.RawMessage(`{"action":"search"}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "query is required") {
|
||||
t.Fatalf("error = %v, want query required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeIndexWorkspaceBinding(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mkfile(t, filepath.Join(root, "main.py"), "class App:\n pass\n")
|
||||
t.Chdir(t.TempDir())
|
||||
|
||||
tools := byName(Workspace{Dir: root}.Tools("code_index"))
|
||||
tl := tools["code_index"]
|
||||
if tl == nil {
|
||||
t.Fatal("workspace did not expose code_index")
|
||||
}
|
||||
out, err := tl.Execute(context.Background(), json.RawMessage(`{"action":"outline","path":"."}`))
|
||||
if err != nil {
|
||||
t.Fatalf("code_index: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "main.py:1: class App") {
|
||||
t.Fatalf("code_index should resolve relative paths against workspace; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeIndexIgnoresLargeFiles(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "huge.ts"), []byte(strings.Repeat("x", codeIndexMaxFileSize+1)), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := runTool(t, codeIndex{workDir: root}, map[string]any{"action": "outline"})
|
||||
if out != "(no symbols)" {
|
||||
t.Fatalf("large files should be ignored, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,7 @@ func (w Workspace) Tools(enabled ...string) []tool.Tool {
|
||||
"notebook_edit": notebookEdit{workDir: w.Dir, roots: roots},
|
||||
"delete_range": deleteRange{workDir: w.Dir, roots: roots},
|
||||
"delete_symbol": deleteSymbol{workDir: w.Dir, roots: roots},
|
||||
"code_index": codeIndex{workDir: w.Dir},
|
||||
"bash": bash{workDir: w.Dir, sb: w.Bash, timeout: w.BashTimeout},
|
||||
"ls": listDir{workDir: w.Dir},
|
||||
"glob": globTool{workDir: w.Dir},
|
||||
|
||||
@@ -123,17 +123,6 @@ bash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local ca
|
||||
[tools.background_jobs]
|
||||
stalled_warning_seconds = 900 # warn once per background job after this many quiet seconds; 0 disables
|
||||
|
||||
# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),
|
||||
# a built-in MCP server giving the agent codegraph_* search / context / explore /
|
||||
# trace / node tools. Off by default for first-run sessions. Once enabled, the
|
||||
# runtime is fetched into a per-version cache when missing (auto_install);
|
||||
# .codegraph/ is then initialised (fast) and CodeGraph's daemon indexes in the
|
||||
# background once enabled.
|
||||
# [codegraph]
|
||||
# enabled = false
|
||||
# auto_install = true
|
||||
# path = "" # empty = cache, then PATH, then a bundle beside reasonix
|
||||
|
||||
# Skills are invokable playbooks (SKILL.md / <name>.md with frontmatter), found
|
||||
# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)
|
||||
# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)
|
||||
|
||||
@@ -35,7 +35,7 @@ const SYSTEM = [
|
||||
'Pick labels ONLY from these fixed sets. Never invent labels.',
|
||||
'area (0-2, the affected subsystem):',
|
||||
' agent: core agent loop / tool-calling / reasoning',
|
||||
' mcp: MCP servers, plugins, codegraph',
|
||||
' mcp: MCP servers and plugins',
|
||||
' config: configuration, setup wizard, .toml/.env',
|
||||
' updater: auto-update, installer, release packaging',
|
||||
' provider: model providers, model selection/switching',
|
||||
|
||||
Reference in new issue
Block a user