From 09547ac9caca0c979a84a25e07867984225aa955 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Feb 2026 03:33:23 +0000 Subject: [PATCH 1/5] Add showboat var for persistent variables across cells Cells executed via `showboat exec` can now set and read persistent variables using `showboat var set/get/list/del`. Variables are stored in a .vars JSON file alongside the markdown document. The runner copies the showboat binary into a temp directory on PATH and sets SHOWBOAT_VARS in the child environment, so cells can call `showboat var` regardless of how the parent was invoked (go run, uvx, system PATH, etc.). Works cross-platform (Windows .exe handling). During `verify`, the vars file is cleared at the start for deterministic replay and cleaned up afterward. https://claude.ai/code/session_014nhLqsJZjScVShx13JFRFz --- cmd/build.go | 6 +- cmd/var.go | 113 ++++++++++++++++++++++++++++++ cmd/var_test.go | 164 ++++++++++++++++++++++++++++++++++++++++++++ cmd/verify.go | 8 ++- exec/image.go | 4 +- exec/image_test.go | 4 +- exec/runner.go | 71 ++++++++++++++++++- exec/runner_test.go | 14 ++-- help.txt | 19 +++++ integration_test.go | 111 ++++++++++++++++++++++++++++++ main.go | 50 ++++++++++++++ 11 files changed, 549 insertions(+), 15 deletions(-) create mode 100644 cmd/var.go create mode 100644 cmd/var_test.go diff --git a/cmd/build.go b/cmd/build.go index 363c0a3..ab15a1e 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -29,7 +29,8 @@ func Exec(file, lang, code, workdir string) (string, int, error) { return "", 1, fmt.Errorf("file not found: %s", file) } - output, exitCode, err := execpkg.Run(lang, code, workdir) + varsFile := VarsFile(file) + output, exitCode, err := execpkg.Run(lang, code, workdir, varsFile) if err != nil { return "", exitCode, fmt.Errorf("running code: %w", err) } @@ -58,7 +59,8 @@ func Image(file, script, workdir string) error { } destDir := filepath.Dir(file) - filename, err := execpkg.RunImage(script, destDir, workdir) + varsFile := VarsFile(file) + filename, err := execpkg.RunImage(script, destDir, workdir, varsFile) if err != nil { return fmt.Errorf("running image script: %w", err) } diff --git a/cmd/var.go b/cmd/var.go new file mode 100644 index 0000000..543990f --- /dev/null +++ b/cmd/var.go @@ -0,0 +1,113 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" +) + +// VarsFile returns the path to the vars file for a given markdown document. +func VarsFile(mdFile string) string { + abs, err := filepath.Abs(mdFile) + if err != nil { + abs = mdFile + } + return abs + ".vars" +} + +// VarSet sets a variable in the vars file identified by SHOWBOAT_VARS. +func VarSet(key, value string) error { + file, err := varsFileFromEnv() + if err != nil { + return err + } + vars, err := loadVars(file) + if err != nil { + return err + } + vars[key] = value + return saveVars(file, vars) +} + +// VarGet returns the value of a variable from the vars file. +func VarGet(key string) (string, error) { + file, err := varsFileFromEnv() + if err != nil { + return "", err + } + vars, err := loadVars(file) + if err != nil { + return "", err + } + val, ok := vars[key] + if !ok { + return "", fmt.Errorf("variable not set: %s", key) + } + return val, nil +} + +// VarList returns all variable names sorted alphabetically. +func VarList() ([]string, error) { + file, err := varsFileFromEnv() + if err != nil { + return nil, err + } + vars, err := loadVars(file) + if err != nil { + return nil, err + } + keys := make([]string, 0, len(vars)) + for k := range vars { + keys = append(keys, k) + } + sort.Strings(keys) + return keys, nil +} + +// VarDel deletes a variable from the vars file. +func VarDel(key string) error { + file, err := varsFileFromEnv() + if err != nil { + return err + } + vars, err := loadVars(file) + if err != nil { + return err + } + delete(vars, key) + return saveVars(file, vars) +} + +func varsFileFromEnv() (string, error) { + file := os.Getenv("SHOWBOAT_VARS") + if file == "" { + return "", fmt.Errorf("SHOWBOAT_VARS not set (are you running inside showboat exec?)") + } + return file, nil +} + +func loadVars(file string) (map[string]string, error) { + data, err := os.ReadFile(file) + if err != nil { + if os.IsNotExist(err) { + return make(map[string]string), nil + } + return nil, fmt.Errorf("reading vars file: %w", err) + } + var vars map[string]string + if err := json.Unmarshal(data, &vars); err != nil { + return nil, fmt.Errorf("parsing vars file: %w", err) + } + return vars, nil +} + +func saveVars(file string, vars map[string]string) error { + data, err := json.MarshalIndent(vars, "", " ") + if err != nil { + return fmt.Errorf("encoding vars: %w", err) + } + data = append(data, '\n') + return os.WriteFile(file, data, 0644) +} diff --git a/cmd/var_test.go b/cmd/var_test.go new file mode 100644 index 0000000..6360945 --- /dev/null +++ b/cmd/var_test.go @@ -0,0 +1,164 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" +) + +func TestVarsFile(t *testing.T) { + // VarsFile should append .vars to the absolute path + got := VarsFile("/tmp/demo.md") + if got != "/tmp/demo.md.vars" { + t.Errorf("expected /tmp/demo.md.vars, got %s", got) + } +} + +func TestVarSetGet(t *testing.T) { + dir := t.TempDir() + varsFile := filepath.Join(dir, "test.vars") + t.Setenv("SHOWBOAT_VARS", varsFile) + + if err := VarSet("FOO", "bar"); err != nil { + t.Fatal(err) + } + + val, err := VarGet("FOO") + if err != nil { + t.Fatal(err) + } + if val != "bar" { + t.Errorf("expected bar, got %q", val) + } +} + +func TestVarSetOverwrite(t *testing.T) { + dir := t.TempDir() + varsFile := filepath.Join(dir, "test.vars") + t.Setenv("SHOWBOAT_VARS", varsFile) + + if err := VarSet("KEY", "first"); err != nil { + t.Fatal(err) + } + if err := VarSet("KEY", "second"); err != nil { + t.Fatal(err) + } + + val, err := VarGet("KEY") + if err != nil { + t.Fatal(err) + } + if val != "second" { + t.Errorf("expected second, got %q", val) + } +} + +func TestVarGetMissing(t *testing.T) { + dir := t.TempDir() + varsFile := filepath.Join(dir, "test.vars") + t.Setenv("SHOWBOAT_VARS", varsFile) + + _, err := VarGet("NONEXISTENT") + if err == nil { + t.Error("expected error for missing variable") + } +} + +func TestVarGetNoEnv(t *testing.T) { + t.Setenv("SHOWBOAT_VARS", "") + + _, err := VarGet("FOO") + if err == nil { + t.Error("expected error when SHOWBOAT_VARS is not set") + } +} + +func TestVarList(t *testing.T) { + dir := t.TempDir() + varsFile := filepath.Join(dir, "test.vars") + t.Setenv("SHOWBOAT_VARS", varsFile) + + VarSet("ZEBRA", "z") + VarSet("APPLE", "a") + VarSet("MANGO", "m") + + keys, err := VarList() + if err != nil { + t.Fatal(err) + } + if len(keys) != 3 { + t.Fatalf("expected 3 keys, got %d", len(keys)) + } + if keys[0] != "APPLE" || keys[1] != "MANGO" || keys[2] != "ZEBRA" { + t.Errorf("expected sorted keys [APPLE MANGO ZEBRA], got %v", keys) + } +} + +func TestVarListEmpty(t *testing.T) { + dir := t.TempDir() + varsFile := filepath.Join(dir, "test.vars") + t.Setenv("SHOWBOAT_VARS", varsFile) + + keys, err := VarList() + if err != nil { + t.Fatal(err) + } + if len(keys) != 0 { + t.Errorf("expected 0 keys, got %d", len(keys)) + } +} + +func TestVarDel(t *testing.T) { + dir := t.TempDir() + varsFile := filepath.Join(dir, "test.vars") + t.Setenv("SHOWBOAT_VARS", varsFile) + + VarSet("A", "1") + VarSet("B", "2") + + if err := VarDel("A"); err != nil { + t.Fatal(err) + } + + _, err := VarGet("A") + if err == nil { + t.Error("expected error after deleting A") + } + + val, err := VarGet("B") + if err != nil { + t.Fatal(err) + } + if val != "2" { + t.Errorf("expected 2, got %q", val) + } +} + +func TestVarDelMissing(t *testing.T) { + dir := t.TempDir() + varsFile := filepath.Join(dir, "test.vars") + t.Setenv("SHOWBOAT_VARS", varsFile) + + // Deleting a non-existent key should not error + if err := VarDel("NONEXISTENT"); err != nil { + t.Errorf("expected no error deleting missing key, got: %v", err) + } +} + +func TestVarsPersistFile(t *testing.T) { + dir := t.TempDir() + varsFile := filepath.Join(dir, "test.vars") + t.Setenv("SHOWBOAT_VARS", varsFile) + + VarSet("PERSIST", "yes") + + // Verify the file exists and is valid JSON + data, err := os.ReadFile(varsFile) + if err != nil { + t.Fatal(err) + } + s := string(data) + if s == "" { + t.Error("expected non-empty vars file") + } +} diff --git a/cmd/verify.go b/cmd/verify.go index b35907b..960e38f 100644 --- a/cmd/verify.go +++ b/cmd/verify.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "os" "strings" execpkg "github.com/simonw/showboat/exec" @@ -33,6 +34,11 @@ func Verify(file, outputFile, workdir string) ([]Diff, error) { return nil, err } + // Start with a clean vars file so verify is a deterministic replay + varsFile := VarsFile(file) + os.Remove(varsFile) + defer os.Remove(varsFile) + var diffs []Diff for i := 0; i < len(blocks); i++ { @@ -42,7 +48,7 @@ func Verify(file, outputFile, workdir string) ([]Diff, error) { } // Execute the code block - output, _, err := execpkg.Run(cb.Lang, cb.Code, workdir) + output, _, err := execpkg.Run(cb.Lang, cb.Code, workdir, varsFile) if err != nil { return nil, fmt.Errorf("executing block %d: %w", i, err) } diff --git a/exec/image.go b/exec/image.go index 34c66b9..62c57c3 100644 --- a/exec/image.go +++ b/exec/image.go @@ -24,8 +24,8 @@ var validImageExts = map[string]bool{ // The last line of stdout is treated as the path to the image. // The image is copied to destDir with a -. filename. // Returns the new filename (not the full path). -func RunImage(script, destDir, workdir string) (string, error) { - output, _, err := Run("bash", script, workdir) +func RunImage(script, destDir, workdir, varsFile string) (string, error) { + output, _, err := Run("bash", script, workdir, varsFile) if err != nil { return "", fmt.Errorf("running image script: %w", err) } diff --git a/exec/image_test.go b/exec/image_test.go index 5047485..97d34b6 100644 --- a/exec/image_test.go +++ b/exec/image_test.go @@ -16,7 +16,7 @@ func TestRunImageScript(t *testing.T) { script := `printf '\x89PNG\r\n\x1a\n' > ` + imgPath + ` && echo ` + imgPath destDir := t.TempDir() - filename, err := RunImage(script, destDir, "") + filename, err := RunImage(script, destDir, "", "") if err != nil { t.Fatal(err) } @@ -36,7 +36,7 @@ func TestRunImageScript(t *testing.T) { func TestRunImageScriptBadPath(t *testing.T) { script := `echo /nonexistent/file.png` destDir := t.TempDir() - _, err := RunImage(script, destDir, "") + _, err := RunImage(script, destDir, "", "") if err == nil { t.Error("expected error for nonexistent image path") } diff --git a/exec/runner.go b/exec/runner.go index c40b683..ae458f6 100644 --- a/exec/runner.go +++ b/exec/runner.go @@ -3,7 +3,12 @@ package exec import ( "bytes" "fmt" + "io" + "os" "os/exec" + "path/filepath" + "runtime" + "strings" ) // Run executes code using the given language interpreter and returns @@ -11,13 +16,44 @@ import ( // Non-zero exit codes are not treated as errors — the output is still // captured and returned alongside the exit code. // If workdir is empty, the current directory is used. -func Run(lang, code, workdir string) (string, int, error) { +// If varsFile is non-empty, the child process gets SHOWBOAT_VARS set +// and the showboat binary is made available on PATH. +func Run(lang, code, workdir, varsFile string) (string, int, error) { cmd := exec.Command(lang, "-c", code) if workdir != "" { cmd.Dir = workdir } + if varsFile != "" { + env := os.Environ() + env = append(env, "SHOWBOAT_VARS="+varsFile) + + // Copy the current binary to a temp dir so cells can call "showboat var" + if self, err := os.Executable(); err == nil { + tmpDir, err := os.MkdirTemp("", "showboat-path-*") + if err == nil { + // Best-effort cleanup; won't block if child spawned background processes + defer os.RemoveAll(tmpDir) + + name := "showboat" + if runtime.GOOS == "windows" { + name = "showboat.exe" + } + dest := filepath.Join(tmpDir, name) + if copyBinary(self, dest) == nil { + pathSep := ":" + if runtime.GOOS == "windows" { + pathSep = ";" + } + prependToPath(env, tmpDir, pathSep) + } + } + } + + cmd.Env = env + } + var buf bytes.Buffer cmd.Stdout = &buf cmd.Stderr = &buf @@ -32,3 +68,36 @@ func Run(lang, code, workdir string) (string, int, error) { return buf.String(), 0, nil } + +// prependToPath adds dir to the front of the PATH entry in env (in-place). +func prependToPath(env []string, dir, sep string) { + for i, e := range env { + if idx := strings.IndexByte(e, '='); idx > 0 { + key := e[:idx] + if strings.EqualFold(key, "PATH") { + env[i] = key + "=" + dir + sep + e[idx+1:] + return + } + } + } + // No PATH found; add one + env = append(env, "PATH="+dir) +} + +// copyBinary copies a file from src to dst with executable permissions. +func copyBinary(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY, 0755) + if err != nil { + return err + } + defer out.Close() + + _, err = io.Copy(out, in) + return err +} diff --git a/exec/runner_test.go b/exec/runner_test.go index 4228947..aa06b4d 100644 --- a/exec/runner_test.go +++ b/exec/runner_test.go @@ -6,7 +6,7 @@ import ( ) func TestRunBash(t *testing.T) { - output, _, err := Run("bash", "echo hello", "") + output, _, err := Run("bash", "echo hello", "", "") if err != nil { t.Fatal(err) } @@ -16,7 +16,7 @@ func TestRunBash(t *testing.T) { } func TestRunPython(t *testing.T) { - output, _, err := Run("python3", "print('hi')", "") + output, _, err := Run("python3", "print('hi')", "", "") if err != nil { t.Fatal(err) } @@ -26,7 +26,7 @@ func TestRunPython(t *testing.T) { } func TestRunWithWorkdir(t *testing.T) { - output, _, err := Run("bash", "pwd", "/tmp") + output, _, err := Run("bash", "pwd", "/tmp", "") if err != nil { t.Fatal(err) } @@ -36,7 +36,7 @@ func TestRunWithWorkdir(t *testing.T) { } func TestRunNonZeroExit(t *testing.T) { - output, exitCode, err := Run("bash", "echo oops && exit 1", "") + output, exitCode, err := Run("bash", "echo oops && exit 1", "", "") if err != nil { t.Fatal(err) } @@ -49,7 +49,7 @@ func TestRunNonZeroExit(t *testing.T) { } func TestRunExitCodeReflected(t *testing.T) { - _, exitCode, err := Run("bash", "exit 42", "") + _, exitCode, err := Run("bash", "exit 42", "", "") if err != nil { t.Fatal(err) } @@ -59,7 +59,7 @@ func TestRunExitCodeReflected(t *testing.T) { } func TestRunZeroExitCode(t *testing.T) { - _, exitCode, err := Run("bash", "echo ok", "") + _, exitCode, err := Run("bash", "echo ok", "", "") if err != nil { t.Fatal(err) } @@ -69,7 +69,7 @@ func TestRunZeroExitCode(t *testing.T) { } func TestRunStderrCaptured(t *testing.T) { - output, _, err := Run("bash", "echo out && echo err >&2", "") + output, _, err := Run("bash", "echo out && echo err >&2", "", "") if err != nil { t.Fatal(err) } diff --git a/help.txt b/help.txt index d279e2c..5e29921 100644 --- a/help.txt +++ b/help.txt @@ -10,6 +10,10 @@ Usage: showboat note [text] Append commentary (text or stdin) showboat exec [code] Run code and capture output showboat image [script] Run script, capture image output + showboat var set Set a persistent variable + showboat var get Get a variable's value + showboat var list List all variable names + showboat var del Delete a variable showboat pop Remove the most recent entry showboat verify [--output ] Re-run and diff all code blocks showboat extract [--filename ] Emit commands to recreate file @@ -54,6 +58,21 @@ Extract: they are regenerated by "exec". Use --filename to substitute a different filename in the emitted commands. +Variables: + Code blocks executed via "exec" can set and read persistent variables using + "showboat var". Variables are stored in a .vars JSON file alongside the + markdown document. The showboat binary is automatically available on PATH + inside executed code blocks. + + Variables persist across successive "exec" calls for the same document. + During "verify", the vars file is cleared at the start for a clean replay. + + # Cell 1: start a server and record its PID + showboat exec demo.md bash 'python3 -m http.server 8000 & showboat var set PID $!' + + # Cell 2: stop the server using the saved PID + showboat exec demo.md bash 'kill $(showboat var get PID)' + Stdin: Commands accept input from stdin when the text/code argument is omitted. For example: diff --git a/integration_test.go b/integration_test.go index d99504c..e94b28a 100644 --- a/integration_test.go +++ b/integration_test.go @@ -237,6 +237,117 @@ func TestVersionFlagDefault(t *testing.T) { } } +func TestVarCrossCellPersistence(t *testing.T) { + tmpBin := filepath.Join(t.TempDir(), "showboat") + build := exec.Command("go", "build", "-o", tmpBin, ".") + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("build failed: %s\n%s", err, out) + } + + dir := t.TempDir() + file := filepath.Join(dir, "demo.md") + + run(t, tmpBin, "init", file, "Var Test") + + // Cell 1: set a variable + run(t, tmpBin, "exec", file, "bash", "showboat var set GREETING hello") + + // Cell 2: read the variable and echo it + out := runOutput(t, tmpBin, "exec", file, "bash", "echo $(showboat var get GREETING)") + if !strings.Contains(out, "hello") { + t.Errorf("expected 'hello' in output, got: %q", out) + } + + // Verify the document content has the echoed value + content, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(content), "hello") { + t.Error("expected 'hello' in document output") + } + + // Verify should replay successfully (vars reset and rebuild) + run(t, tmpBin, "verify", file) + + // The .vars file should be cleaned up after verify + varsFile := file + ".vars" + if _, err := os.Stat(varsFile); err == nil { + t.Error("expected vars file to be cleaned up after verify") + } +} + +func TestVarOverwriteAndList(t *testing.T) { + tmpBin := filepath.Join(t.TempDir(), "showboat") + build := exec.Command("go", "build", "-o", tmpBin, ".") + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("build failed: %s\n%s", err, out) + } + + dir := t.TempDir() + file := filepath.Join(dir, "demo.md") + + run(t, tmpBin, "init", file, "Var Overwrite Test") + + // Set two variables + run(t, tmpBin, "exec", file, "bash", "showboat var set A first && showboat var set B second") + + // Overwrite A + run(t, tmpBin, "exec", file, "bash", "showboat var set A updated") + + // Read back and check + out := runOutput(t, tmpBin, "exec", file, "bash", "echo $(showboat var get A)") + if !strings.Contains(out, "updated") { + t.Errorf("expected 'updated' in output, got: %q", out) + } + + // List should show both vars + out = runOutput(t, tmpBin, "exec", file, "bash", "showboat var list") + if !strings.Contains(out, "A") || !strings.Contains(out, "B") { + t.Errorf("expected var list to contain A and B, got: %q", out) + } + + // Delete A and confirm it's gone + run(t, tmpBin, "exec", file, "bash", "showboat var del A") + cmdExec := exec.Command(tmpBin, "exec", file, "bash", "showboat var get A") + cmdOut, err := cmdExec.CombinedOutput() + if err == nil { + t.Error("expected non-zero exit when getting deleted var") + } + if !strings.Contains(string(cmdOut), "variable not set") { + t.Errorf("expected 'variable not set' error, got: %q", string(cmdOut)) + } + + // Clean up the vars file + os.Remove(file + ".vars") +} + +func TestVarWithPython(t *testing.T) { + tmpBin := filepath.Join(t.TempDir(), "showboat") + build := exec.Command("go", "build", "-o", tmpBin, ".") + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("build failed: %s\n%s", err, out) + } + + dir := t.TempDir() + file := filepath.Join(dir, "demo.md") + + run(t, tmpBin, "init", file, "Python Var Test") + + // Set a var from bash + run(t, tmpBin, "exec", file, "bash", "showboat var set NUM 42") + + // Read it from Python using subprocess + pythonCode := `import subprocess; result = subprocess.run(["showboat", "var", "get", "NUM"], capture_output=True, text=True); print("got:" + result.stdout)` + out := runOutput(t, tmpBin, "exec", file, "python3", pythonCode) + if !strings.Contains(out, "got:42") { + t.Errorf("expected 'got:42' from python, got: %q", out) + } + + // Clean up + os.Remove(file + ".vars") +} + func TestVersionFlagInjectedByLdflags(t *testing.T) { tmpBin := filepath.Join(t.TempDir(), "showcase") build := exec.Command("go", "build", "-ldflags", "-X main.version=1.2.3", "-o", tmpBin, ".") diff --git a/main.go b/main.go index 75384be..2a04962 100644 --- a/main.go +++ b/main.go @@ -114,6 +114,56 @@ func main() { os.Exit(1) } + case "var": + if len(args) < 2 { + fmt.Fprintln(os.Stderr, "usage: showboat var [args...]") + os.Exit(1) + } + switch args[1] { + case "set": + if len(args) < 4 { + fmt.Fprintln(os.Stderr, "usage: showboat var set ") + os.Exit(1) + } + if err := cmd.VarSet(args[2], args[3]); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + case "get": + if len(args) < 3 { + fmt.Fprintln(os.Stderr, "usage: showboat var get ") + os.Exit(1) + } + val, err := cmd.VarGet(args[2]) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + fmt.Print(val) + case "list": + keys, err := cmd.VarList() + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + for _, k := range keys { + fmt.Println(k) + } + case "del": + if len(args) < 3 { + fmt.Fprintln(os.Stderr, "usage: showboat var del ") + os.Exit(1) + } + if err := cmd.VarDel(args[2]); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + default: + fmt.Fprintf(os.Stderr, "unknown var subcommand: %s\n", args[1]) + fmt.Fprintln(os.Stderr, "usage: showboat var [args...]") + os.Exit(1) + } + case "pop": if len(args) < 2 { fmt.Fprintln(os.Stderr, "usage: showboat pop ") From 691f580e2b5c6bcd5d362c8a9991bf7a1ab840f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Feb 2026 05:08:32 +0000 Subject: [PATCH 2/5] Regenerate README.md with updated help output https://claude.ai/code/session_014nhLqsJZjScVShx13JFRFz --- README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/README.md b/README.md index e19c3dd..a00734c 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,10 @@ Usage: showboat note [text] Append commentary (text or stdin) showboat exec [code] Run code and capture output showboat image [script] Run script, capture image output + showboat var set Set a persistent variable + showboat var get Get a variable's value + showboat var list List all variable names + showboat var del Delete a variable showboat pop Remove the most recent entry showboat verify [--output ] Re-run and diff all code blocks showboat extract [--filename ] Emit commands to recreate file @@ -106,6 +110,21 @@ Extract: they are regenerated by "exec". Use --filename to substitute a different filename in the emitted commands. +Variables: + Code blocks executed via "exec" can set and read persistent variables using + "showboat var". Variables are stored in a .vars JSON file alongside the + markdown document. The showboat binary is automatically available on PATH + inside executed code blocks. + + Variables persist across successive "exec" calls for the same document. + During "verify", the vars file is cleared at the start for a clean replay. + + # Cell 1: start a server and record its PID + showboat exec demo.md bash 'python3 -m http.server 8000 & showboat var set PID $!' + + # Cell 2: stop the server using the saved PID + showboat exec demo.md bash 'kill $(showboat var get PID)' + Stdin: Commands accept input from stdin when the text/code argument is omitted. For example: From b43e3083e58d243d9ea881cb35884d03680b314b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Feb 2026 05:15:37 +0000 Subject: [PATCH 3/5] Add demo document for showboat var feature Comprehensive demo showing: basic set/get, starting a background http.server and saving its PID, fetching from the server, stopping it with the saved PID, listing and deleting variables, and cross-language usage from Python. Passes showboat verify. https://claude.ai/code/session_014nhLqsJZjScVShx13JFRFz --- demos/showboat-vars.md | 123 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 demos/showboat-vars.md diff --git a/demos/showboat-vars.md b/demos/showboat-vars.md new file mode 100644 index 0000000..23c1285 --- /dev/null +++ b/demos/showboat-vars.md @@ -0,0 +1,123 @@ +# Persistent Variables with showboat var + +*2026-02-10T05:09:08Z* + +This demo shows how `showboat var` lets you pass state between cells. Code blocks executed via `showboat exec` can set and read persistent variables — useful for recording PIDs, ports, file paths, and other values that later cells need. + +## Basic usage + +Variables are set with `showboat var set KEY VALUE` and read with `showboat var get KEY`. The showboat binary is automatically available on PATH inside executed cells. + +```bash +showboat var set GREETING "Hello from an earlier cell" +echo "Variable set." +``` + +```output +Variable set. +``` + +```bash +echo "The greeting is: $(showboat var get GREETING)" +``` + +```output +The greeting is: Hello from an earlier cell +``` + +## Starting a background HTTP server + +A common use case: start a server in one cell, do work against it, then shut it down. Without `showboat var`, there is no way to pass the PID between cells since each runs in a fresh process. + +```bash +mkdir -p /tmp/showboat-demo-site +echo "

