Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,17 @@ Point it at a codebase. It parses the code, discovers the structure, and lets yo
```bash
git clone https://github.com/agentic-research/mache.git
cd mache && task build && task install
mache serve . # ← long-running daemon on :7532 — keep it running (use a 2nd terminal)
claude mcp add --transport http mache http://localhost:7532/mcp
mache init --global # installs the keepalive HTTP daemon (:7532) + registers detected editors
```

That's the 30-second path for a **Go** codebase. Two things new users hit:
That's the 30-second path for a **Go** codebase. `mache init --global` installs a per-user supervisor (launchd on macOS, systemd `--user` on Linux) that keeps the shared mache HTTP daemon alive on `localhost:7532` and registers it with Claude Code and any detected editors — no terminal to babysit. Then `mache init` (no flag) inside a project records what that project serves.

- `mache serve` is a **daemon** — it must stay running, or the client gets `connection refused`. Background it or run it in another terminal.
Two things new users hit:

- **HTTP is the canonical transport.** One shared daemon serves every project, routing per session via the MCP roots protocol. `--stdio` exists only as an escape hatch for CI / sandboxes / headless agents and is never registered for editor use (see [ADR-0022](docs/adr/0022-mcp-transport-canonical.md)).
- For **Rust / Python / TypeScript**, point mache at a [ley-line-open](https://github.com/agentic-research/ley-line-open)-built `.db` instead of a directory (`mache serve ./code.db`) to get accurate `find_callers` + `get_type_info` + `get_diagnostics`. A bare directory uses the built-in tree-sitter tier, which is tuned for Go.

For the full first-run flow — source choice (directory vs `.db` vs live hot-swap), HTTP vs stdio, `--scope`, Claude Desktop, mount as filesystem, write-back, schema inference, troubleshooting — see [GETTING-STARTED.md](GETTING-STARTED.md).
For the full first-run flow — source choice (directory vs `.db` vs live hot-swap), the `--stdio` escape hatch, `--scope`, Claude Desktop, mount as filesystem, write-back, schema inference, troubleshooting — see [GETTING-STARTED.md](GETTING-STARTED.md).

## What it gives an agent

Expand Down Expand Up @@ -85,7 +86,7 @@ The graph is the same on either path; MCP and the filesystem are two ways to tal
| Capability | Status |
| --------------------------------------- | ------------------------------------------------------------------------------------------------ |
| Tree-sitter parsing (28 langs) | Stable |
| MCP server (17 tools, stdio + HTTP) | Stable |
| MCP server (17 tools, HTTP canonical) | Stable |
| Cross-repo serve (`--mount NAME=PATH`) | Stable (find_callers federates; find_callees stays per-mount for now) |
| Cross-references (callers/callees) | Stable |
| `find_smells` (9 structural rules) | Stable. `fan_out_skew` is qualifier-aware via ley-line-open `BindingRecord.qualifier` |
Expand Down
34 changes: 16 additions & 18 deletions cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,15 +155,17 @@ func evalOrAbs(p string) (string, error) {
}
}

// mcpServerEntry is the mache entry written into MCP config files.
type mcpServerEntry struct {
Command string `json:"command"`
Args []string `json:"args"`
// httpServerEntry is the canonical MCP client entry for mache: a pointer to
// the shared Streamable HTTP daemon (see ADR-0022). Onboarding never registers
// a stdio command — `mache serve --stdio` is an explicit CI/sandbox escape
// hatch, not an editor-registered transport.
func httpServerEntry() map[string]any {
return map[string]any{"type": "http", "url": macheHTTPURL}
}

// writeClaudeMCPConfig writes or merges a mache entry into .claude/mcp.json.
// Uses map[string]any as root type to preserve unknown top-level keys.
func writeClaudeMCPConfig(projectDir, macheCommand string) error {
func writeClaudeMCPConfig(projectDir, _ string) error {
claudeDir := filepath.Join(projectDir, ".claude")
if err := os.MkdirAll(claudeDir, 0o755); err != nil {
return fmt.Errorf("create .claude dir: %w", err)
Expand All @@ -185,10 +187,7 @@ func writeClaudeMCPConfig(projectDir, macheCommand string) error {
servers = make(map[string]any)
}

servers["mache"] = mcpServerEntry{
Command: macheCommand,
Args: []string{"serve"},
}
servers["mache"] = httpServerEntry()
root["mcpServers"] = servers

out, err := json.MarshalIndent(root, "", " ")
Expand Down Expand Up @@ -293,9 +292,7 @@ func detectEditors(binaryPath string) []editorConfig {
ConfigPath: filepath.Join(home, ".config", "zed", "settings.json"),
ServerKey: "context_servers",
SharedConfig: true,
EntryFunc: func(bp string) map[string]any {
return map[string]any{"source": "custom", "command": bp, "args": []string{"serve"}}
},
EntryFunc: func(string) map[string]any { return httpServerEntry() },
},
}

Expand All @@ -312,17 +309,15 @@ func detectEditors(binaryPath string) []editorConfig {
Name: "VS Code",
ConfigPath: vscodePath,
ServerKey: "servers",
EntryFunc: func(bp string) map[string]any {
return map[string]any{"type": "stdio", "command": bp, "args": []string{"serve"}}
},
EntryFunc: func(string) map[string]any { return httpServerEntry() },
})
}

return editors
}

