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
6 changes: 1 addition & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,6 @@ Or manually by adding this repo's `skills/` directory to your Agent skill search
# Evaluate code (daemon starts automatically)
julia-client -e 'println("hello")'

# Pkg operations (disable timeout)
julia-client --timeout 0 -e 'using Pkg; Pkg.add("Example")'

# Explicit project environment
julia-client --project /path/to/project -e 'using MyPackage'

Expand All @@ -42,7 +39,6 @@ echo 'println("hello")' | julia-client

# Session management
julia-client sessions # list active sessions
julia-client restart # restart current session
julia-client stop # shut down the daemon
```

Expand All @@ -56,4 +52,4 @@ A single `julia-client` binary serves as both client and daemon:
## Alternatives

- [julia-mcp](https://github.com/aplavin/julia-mcp?tab=readme-ov-file) is very similar but uses MCP server instead
- [DaemonicCabal.jl](https://github.com/tecosaur/DaemonicCabal.jl) only runs on Linux
- [DaemonicCabal.jl](https://github.com/tecosaur/DaemonicCabal.jl) only runs on Linux
201 changes: 93 additions & 108 deletions go/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,67 +4,21 @@ import (
"encoding/json"
"net"
"os"
"os/exec"
"path/filepath"
"sync"
"testing"
"time"
)

// ---- detectEnv / resolveProject ----

func TestDetectEnv_FindsProjectToml(t *testing.T) {
root := t.TempDir()
sub := filepath.Join(root, "a", "b")
if err := os.MkdirAll(sub, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "Project.toml"), []byte{}, 0644); err != nil {
t.Fatal(err)
}

got := detectEnv(sub)
if got != root {
t.Errorf("detectEnv(%q) = %q, want %q", sub, got, root)
}
}

func TestDetectEnv_NoneFound(t *testing.T) {
dir := t.TempDir()
// Make sure there's no Project.toml anywhere up the tree
// (TempDir is under /tmp which never has one)
got := detectEnv(dir)
if got != "" {
t.Errorf("detectEnv(%q) = %q, want empty", dir, got)
}
}

func TestResolveProject_Empty(t *testing.T) {
// When empty, result is either a detected env or "".
// Just ensure it doesn't panic and returns a valid absolute path or "".
got := resolveProject("")
if got != "" {
if !filepath.IsAbs(got) {
t.Errorf("resolveProject(\"\") = %q, want absolute path or empty", got)
}
}
}

func TestResolveProject_Relative(t *testing.T) {
root := t.TempDir()
sub := filepath.Join(root, "proj")
os.Mkdir(sub, 0755)

orig, _ := os.Getwd()
os.Chdir(root)
defer os.Chdir(orig)

got := resolveProject("proj")
// Resolve symlinks on both sides (macOS /var → /private/var)
gotR, _ := filepath.EvalSymlinks(got)
subR, _ := filepath.EvalSymlinks(sub)
if gotR != subR {
t.Errorf("resolveProject(\"proj\") = %q, want %q", got, sub)
// TestMain allows the test binary to act as the CLI when TEST_CLI=1,
// enabling subprocess-based end-to-end tests of main().
func TestMain(m *testing.M) {
if os.Getenv("TEST_CLI") == "1" {
main()
return
}
os.Exit(m.Run())
}

// ---- pkgPattern ----
Expand Down Expand Up @@ -142,82 +96,80 @@ func TestHandleRequest_Stop(t *testing.T) {
}
}

// ---- daemon socket integration (no Julia) ----

func TestDaemonPingOverSocket(t *testing.T) {
socketPath := filepath.Join(t.TempDir(), "test.sock")
// ---- helpers ----

var wg sync.WaitGroup
// startTestDaemon launches serveDaemon in a goroutine and returns a stop func and the socket path.
// The returned WaitGroup is done when the daemon exits.
func startTestDaemon(t *testing.T) (socketPath string, stop func(), wg *sync.WaitGroup) {
t.Helper()
socketPath = filepath.Join(t.TempDir(), "test.sock")
wg = &sync.WaitGroup{}
wg.Add(1)
go func() {
defer wg.Done()
serveDaemon(socketPath, time.Hour)
}()
waitForSocket(t, socketPath)
stop = func() {
conn, _ := net.Dial("unix", socketPath)
if conn != nil {
json.NewEncoder(conn).Encode(map[string]any{"action": "stop"})
conn.Close()
}
wg.Wait()
}
return
}

// Wait for socket to appear
func waitForSocket(t *testing.T, socketPath string) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if _, err := os.Stat(socketPath); err == nil {
break
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("daemon socket did not appear in time")
}

func sendRequest(t *testing.T, socketPath string, payload map[string]any) map[string]any {
t.Helper()
conn, err := net.Dial("unix", socketPath)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer conn.Close()

if err := json.NewEncoder(conn).Encode(map[string]any{"action": "ping"}); err != nil {
t.Fatal(err)
}
json.NewEncoder(conn).Encode(payload)
var resp map[string]any
if err := json.NewDecoder(conn).Decode(&resp); err != nil {
t.Fatal(err)
}
json.NewDecoder(conn).Decode(&resp)
return resp
}

// ---- daemon socket integration (no Julia) ----

func TestDaemonPingOverSocket(t *testing.T) {
socketPath, stop, _ := startTestDaemon(t)
defer stop()

resp := sendRequest(t, socketPath, map[string]any{"action": "ping"})
if resp["output"] != "pong" {
t.Errorf("ping over socket = %v, want pong", resp["output"])
}

// Stop the daemon so the goroutine exits
conn2, _ := net.Dial("unix", socketPath)
json.NewEncoder(conn2).Encode(map[string]any{"action": "stop"})
conn2.Close()
wg.Wait()
}

// ---- Julia integration ----

func TestEvalBasic(t *testing.T) {
socketPath := filepath.Join(t.TempDir(), "test.sock")

var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
serveDaemon(socketPath, time.Hour)
}()

// Wait for socket
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if _, err := os.Stat(socketPath); err == nil {
break
}
time.Sleep(20 * time.Millisecond)
}
socketPath, stop, _ := startTestDaemon(t)
defer stop()

cwd, _ := os.Getwd()
send := func(payload map[string]any) map[string]any {
conn, err := net.Dial("unix", socketPath)
if err != nil {
t.Fatalf("dial: %v", err)
if _, ok := payload["cwd"]; !ok {
payload["cwd"] = cwd
}
defer conn.Close()
json.NewEncoder(conn).Encode(payload)
var resp map[string]any
json.NewDecoder(conn).Decode(&resp)
return resp
return sendRequest(t, socketPath, payload)
}

// Eval basic expression
Expand All @@ -238,12 +190,11 @@ func TestEvalBasic(t *testing.T) {
t.Errorf("state not persisted: x = %q, want %q", out2, "42\n")
}

// Restart clears state
send(map[string]any{"action": "restart"})
resp3 := send(map[string]any{"action": "eval", "code": "println(isdefined(Main, :x))"})
// Fresh eval clears state before running code.
resp3 := send(map[string]any{"action": "eval", "code": "println(isdefined(Main, :x))", "fresh": true})
out3, _ := resp3["output"].(string)
if out3 != "false\n" {
t.Errorf("after restart x should be undefined, got %q", out3)
t.Errorf("after fresh eval x should be undefined, got %q", out3)
}

// println adds trailing newline; print does not
Expand All @@ -255,10 +206,44 @@ func TestEvalBasic(t *testing.T) {
if out5, _ := resp5["output"].(string); out5 != "with-nl\n" {
t.Errorf("println output = %q, want %q", out5, "with-nl\n")
}
}

// TestScriptFile exercises the full main() routing: julia-client script.jl
// The test binary re-invokes itself as the CLI via the TestMain/TEST_CLI mechanism.
func TestScriptFile(t *testing.T) {
socketPath, stop, _ := startTestDaemon(t)
defer stop()

cmd := exec.Command(os.Args[0], "--socket", socketPath, "testdata/compute.jl")
cmd.Env = append(os.Environ(), "TEST_CLI=1")
out, err := cmd.Output()
if err != nil {
stderr := ""
if e, ok := err.(*exec.ExitError); ok {
stderr = string(e.Stderr)
}
t.Fatalf("script run failed: %v\n%s", err, stderr)
}
if got := string(out); got != "42\n" {
t.Errorf("script output = %q, want %q", got, "42\n")
}
}

// Stop daemon
conn, _ := net.Dial("unix", socketPath)
json.NewEncoder(conn).Encode(map[string]any{"action": "stop"})
conn.Close()
wg.Wait()
func TestPrintResult(t *testing.T) {
socketPath, stop, _ := startTestDaemon(t)
defer stop()

cwd, _ := os.Getwd()
resp := sendRequest(t, socketPath, map[string]any{
"action": "eval",
"code": "1 + 1",
"cwd": cwd,
"print_result": true,
})
if resp["error"] != nil {
t.Fatalf("print_result error: %v", resp["error"])
}
if out, _ := resp["output"].(string); out != "2\n" {
t.Errorf("print_result output = %q, want %q", out, "2\n")
}
}
23 changes: 10 additions & 13 deletions go/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,11 @@ func handleRequest(state *daemonState, req map[string]any) map[string]any {
switch action {
case "eval":
code, _ := req["code"].(string)
envPath, _ := req["env_path"].(string)
cwd, _ := req["cwd"].(string)
project, _ := req["project"].(string)
session, _ := req["session"].(string)
juliaCmd, _ := req["julia_cmd"].(string)
fresh, _ := req["fresh"].(bool)

var timeoutSecs float64
if t, ok := req["timeout"]; ok {
Expand All @@ -48,24 +51,22 @@ func handleRequest(state *daemonState, req map[string]any) map[string]any {
}

printResult, _ := req["print_result"].(bool)
sess, err := state.manager.getOrCreate(envPath, juliaCmd)
if fresh {
state.manager.restart(session, project, cwd)
}
sess, err := state.manager.getOrCreate(cwd, project, session, juliaCmd)
if err != nil {
return errResp(err.Error())
}
output, err := sess.execute(code, timeoutSecs, printResult)
if err != nil {
if !sess.isAlive() {
state.manager.remove(envPath)
state.manager.remove(session, project, cwd)
}
return errResp(err.Error())
}
return map[string]any{"output": output, "error": nil}

case "restart":
envPath, _ := req["env_path"].(string)
state.manager.restart(envPath)
return map[string]any{"output": "Session restarted.", "error": nil}

case "sessions":
sessions := state.manager.list()
if len(sessions) == 0 {
Expand All @@ -77,11 +78,7 @@ func handleRequest(state *daemonState, req map[string]any) map[string]any {
if !s.alive {
status = "dead"
}
label := s.envPath
if s.isTemp {
label += " (temp)"
}
line := fmt.Sprintf(" %s: %s", label, status)
line := fmt.Sprintf(" %s: %s", s.project, status)
if s.juliaCmd != "" {
line += " julia_cmd=" + s.juliaCmd
}
Expand Down
Loading
Loading