It works!

" > /tmp/showboat-demo-site/index.html +python3 -m http.server 8642 --directory /tmp/showboat-demo-site > /dev/null 2>&1 & +showboat var set SERVER_PID $! +showboat var set SERVER_PORT 8642 +sleep 0.3 +echo "Server started on port 8642." +``` + +```output +Server started on port 8642. +``` + +Now a later cell can fetch from the server using the saved port, without needing to know the port number: + +```bash +PORT=$(showboat var get SERVER_PORT) +curl -s http://localhost:$PORT/ +``` + +```output +

It works!

+``` + +## Stopping the server + +The PID was saved earlier. A later cell can use it to cleanly shut down the server: + +```bash +PID=$(showboat var get SERVER_PID) +kill $PID 2>/dev/null && echo "Server stopped." || echo "Server already stopped." +``` + +```output +Server stopped. +``` + +## Listing and cleaning up variables + +You can list all variables and delete ones you no longer need: + +```bash +echo "All variables:" +showboat var list +echo "" +echo "Deleting SERVER_PID..." +showboat var del SERVER_PID +echo "Remaining variables:" +showboat var list +``` + +```output +All variables: +GREETING +SERVER_PID +SERVER_PORT + +Deleting SERVER_PID... +Remaining variables: +GREETING +SERVER_PORT +``` + +## Cross-language support + +Variables work across languages. Any language can call the `showboat` binary via subprocess: + +```python3 +import subprocess + +# Read a variable set by a bash cell +result = subprocess.run(["showboat", "var", "get", "GREETING"], capture_output=True, text=True) +print(f"From Python: {result.stdout}") + +# Set a new variable from Python +subprocess.run(["showboat", "var", "set", "PYTHON_VERSION", "3.x"]) +print("Set PYTHON_VERSION from Python.") +``` + +```output +From Python: Hello from an earlier cell +Set PYTHON_VERSION from Python. +``` + +```bash +echo "Python set PYTHON_VERSION to: $(showboat var get PYTHON_VERSION)" +``` + +```output +Python set PYTHON_VERSION to: 3.x +``` From 0ab83e5892a96e4e748936f45e75973342461076 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Feb 2026 05:39:49 +0000 Subject: [PATCH 4/5] Avoid copying binary on every exec: use parent dir or symlink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit showboatDir() now checks if the directory containing os.Executable() already has a "showboat" entry (the common case for go install, system PATH, etc.) and uses it directly — zero I/O, no temp dir, no cleanup. Only when the binary lives elsewhere (uvx Python shim, go run, renamed binary) does it fall back to creating a temp dir with a symlink. Copy is a last resort for Windows without symlink privileges. Both paths are covered by unit tests: TestShowboatDirFastPath and TestShowboatDirSymlinkFallback. https://claude.ai/code/session_014nhLqsJZjScVShx13JFRFz --- exec/runner.go | 84 +++++++++++++++++++++++++++++++++++---------- exec/runner_test.go | 61 ++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 19 deletions(-) diff --git a/exec/runner.go b/exec/runner.go index ae458f6..c3a3548 100644 --- a/exec/runner.go +++ b/exec/runner.go @@ -29,26 +29,16 @@ func Run(lang, code, workdir, varsFile string) (string, int, error) { env := os.Environ() env = append(env, "SHOWBOAT_VARS="+varsFile) - // Copy the current binary to a temp dir so cells can call "showboat var" - if self, err := os.Executable(); err == nil { - tmpDir, err := os.MkdirTemp("", "showboat-path-*") - if err == nil { - // Best-effort cleanup; won't block if child spawned background processes - defer os.RemoveAll(tmpDir) - - name := "showboat" - if runtime.GOOS == "windows" { - name = "showboat.exe" - } - dest := filepath.Join(tmpDir, name) - if copyBinary(self, dest) == nil { - pathSep := ":" - if runtime.GOOS == "windows" { - pathSep = ";" - } - prependToPath(env, tmpDir, pathSep) - } + dir, cleanup := showboatDir() + if cleanup != nil { + defer cleanup() + } + if dir != "" { + pathSep := ":" + if runtime.GOOS == "windows" { + pathSep = ";" } + prependToPath(env, dir, pathSep) } cmd.Env = env @@ -69,6 +59,62 @@ func Run(lang, code, workdir, varsFile string) (string, int, error) { return buf.String(), 0, nil } +// showboatDir returns a directory containing a "showboat" binary suitable +// for prepending to PATH. It tries the cheapest option first: +// +// 1. If the directory containing os.Executable() already has a file named +// "showboat" (or "showboat.exe" on Windows), use that directory directly +// — no copy, no symlink, no cleanup. +// 2. Otherwise, create a temp directory with a symlink to the binary. +// On Windows (where symlinks need privileges), fall back to a copy. +// +// Returns ("", nil) if the binary cannot be located. +func showboatDir() (dir string, cleanup func()) { + self, err := os.Executable() + if err != nil { + return "", nil + } + return showboatDirFrom(self) +} + +// showboatDirFrom is the testable core of showboatDir. Given the path to +// the current binary, it returns a directory containing a "showboat" entry. +func showboatDirFrom(self string) (dir string, cleanup func()) { + name := "showboat" + if runtime.GOOS == "windows" { + name = "showboat.exe" + } + + // Fast path: the binary's own directory already contains "showboat". + // Covers: go install, system PATH, direct ./showboat invocation. + selfDir := filepath.Dir(self) + if _, err := os.Stat(filepath.Join(selfDir, name)); err == nil { + return selfDir, nil + } + + // Slow path: create a temp dir with a link to the real binary. + // Covers: uvx (Python shim), go run, renamed binaries. + tmpDir, err := os.MkdirTemp("", "showboat-path-*") + if err != nil { + return "", nil + } + dest := filepath.Join(tmpDir, name) + + // Try symlink first (free, works on Linux/macOS). + if os.Symlink(self, dest) == nil { + return tmpDir, func() { os.RemoveAll(tmpDir) } + } + + // Symlink failed (Windows without dev mode) — fall back to copy. + if copyBinary(self, dest) == nil { + return tmpDir, func() { os.RemoveAll(tmpDir) } + } + + // Both failed; clean up and give up. + os.RemoveAll(tmpDir) + return "", nil +} + // prependToPath adds dir to the front of the PATH entry in env (in-place). func prependToPath(env []string, dir, sep string) { for i, e := range env { diff --git a/exec/runner_test.go b/exec/runner_test.go index aa06b4d..c174303 100644 --- a/exec/runner_test.go +++ b/exec/runner_test.go @@ -1,6 +1,8 @@ package exec import ( + "os" + "path/filepath" "strings" "testing" ) @@ -77,3 +79,62 @@ func TestRunStderrCaptured(t *testing.T) { t.Errorf("expected both 'out' and 'err' in output, got %q", output) } } + +func TestShowboatDirFastPath(t *testing.T) { + // Create a temp dir with a fake "showboat" binary in it, + // simulating the common case (go install, system PATH). + tmpDir := t.TempDir() + showboatPath := filepath.Join(tmpDir, "showboat") + if err := os.WriteFile(showboatPath, []byte("#!/bin/sh\n"), 0755); err != nil { + t.Fatal(err) + } + + // Temporarily override os.Executable by calling showboatDirFrom directly. + dir, cleanup := showboatDirFrom(showboatPath) + if cleanup != nil { + defer cleanup() + t.Error("expected no cleanup for fast path (no temp dir created)") + } + if dir != tmpDir { + t.Errorf("expected fast path to return %q, got %q", tmpDir, dir) + } +} + +func TestShowboatDirSymlinkFallback(t *testing.T) { + // Create a temp dir with the binary under a non-"showboat" name, + // simulating the uvx/go-run case where the binary has a different name. + tmpDir := t.TempDir() + weirdName := filepath.Join(tmpDir, "some-other-name") + if err := os.WriteFile(weirdName, []byte("#!/bin/sh\n"), 0755); err != nil { + t.Fatal(err) + } + + dir, cleanup := showboatDirFrom(weirdName) + if cleanup == nil { + t.Fatal("expected cleanup function for symlink fallback") + } + defer cleanup() + + if dir == tmpDir { + t.Error("should NOT have returned the original dir (no 'showboat' there)") + } + + // The returned dir should contain a "showboat" symlink + link := filepath.Join(dir, "showboat") + info, err := os.Lstat(link) + if err != nil { + t.Fatalf("expected showboat symlink at %s: %v", link, err) + } + if info.Mode()&os.ModeSymlink == 0 { + t.Errorf("expected symlink, got mode %v", info.Mode()) + } + + // The symlink should point to the original binary + target, err := os.Readlink(link) + if err != nil { + t.Fatal(err) + } + if target != weirdName { + t.Errorf("expected symlink target %q, got %q", weirdName, target) + } +} From 27e1b274eccbd994f0c84734f4f2950be3bc0205 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Feb 2026 05:46:39 +0000 Subject: [PATCH 5/5] Add test for copy fallback when symlinks are unavailable Injects a failing symlinkFunc to force showboatDirFrom through the copy path on Linux (where symlinks normally always succeed). Verifies the copied file has correct content and executable permissions. https://claude.ai/code/session_014nhLqsJZjScVShx13JFRFz --- exec/runner.go | 6 +++++- exec/runner_test.go | 50 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/exec/runner.go b/exec/runner.go index c3a3548..3e7d2f8 100644 --- a/exec/runner.go +++ b/exec/runner.go @@ -77,6 +77,10 @@ func showboatDir() (dir string, cleanup func()) { return showboatDirFrom(self) } +// symlinkFunc is the function used to create symlinks. It can be +// overridden in tests to simulate platforms where symlinks fail. +var symlinkFunc = os.Symlink + // showboatDirFrom is the testable core of showboatDir. Given the path to // the current binary, it returns a directory containing a "showboat" entry. func showboatDirFrom(self string) (dir string, cleanup func()) { @@ -101,7 +105,7 @@ func showboatDirFrom(self string) (dir string, cleanup func()) { dest := filepath.Join(tmpDir, name) // Try symlink first (free, works on Linux/macOS). - if os.Symlink(self, dest) == nil { + if symlinkFunc(self, dest) == nil { return tmpDir, func() { os.RemoveAll(tmpDir) } } diff --git a/exec/runner_test.go b/exec/runner_test.go index c174303..b6a45f4 100644 --- a/exec/runner_test.go +++ b/exec/runner_test.go @@ -1,6 +1,7 @@ package exec import ( + "fmt" "os" "path/filepath" "strings" @@ -138,3 +139,52 @@ func TestShowboatDirSymlinkFallback(t *testing.T) { t.Errorf("expected symlink target %q, got %q", weirdName, target) } } + +func TestShowboatDirCopyFallback(t *testing.T) { + // Force symlink to fail so showboatDirFrom falls back to copy. + old := symlinkFunc + symlinkFunc = func(_, _ string) error { + return fmt.Errorf("symlinks not supported") + } + defer func() { symlinkFunc = old }() + + // Create a fake binary under a non-"showboat" name. + tmpDir := t.TempDir() + weirdName := filepath.Join(tmpDir, "some-other-name") + content := []byte("fake binary content") + if err := os.WriteFile(weirdName, content, 0755); err != nil { + t.Fatal(err) + } + + dir, cleanup := showboatDirFrom(weirdName) + if cleanup == nil { + t.Fatal("expected cleanup function for copy fallback") + } + defer cleanup() + + if dir == tmpDir { + t.Error("should NOT have returned the original dir") + } + + // The returned dir should contain a regular file (not a symlink) + copied := filepath.Join(dir, "showboat") + info, err := os.Lstat(copied) + if err != nil { + t.Fatalf("expected showboat file at %s: %v", copied, err) + } + if info.Mode()&os.ModeSymlink != 0 { + t.Error("expected regular file, got symlink") + } + if info.Mode().Perm()&0111 == 0 { + t.Errorf("expected executable permissions, got %v", info.Mode().Perm()) + } + + // The content should match the original + got, err := os.ReadFile(copied) + if err != nil { + t.Fatal(err) + } + if string(got) != string(content) { + t.Errorf("expected copied content %q, got %q", content, got) + } +}