func mcpServersEntry(binaryPath string) map[string]any {
return map[string]any{"command": binaryPath, "args": []string{"serve"}}
func mcpServersEntry(string) map[string]any {
return httpServerEntry()
}

// registerEditorMCP upserts mache into an editor's MCP config file.
Expand Down Expand Up @@ -383,7 +378,10 @@ func registerClaudeCodeCLIImpl(binaryPath string) bool {
}
// Remove first (may fail if not registered — that's fine)
_ = exec.Command(claudePath, "mcp", "remove", "-s", "user", "mache").Run()
err = exec.Command(claudePath, "mcp", "add", "--scope", "user", "mache", "--", binaryPath, "serve").Run()
// Register the canonical Streamable HTTP endpoint (ADR-0022), not a stdio
// command. binaryPath is unused now — the daemon is shared, not spawned
// per client.
err = exec.Command(claudePath, "mcp", "add", "--scope", "user", "--transport", "http", "mache", macheHTTPURL).Run()
return err == nil
}

Expand Down
11 changes: 6 additions & 5 deletions cmd/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -278,9 +278,10 @@ func TestWriteClaudeMCPConfig_Fresh(t *testing.T) {
require.NoError(t, json.Unmarshal(data, &root))
servers := root["mcpServers"].(map[string]any)
mache := servers["mache"].(map[string]any)
assert.Equal(t, "mache", mache["command"])
args := mache["args"].([]any)
assert.Equal(t, "serve", args[0])
// Canonical: HTTP endpoint, not a stdio command (ADR-0022).
assert.Equal(t, "http", mache["type"])
assert.Equal(t, macheHTTPURL, mache["url"])
assert.NotContains(t, mache, "command")
}

