diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..bcf4bb2 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,17 @@ +name: Test + +on: + push: + branches: ["main"] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - uses: julia-actions/setup-julia@v2 + - run: go test -v -timeout 300s ./go/ diff --git a/README.md b/README.md index a7dcc5b..bce2d76 100644 --- a/README.md +++ b/README.md @@ -30,16 +30,16 @@ GitHub Actions builds cross-platform binaries (Linux/macOS × amd64/arm64, Windo ```bash # Evaluate code (daemon starts automatically) -julia-client eval 'println("hello")' +julia-client -e 'println("hello")' # Pkg operations (disable timeout) -julia-client eval --timeout 0 'using Pkg; Pkg.add("Example")' - -# Custom Julia binary -julia-client eval --julia-cmd "julia +1.11" 'versioninfo()' +julia-client --timeout 0 -e 'using Pkg; Pkg.add("Example")' # Explicit project environment -julia-client eval --env /path/to/project 'using MyPackage' +julia-client --project /path/to/project -e 'using MyPackage' + +# Read from stdin +echo 'println("hello")' | julia-client # Session management julia-client sessions # list active sessions @@ -57,3 +57,8 @@ A single `julia-client` binary serves as both client and daemon: - **Client mode** (default) — sends JSON requests over a Unix socket (`~/.local/share/julia-client/julia-daemon.sock`) - **Daemon mode** (`julia-client daemon`) — background server managing persistent Julia processes; auto-started on first `eval`, shuts down after 30 minutes of inactivity + +## 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 \ No newline at end of file diff --git a/go/client_test.go b/go/client_test.go new file mode 100644 index 0000000..7a22cb2 --- /dev/null +++ b/go/client_test.go @@ -0,0 +1,253 @@ +package main + +import ( + "encoding/json" + "net" + "os" + "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) + } +} + +// ---- pkgPattern ---- + +func TestPkgPattern(t *testing.T) { + hits := []string{ + "Pkg.add(\"Example\")", + "using Pkg; Pkg.update()", + "Pkg.resolve()", + } + misses := []string{ + "println(\"hello\")", + "x = 1 + 2", + "# no package ops here", + } + for _, s := range hits { + if !pkgPattern.MatchString(s) { + t.Errorf("pkgPattern should match %q", s) + } + } + for _, s := range misses { + if pkgPattern.MatchString(s) { + t.Errorf("pkgPattern should not match %q", s) + } + } +} + +// ---- handleRequest (no Julia needed) ---- + +func newTestState() *daemonState { + return &daemonState{ + manager: newSessionManager(), + lastRequest: time.Now(), + stopCh: make(chan struct{}), + } +} + +func TestHandleRequest_Ping(t *testing.T) { + state := newTestState() + resp := handleRequest(state, map[string]any{"action": "ping"}) + if resp["output"] != "pong" { + t.Errorf("ping response = %v, want pong", resp["output"]) + } +} + +func TestHandleRequest_SessionsEmpty(t *testing.T) { + state := newTestState() + resp := handleRequest(state, map[string]any{"action": "sessions"}) + out, _ := resp["output"].(string) + if out != "No active Julia sessions." { + t.Errorf("sessions response = %q", out) + } +} + +func TestHandleRequest_UnknownAction(t *testing.T) { + state := newTestState() + resp := handleRequest(state, map[string]any{"action": "bogus"}) + if resp["error"] == nil { + t.Error("expected error for unknown action") + } +} + +func TestHandleRequest_Stop(t *testing.T) { + state := newTestState() + resp := handleRequest(state, map[string]any{"action": "stop"}) + if resp["output"] != "Daemon stopping." { + t.Errorf("stop response = %v", resp["output"]) + } + select { + case <-state.stopCh: + // closed as expected + default: + t.Error("stopCh not closed after stop action") + } +} + +// ---- daemon socket integration (no Julia) ---- + +func TestDaemonPingOverSocket(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 to appear + 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) + } + + 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) + } + var resp map[string]any + if err := json.NewDecoder(conn).Decode(&resp); err != nil { + t.Fatal(err) + } + 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) + } + + send := func(payload map[string]any) map[string]any { + conn, err := net.Dial("unix", socketPath) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + json.NewEncoder(conn).Encode(payload) + var resp map[string]any + json.NewDecoder(conn).Decode(&resp) + return resp + } + + // Eval basic expression + resp := send(map[string]any{"action": "eval", "code": `println("hello world")`}) + if resp["error"] != nil { + t.Fatalf("eval error: %v", resp["error"]) + } + out, _ := resp["output"].(string) + if out != "hello world" { + t.Errorf("eval output = %q, want %q", out, "hello world") + } + + // State persists across calls + send(map[string]any{"action": "eval", "code": "x = 42"}) + resp2 := send(map[string]any{"action": "eval", "code": "println(x)"}) + out2, _ := resp2["output"].(string) + if out2 != "42" { + t.Errorf("state not persisted: x = %q, want 42", out2) + } + + // Restart clears state + send(map[string]any{"action": "restart"}) + resp3 := send(map[string]any{"action": "eval", "code": "println(isdefined(Main, :x))"}) + out3, _ := resp3["output"].(string) + if out3 != "false" { + t.Errorf("after restart x should be undefined, got %q", out3) + } + + // Stop daemon + conn, _ := net.Dial("unix", socketPath) + json.NewEncoder(conn).Encode(map[string]any{"action": "stop"}) + conn.Close() + wg.Wait() +} diff --git a/go/main.go b/go/main.go index 208bbab..183df4c 100644 --- a/go/main.go +++ b/go/main.go @@ -41,13 +41,13 @@ func detectEnv(start string) string { return "" } -// resolveEnv returns an absolute env path: auto-detected if env is empty, +// resolveProject returns an absolute project path: auto-detected if project is empty, // absolutized if the caller supplied a (possibly relative) path. -func resolveEnv(env string) string { - if env == "" { +func resolveProject(project string) string { + if project == "" { return detectEnv("") } - abs, _ := filepath.Abs(env) + abs, _ := filepath.Abs(project) return abs } @@ -121,7 +121,7 @@ func run(socketPath string, payload map[string]any, startIfNeeded bool) { fmt.Println(resp.Output) } -func cmdEval(socketPath, code, env string, timeout float64, juliaCmd string) { +func cmdEval(socketPath, code, project string, timeout float64, juliaCmd string) { if code == "-" { b, err := io.ReadAll(os.Stdin) if err != nil { @@ -131,8 +131,8 @@ func cmdEval(socketPath, code, env string, timeout float64, juliaCmd string) { code = string(b) } payload := map[string]any{"action": "eval", "code": code} - if env := resolveEnv(env); env != "" { - payload["env_path"] = env + if p := resolveProject(project); p != "" { + payload["env_path"] = p } if timeout != -1 { payload["timeout"] = timeout @@ -147,10 +147,10 @@ func cmdSessions(socketPath string) { run(socketPath, map[string]any{"action": "sessions"}, false) } -func cmdRestart(socketPath, env string) { +func cmdRestart(socketPath, project string) { payload := map[string]any{"action": "restart"} - if env := resolveEnv(env); env != "" { - payload["env_path"] = env + if p := resolveProject(project); p != "" { + payload["env_path"] = p } run(socketPath, payload, false) } @@ -163,57 +163,70 @@ func usage() { fmt.Fprintf(os.Stderr, `julia-client: Julia REPL client Usage: + julia-client [flags] [-e CODE] julia-client [--socket PATH] [options] +Eval flags: + -e, --eval CODE Evaluate Julia code (omit or use - to read stdin) + --project PATH Julia project directory (auto-detected from $PWD) + --timeout SECS Timeout in seconds (0 = no timeout, default: 60) + --julia-cmd CMD Custom Julia binary, e.g. "julia +1.11" + Commands: - eval [CODE] Evaluate Julia code in a persistent session (- or omit to read stdin) - --env PATH Julia project directory - --timeout SECS Timeout in seconds (0 = no timeout, default: 60) - --julia-cmd CMD Custom Julia binary, e.g. "julia +1.11" - sessions List active Julia sessions - restart Restart a Julia session, clearing all state - --env PATH Project directory - stop Stop the daemon - daemon Run the daemon in the foreground (normally auto-started) + sessions List active Julia sessions + restart Restart a Julia session, clearing all state + --project PATH Project directory + stop Stop the daemon + daemon Run the daemon in the foreground (normally auto-started) --idle-timeout SECS Shut down after idle (default: 1800) -Flags: - --socket PATH Unix socket path (default: %s) +Global flags: + --socket PATH Unix socket path (default: %s) `, defaultSocket) os.Exit(2) } func main() { socketFlag := flag.String("socket", defaultSocket, "Unix socket path") + evalShort := flag.String("e", "", "Evaluate Julia code") + evalLong := flag.String("eval", "", "Evaluate Julia code") + projectFlag := flag.String("project", "", "Julia project directory") + timeoutFlag := flag.Float64("timeout", -1, "Timeout in seconds") + juliaCmdFlag := flag.String("julia-cmd", "", "Custom Julia binary") flag.Usage = usage flag.Parse() + // -e / --eval: evaluate mode + code := *evalShort + if code == "" { + code = *evalLong + } + if code != "" { + cmdEval(*socketFlag, code, *projectFlag, *timeoutFlag, *juliaCmdFlag) + return + } + args := flag.Args() + + // No subcommand: read stdin only if it's a pipe/redirect, not a terminal if len(args) == 0 { - usage() + fi, err := os.Stdin.Stat() + if err != nil || fi.Mode()&os.ModeCharDevice != 0 { + usage() + } + cmdEval(*socketFlag, "-", *projectFlag, *timeoutFlag, *juliaCmdFlag) + return } switch args[0] { - case "eval": - fs := flag.NewFlagSet("eval", flag.ExitOnError) - env := fs.String("env", "", "Julia project directory") - timeout := fs.Float64("timeout", -1, "Timeout in seconds (-1 = use daemon default)") - juliaCmd := fs.String("julia-cmd", "", "Custom Julia binary") - fs.Parse(args[1:]) - code := "-" - if fs.NArg() > 0 { - code = fs.Arg(0) - } - cmdEval(*socketFlag, code, *env, *timeout, *juliaCmd) - case "sessions": cmdSessions(*socketFlag) case "restart": fs := flag.NewFlagSet("restart", flag.ExitOnError) - env := fs.String("env", "", "Project directory") + project := fs.String("project", "", "Project directory") fs.Parse(args[1:]) - cmdRestart(*socketFlag, *env) + cmdRestart(*socketFlag, *project) case "stop": cmdStop(*socketFlag) diff --git a/justfile b/justfile new file mode 100644 index 0000000..6f05664 --- /dev/null +++ b/justfile @@ -0,0 +1,8 @@ +install: + go build -o ~/.local/bin/julia-client ./go/ + +test: + go test -v -timeout 300s ./go/ + +build: + go build -o julia-client ./go/ diff --git a/skills/julia-client/SKILL.md b/skills/julia-client/SKILL.md index 132dd8e..cfabff8 100644 --- a/skills/julia-client/SKILL.md +++ b/skills/julia-client/SKILL.md @@ -1,27 +1,15 @@ --- name: julia-client description: "Run Julia code in a persistent session with project env auto-detection and timeout handling. Use for efficient Julia code execution." -category: language -complexity: basic --- ## Running code ```bash -julia-client eval 'x=1' -julia-client eval 'println(x)' +julia-client -e 'x=1' +julia-client -e 'println(x)' ``` -- Use `display(...)` or `println(...)` to produce visible output. -- For Pkg operations, disable the timeout: - ```bash - julia-client eval --timeout 0 'using Pkg; Pkg.add("Example")' - ``` -- For a custom Julia binary: - ```bash - julia-client eval --julia-cmd "julia +1.11" 'versioninfo()' - ``` - ## Session management ```bash