diff --git a/cmd/agent/container/setup.go b/cmd/agent/container/setup.go
index 9802723eb..843822df2 100644
--- a/cmd/agent/container/setup.go
+++ b/cmd/agent/container/setup.go
@@ -35,6 +35,7 @@ import (
"github.com/devsy-org/devsy/pkg/ide/fleet"
"github.com/devsy-org/devsy/pkg/ide/jetbrains"
"github.com/devsy-org/devsy/pkg/ide/jupyter"
+ "github.com/devsy-org/devsy/pkg/ide/marimo"
"github.com/devsy-org/devsy/pkg/ide/openvscode"
"github.com/devsy-org/devsy/pkg/ide/rstudio"
"github.com/devsy-org/devsy/pkg/ide/vscode"
@@ -617,6 +618,11 @@ func (cmd *SetupContainerCmd) installIDE(
setupInfo.SubstitutionContext.ContainerWorkspaceFolder,
config.GetRemoteUser(setupInfo), ide.Options).
Install()
+ case string(config2.IDEMarimo):
+ return marimo.NewMarimoServer(
+ setupInfo.SubstitutionContext.ContainerWorkspaceFolder,
+ config.GetRemoteUser(setupInfo), ide.Options).
+ Install()
case string(config2.IDERStudio):
return rstudio.NewRStudioServer(
setupInfo.SubstitutionContext.ContainerWorkspaceFolder,
diff --git a/desktop/src/renderer/public/icons/ides/marimo.svg b/desktop/src/renderer/public/icons/ides/marimo.svg
new file mode 100644
index 000000000..f28ba734b
--- /dev/null
+++ b/desktop/src/renderer/public/icons/ides/marimo.svg
@@ -0,0 +1,19 @@
+
diff --git a/desktop/src/renderer/public/icons/ides/marimo_dark.svg b/desktop/src/renderer/public/icons/ides/marimo_dark.svg
new file mode 100644
index 000000000..116d0b793
--- /dev/null
+++ b/desktop/src/renderer/public/icons/ides/marimo_dark.svg
@@ -0,0 +1,19 @@
+
diff --git a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte
index be83a2d5a..da4b5e759 100644
--- a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte
+++ b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte
@@ -48,6 +48,7 @@ const IDE_ICON_DARK_VARIANTS = new Set([
"code-server",
"cursor",
"jupyter",
+ "marimo",
"none",
"zed",
])
@@ -93,6 +94,7 @@ const IDE_GROUPS = [
label: "Other",
options: [
{ value: "jupyternotebook", label: "Jupyter Notebook", iconName: "jupyter" },
+ { value: "marimo", label: "marimo", iconName: "marimo" },
{ value: "vscode-insiders", label: "VS Code Insiders", iconName: "vscode_insiders" },
{ value: "positron", label: "Positron", iconName: "positron" },
{ value: "rstudio", label: "RStudio Server", iconName: "rstudio" },
diff --git a/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte b/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte
index bed291b27..516b0c73f 100644
--- a/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte
+++ b/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte
@@ -81,6 +81,7 @@ const IDE_OPTIONS = [
{ value: "clion", label: "CLion" },
{ value: "dataspell", label: "DataSpell" },
{ value: "jupyternotebook", label: "Jupyter Notebook" },
+ { value: "marimo", label: "marimo" },
{ value: "vscode-insiders", label: "VS Code Insiders" },
{ value: "positron", label: "Positron" },
{ value: "rstudio", label: "RStudio Server" },
diff --git a/pkg/config/ide.go b/pkg/config/ide.go
index 828ce6f09..aa5009f12 100644
--- a/pkg/config/ide.go
+++ b/pkg/config/ide.go
@@ -28,6 +28,7 @@ const (
IDEWindsurf IDE = "windsurf"
IDEAntigravity IDE = "antigravity"
IDEBob IDE = "bob"
+ IDEMarimo IDE = "marimo"
)
type IDEGroup string
diff --git a/pkg/ide/ideparse/parse.go b/pkg/ide/ideparse/parse.go
index 173909fc2..317c277f7 100644
--- a/pkg/ide/ideparse/parse.go
+++ b/pkg/ide/ideparse/parse.go
@@ -15,6 +15,7 @@ import (
"github.com/devsy-org/devsy/pkg/ide/fleet"
"github.com/devsy-org/devsy/pkg/ide/jetbrains"
"github.com/devsy-org/devsy/pkg/ide/jupyter"
+ "github.com/devsy-org/devsy/pkg/ide/marimo"
"github.com/devsy-org/devsy/pkg/ide/openvscode"
"github.com/devsy-org/devsy/pkg/ide/rstudio"
"github.com/devsy-org/devsy/pkg/ide/vscode"
@@ -172,6 +173,14 @@ var AllowedIDEs = []AllowedIDE{
IconDark: config.WebsiteAssetsURL + "/jupyter_dark.svg",
Group: config.IDEGroupOther,
},
+ {
+ Name: config.IDEMarimo,
+ DisplayName: "marimo",
+ Options: marimo.Options,
+ Icon: config.WebsiteAssetsURL + "/marimo.svg",
+ IconDark: config.WebsiteAssetsURL + "/marimo_dark.svg",
+ Group: config.IDEGroupOther,
+ },
{
Name: config.IDEVSCodeInsiders,
DisplayName: "VS Code Insiders",
diff --git a/pkg/ide/jupyter/jupyter.go b/pkg/ide/jupyter/jupyter.go
index d346a593b..0c721c039 100644
--- a/pkg/ide/jupyter/jupyter.go
+++ b/pkg/ide/jupyter/jupyter.go
@@ -15,6 +15,8 @@ const (
BindAddressOption = "BIND_ADDRESS"
)
+const DefaultServerPort = 10700
+
var Options = ide.Options{
BindAddressOption: {
Name: BindAddressOption,
@@ -23,7 +25,11 @@ var Options = ide.Options{
},
}
-const DefaultServerPort = 10700
+type JupyterNotbookServer struct {
+ values map[string]config.OptionValue
+ workspaceFolder string
+ userName string
+}
func NewJupyterNotebookServer(
workspaceFolder string,
@@ -37,40 +43,45 @@ func NewJupyterNotebookServer(
}
}
-type JupyterNotbookServer struct {
- values map[string]config.OptionValue
- workspaceFolder string
- userName string
-}
-
func (o *JupyterNotbookServer) Install() error {
- err := o.installNotebook()
- if err != nil {
+ if err := o.installNotebook(); err != nil {
return err
}
-
return o.Start()
}
+func (o *JupyterNotbookServer) Start() error {
+ return command.StartBackgroundOnce("jupyter", func() (*exec.Cmd, error) {
+ log.Infof("Starting jupyter notebook in background")
+ runCommand := fmt.Sprintf(
+ "jupyter notebook --ip='*' --NotebookApp.notebook_dir='%s' --NotebookApp.token='' "+
+ "--NotebookApp.password='' --no-browser --port '%s' --allow-root",
+ o.workspaceFolder,
+ strconv.Itoa(DefaultServerPort),
+ )
+ args := []string{}
+ if o.userName != "" {
+ args = append(args, "su", o.userName, "-w", "SSH_AUTH_SOCK", "-l", "-c", runCommand)
+ } else {
+ args = append(args, "sh", "-l", "-c", runCommand)
+ }
+ //nolint:gosec // args are constructed from trusted inputs
+ cmd := exec.Command(args[0], args[1:]...)
+ cmd.Dir = o.workspaceFolder
+ return cmd, nil
+ })
+}
+
func (o *JupyterNotbookServer) installNotebook() error {
if command.ExistsForUser("jupyter", o.userName) {
return nil
}
- // check if pip3 exists
- baseCommand := ""
- if command.ExistsForUser("pip3", o.userName) {
- baseCommand = "pip3"
- } else if command.ExistsForUser("pip", o.userName) {
- baseCommand = "pip"
- } else {
- return fmt.Errorf(
- "seems like neither pip3 nor pip exists, please make sure to install python correctly",
- )
+ runCommand, err := ide.PythonInstallCommand(o.userName, "notebook")
+ if err != nil {
+ return err
}
- // install notebook command
- runCommand := fmt.Sprintf("%s install notebook", baseCommand)
args := []string{}
if o.userName != "" {
args = append(args, "su", o.userName, "-c", runCommand)
@@ -78,8 +89,8 @@ func (o *JupyterNotbookServer) installNotebook() error {
args = append(args, "sh", "-c", runCommand)
}
- // install
log.Infof("installing jupyter notebook")
+ //nolint:gosec // args are constructed from trusted inputs
out, err := exec.Command(args[0], args[1:]...).CombinedOutput()
if err != nil {
return fmt.Errorf(
@@ -91,24 +102,3 @@ func (o *JupyterNotbookServer) installNotebook() error {
log.Info("installed jupyter notebook")
return nil
}
-
-func (o *JupyterNotbookServer) Start() error {
- return command.StartBackgroundOnce("jupyter", func() (*exec.Cmd, error) {
- log.Infof("Starting jupyter notebook in background...")
- runCommand := fmt.Sprintf(
- "jupyter notebook --ip='*' --NotebookApp.notebook_dir='%s' --NotebookApp.token='' "+
- "--NotebookApp.password='' --no-browser --port '%s' --allow-root",
- o.workspaceFolder,
- strconv.Itoa(DefaultServerPort),
- )
- args := []string{}
- if o.userName != "" {
- args = append(args, "su", o.userName, "-w", "SSH_AUTH_SOCK", "-l", "-c", runCommand)
- } else {
- args = append(args, "sh", "-l", "-c", runCommand)
- }
- cmd := exec.Command(args[0], args[1:]...)
- cmd.Dir = o.workspaceFolder
- return cmd, nil
- })
-}
diff --git a/pkg/ide/marimo/marimo.go b/pkg/ide/marimo/marimo.go
new file mode 100644
index 000000000..fb6e24680
--- /dev/null
+++ b/pkg/ide/marimo/marimo.go
@@ -0,0 +1,110 @@
+package marimo
+
+import (
+ "fmt"
+ "os/exec"
+ "strconv"
+
+ "github.com/devsy-org/devsy/pkg/command"
+ "github.com/devsy-org/devsy/pkg/config"
+ "github.com/devsy-org/devsy/pkg/ide"
+ "github.com/devsy-org/devsy/pkg/log"
+)
+
+const (
+ BindAddressOption = "BIND_ADDRESS"
+ ForwardPortsOption = "FORWARD_PORTS"
+)
+
+const DefaultServerPort = 10800
+
+var Options = ide.Options{
+ ForwardPortsOption: {
+ Name: ForwardPortsOption,
+ Description: "If Devsy should automatically do port-forwarding",
+ Default: "true",
+ Enum: []string{"true", "false"},
+ },
+ BindAddressOption: {
+ Name: BindAddressOption,
+ Description: "The address to bind the server to locally, e.g. 0.0.0.0:12345",
+ Default: "",
+ },
+}
+
+type MarimoServer struct {
+ values map[string]config.OptionValue
+ workspaceFolder string
+ userName string
+}
+
+func NewMarimoServer(
+ workspaceFolder string,
+ userName string,
+ values map[string]config.OptionValue,
+) *MarimoServer {
+ return &MarimoServer{
+ values: values,
+ workspaceFolder: workspaceFolder,
+ userName: userName,
+ }
+}
+
+func (o *MarimoServer) Install() error {
+ if err := o.installMarimo(); err != nil {
+ return err
+ }
+ return o.Start()
+}
+
+func (o *MarimoServer) Start() error {
+ return command.StartBackgroundOnce("marimo", func() (*exec.Cmd, error) {
+ log.Infof("Starting marimo in background")
+ runCommand := fmt.Sprintf(
+ "marimo edit --headless --host 0.0.0.0 --port %s --no-token '%s'",
+ strconv.Itoa(DefaultServerPort),
+ o.workspaceFolder,
+ )
+ args := []string{}
+ if o.userName != "" {
+ args = append(args, "su", o.userName, "-w", "SSH_AUTH_SOCK", "-l", "-c", runCommand)
+ } else {
+ args = append(args, "sh", "-l", "-c", runCommand)
+ }
+ //nolint:gosec // args are constructed from trusted inputs
+ cmd := exec.Command(args[0], args[1:]...)
+ cmd.Dir = o.workspaceFolder
+ return cmd, nil
+ })
+}
+
+func (o *MarimoServer) installMarimo() error {
+ if command.ExistsForUser("marimo", o.userName) {
+ return nil
+ }
+
+ runCommand, err := ide.PythonInstallCommand(o.userName, "marimo")
+ if err != nil {
+ return err
+ }
+
+ args := []string{}
+ if o.userName != "" {
+ args = append(args, "su", o.userName, "-c", runCommand)
+ } else {
+ args = append(args, "sh", "-c", runCommand)
+ }
+
+ log.Infof("installing marimo")
+ //nolint:gosec // args are constructed from trusted inputs
+ out, err := exec.Command(args[0], args[1:]...).CombinedOutput()
+ if err != nil {
+ return fmt.Errorf(
+ "error installing marimo: %w",
+ command.WrapCommandError(out, err),
+ )
+ }
+
+ log.Info("installed marimo")
+ return nil
+}
diff --git a/pkg/ide/opener/opener.go b/pkg/ide/opener/opener.go
index 582c40dd2..c971b17ce 100644
--- a/pkg/ide/opener/opener.go
+++ b/pkg/ide/opener/opener.go
@@ -18,6 +18,7 @@ import (
"github.com/devsy-org/devsy/pkg/ide/fleet"
"github.com/devsy-org/devsy/pkg/ide/jetbrains"
"github.com/devsy-org/devsy/pkg/ide/jupyter"
+ "github.com/devsy-org/devsy/pkg/ide/marimo"
"github.com/devsy-org/devsy/pkg/ide/openvscode"
"github.com/devsy-org/devsy/pkg/ide/rstudio"
"github.com/devsy-org/devsy/pkg/ide/vscode"
@@ -80,6 +81,8 @@ func browserIDEOpener(
return openCodeServerBrowser, true
case string(config.IDEJupyterNotebook):
return openJupyterBrowser, true
+ case string(config.IDEMarimo):
+ return openMarimoBrowser, true
case string(config.IDERStudio):
return openRStudioBrowser, true
default:
@@ -347,6 +350,25 @@ func openJupyterBrowser(
})
}
+func openMarimoBrowser(
+ ctx context.Context,
+ ideOptions map[string]config.OptionValue,
+ params IDEParams,
+) (string, error) {
+ return openBrowserIDE(ctx, params, browserIDESpec{
+ BindAddrOption: marimo.Options.GetValue(ideOptions, marimo.BindAddressOption),
+ DefaultPort: marimo.DefaultServerPort,
+ ForwardPorts: marimo.Options.GetValue(
+ ideOptions, marimo.ForwardPortsOption,
+ ) == config.BoolTrue,
+ Label: "marimo",
+ LogName: "marimo",
+ TargetURLFn: func(port int, _ string) string {
+ return fmt.Sprintf("http://localhost:%d/", port)
+ },
+ })
+}
+
func openRStudioBrowser(
ctx context.Context,
ideOptions map[string]config.OptionValue,
diff --git a/pkg/ide/types.go b/pkg/ide/types.go
index 3e35404ff..8d9bd478c 100644
--- a/pkg/ide/types.go
+++ b/pkg/ide/types.go
@@ -1,13 +1,35 @@
package ide
import (
+ "fmt"
"io"
"time"
+ "github.com/devsy-org/devsy/pkg/command"
"github.com/devsy-org/devsy/pkg/config"
"github.com/devsy-org/devsy/pkg/log"
)
+// PythonInstallCommand returns the shell command that installs a Python package
+// for the given user, preferring uv when present and falling back to pip3 then
+// pip. Returns an error if none are available.
+//
+// When uv is used, the package is installed as a uv-managed tool (isolated
+// env, shim binary placed in ~/.local/bin); this avoids touching system
+// Python and sidesteps PEP 668 externally-managed-environment errors. The
+// pip fallbacks preserve the existing system-install behavior.
+func PythonInstallCommand(userName, pkg string) (string, error) {
+ switch {
+ case command.ExistsForUser("uv", userName):
+ return fmt.Sprintf("uv tool install %s", pkg), nil
+ case command.ExistsForUser("pip3", userName):
+ return fmt.Sprintf("pip3 install %s", pkg), nil
+ case command.ExistsForUser("pip", userName):
+ return fmt.Sprintf("pip install %s", pkg), nil
+ }
+ return "", fmt.Errorf("none of uv, pip3, or pip exist; install Python tooling first")
+}
+
type IDE interface {
Install() error
}
@@ -55,6 +77,8 @@ func ReusesAuthSock(ide string) bool {
return true
case string(config.IDEJupyterNotebook):
return true
+ case string(config.IDEMarimo):
+ return true
default:
return false
}