func TestWriteClaudeMCPConfig_MergeExisting(t *testing.T) {
Expand All @@ -305,7 +306,7 @@ func TestWriteClaudeMCPConfig_MergeExisting(t *testing.T) {
assert.Contains(t, servers, "mache")

mache := servers["mache"].(map[string]any)
assert.Equal(t, "/usr/local/bin/mache", mache["command"])
assert.Equal(t, macheHTTPURL, mache["url"])
}

func TestWriteClaudeMCPConfig_PreservesUnknownKeys(t *testing.T) {
Expand Down Expand Up @@ -384,7 +385,7 @@ func TestRegisterEditorMCP_Fresh(t *testing.T) {
require.NoError(t, json.Unmarshal(data, &root))
servers := root["mcpServers"].(map[string]any)
mache := servers["mache"].(map[string]any)
assert.Equal(t, "/usr/local/bin/mache", mache["command"])
assert.Equal(t, macheHTTPURL, mache["url"])
}

func TestRegisterEditorMCP_MergeExisting(t *testing.T) {
Expand Down
191 changes: 191 additions & 0 deletions cmd/daemon_agent.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
package cmd

import (
"encoding/xml"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
)

// Canonical MCP transport endpoint. mache serves Streamable HTTP on this
// address (serve.go default), and onboarding (`mache init`) registers clients
// against it. stdio is a deliberate escape hatch (`mache serve --stdio`) for
// CI / sandbox / headless use — it is never registered. See ADR-0022.
const (
macheHTTPListen = "localhost:7532"
macheHTTPURL = "http://localhost:7532/mcp"
launchAgentLabel = "com.agentic-research.mache"
)

// daemonAgentAutoload gates the launchctl/systemctl load step. Tests set it
// false to exercise plist/unit writing without a real supervisor side effect.
var daemonAgentAutoload = true

// logf writes a progress line, discarding the write error (best-effort output).
func logf(w io.Writer, format string, a ...any) { _, _ = fmt.Fprintf(w, format, a...) }

// xmlText escapes s for use as plist <string> element text. os.Executable() can
// resolve to a path with XML-significant characters (&, <, >) or quotes — e.g.
// an app bundle under "/Applications/Mache Tools/" — which would otherwise
// produce a malformed plist.
func xmlText(s string) string {
var b strings.Builder
_ = xml.EscapeText(&b, []byte(s))
return b.String()
}

// systemdQuote double-quotes s for a systemd ExecStart argument so a binary
// path containing spaces (or quotes/backslashes) is treated as one argument
// rather than split on whitespace. systemd honors \" and \\ inside double
// quotes.
func systemdQuote(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `"`, `\"`)
return `"` + s + `"`
}

// launchAgentPlist renders the macOS LaunchAgent plist that keepalives the
// shared mache HTTP daemon, so the endpoint registered by `mache init` is
// answerable without anyone running `mache serve` by hand.
//
// KeepAlive only restarts on failure (SuccessfulExit=false) and ThrottleInterval
// guards against a tight crash-loop if the daemon can't start (e.g. stale
// ~/.mache state — mache-823d91). Pure function so it can be unit-tested.
func launchAgentPlist(binPath, logPath string) string {
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>%s</string>
<key>ProgramArguments</key>
<array>
<string>%s</string>
<string>serve</string>
<string>--http</string>
<string>%s</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>ThrottleInterval</key>
<integer>10</integer>
<key>StandardOutPath</key>
<string>%s</string>
<key>StandardErrorPath</key>
<string>%s</string>
</dict>
</plist>
`, launchAgentLabel, xmlText(binPath), macheHTTPListen, xmlText(logPath), xmlText(logPath))
}

// systemdUserUnit renders the Linux systemd --user service that keepalives the
// shared mache HTTP daemon. Pure function so it can be unit-tested.
func systemdUserUnit(binPath string) string {
return fmt.Sprintf(`[Unit]
Description=mache MCP HTTP daemon (Streamable HTTP on %s)
After=network.target

[Service]
ExecStart=%s serve --http %s
Restart=on-failure
RestartSec=10

[Install]
WantedBy=default.target
`, macheHTTPListen, systemdQuote(binPath), macheHTTPListen)
}

// installDaemonAgent writes and loads a per-user supervisor that keeps the
// shared mache HTTP daemon alive, so the canonical endpoint is always
// answerable. Best-effort: it reports what it did but never fails init — a
// user can always run `mache serve --http` by hand. Idempotent.
func installDaemonAgent(w io.Writer, binPath string) {
switch runtime.GOOS {
case "darwin":
installLaunchAgent(w, binPath)
case "linux":
installSystemdUnit(w, binPath)
default:
logf(w, " [daemon] no supervisor for %s — run `mache serve --http %s` to start the daemon.\n", runtime.GOOS, macheHTTPListen)
}
}

func installLaunchAgent(w io.Writer, binPath string) {
home, err := os.UserHomeDir()
if err != nil {
logf(w, " [daemon] could not resolve home dir: %v — start manually: mache serve --http %s\n", err, macheHTTPListen)
return
}
agentDir := filepath.Join(home, "Library", "LaunchAgents")
logPath := filepath.Join(home, "Library", "Logs", "mache.log")
plistPath := filepath.Join(agentDir, launchAgentLabel+".plist")

if err := os.MkdirAll(agentDir, 0o755); err != nil {
logf(w, " [daemon] could not create %s: %v\n", agentDir, err)
return
}
if err := os.WriteFile(plistPath, []byte(launchAgentPlist(binPath, logPath)), 0o644); err != nil {
logf(w, " [daemon] could not write %s: %v\n", plistPath, err)
return
}
logf(w, " [daemon] wrote %s\n", plistPath)

if !daemonAgentAutoload {
return
}
// Reload: bootout (ignore failure if not loaded) then bootstrap.
if launchctl, err := exec.LookPath("launchctl"); err == nil {
target := fmt.Sprintf("gui/%d", os.Getuid())
_ = exec.Command(launchctl, "bootout", target+"/"+launchAgentLabel).Run()
if err := exec.Command(launchctl, "bootstrap", target, plistPath).Run(); err != nil {
logf(w, " [daemon] plist installed; load it with: launchctl bootstrap %s %s\n", target, plistPath)
return
}
logf(w, " [daemon] loaded — mache HTTP daemon will keepalive on %s\n", macheHTTPListen)
return
}
logf(w, " [daemon] plist installed; launchctl not found — load it after restart.\n")
}

func installSystemdUnit(w io.Writer, binPath string) {
home, err := os.UserHomeDir()
if err != nil {
logf(w, " [daemon] could not resolve home dir: %v — start manually: mache serve --http %s\n", err, macheHTTPListen)
return
}
unitDir := filepath.Join(home, ".config", "systemd", "user")
unitPath := filepath.Join(unitDir, "mache.service")

if err := os.MkdirAll(unitDir, 0o755); err != nil {
logf(w, " [daemon] could not create %s: %v\n", unitDir, err)
return
}
if err := os.WriteFile(unitPath, []byte(systemdUserUnit(binPath)), 0o644); err != nil {
logf(w, " [daemon] could not write %s: %v\n", unitPath, err)
return
}
logf(w, " [daemon] wrote %s\n", unitPath)

if !daemonAgentAutoload {
return
}
if systemctl, err := exec.LookPath("systemctl"); err == nil {
_ = exec.Command(systemctl, "--user", "daemon-reload").Run()
if err := exec.Command(systemctl, "--user", "enable", "--now", "mache.service").Run(); err != nil {
logf(w, " [daemon] unit installed; enable it with: systemctl --user enable --now mache.service\n")
return
}
logf(w, " [daemon] enabled — mache HTTP daemon will keepalive on %s\n", macheHTTPListen)
return
}
logf(w, " [daemon] unit installed; systemctl not found — enable it manually.\n")
}
Loading
Loading