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
1 change: 1 addition & 0 deletions baml_language/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -682,6 +682,31 @@ check:
baml run --list --from .
details: "`baml check` compiles the project and reports compiler errors without running anything — the most direct way to validate. Other commands (`baml run`, `baml run --list`, `baml fmt`, `baml test`) also load and check the project before their main work. Use `baml run --list` when you additionally want the compiled function signatures."

playground:
summary: "Serves the BAML playground web UI for a project and opens it in a browser."
syntax: |
baml playground # current project, first free port from 4265
baml playground --from ./my-app # explicit project directory
baml playground --file ./one.baml # standalone single file
baml playground --port 4265 # pin the port (errors if taken)
baml playground --no-open # don't launch a browser (automatic in SSH/headless sessions)
details: |
`baml playground` starts a loopback-only web server hosting the playground UI
and an LSP backend for the loaded project. It binds the first free port from
4265 (scanning up to 4364) unless `--port` pins one, prints the URL to stdout,
and opens a browser unless `--no-open` is set or the session is headless
(SSH, or no display on Linux).

On a remote machine, forward the port and open the printed URL locally:

ssh -L 4265:localhost:4265 user@host

The printed URL carries a `?token=...` query parameter that authorizes the
session — open it verbatim and treat it as a per-session secret. API requests
without the token are rejected.

See also: `baml describe check`.

spawn:
summary: "Launches a concurrent task that runs in the background."
syntax: |
Expand Down
2 changes: 2 additions & 0 deletions baml_language/crates/baml_cli/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,8 @@ mod tests {
assert!(help.contains("Usage: baml playground [OPTIONS]"), "{help}");
assert!(help.contains("--file <PATH>"), "{help}");
assert!(help.contains("--from <PATH>"), "{help}");
assert!(help.contains("--port <PORT>"), "{help}");
assert!(help.contains("--no-open"), "{help}");
}

/// `run -e` accepts hyphen-prefixed values without consuming run flags.
Expand Down
18 changes: 16 additions & 2 deletions baml_language/crates/baml_cli/src/describe_command_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1093,6 +1093,12 @@ fn render_keyword_cleanup() {
insta::assert_snapshot!(output);
}

#[test]
fn render_keyword_playground() {
let output = capture_keyword("playground");
insta::assert_snapshot!(output);
}

#[test]
fn render_keyword_baml_sdk() {
let output = capture_keyword("baml_sdk");
Expand Down Expand Up @@ -1132,9 +1138,17 @@ fn dispatch_defer_and_cleanup_resolve_to_keyword() {

#[test]
fn dispatch_language_topic_resolves_to_keyword() {
// Language/SDK + pattern topics route to keyword docs, not package resolution.
// Language/SDK + pattern topics and CLI-command topics route to keyword
// docs, not package resolution.
let db = simple_project();
for name in ["python", "typescript", "baml_sdk", "patterns", "pattern"] {
for name in [
"python",
"typescript",
"baml_sdk",
"patterns",
"pattern",
"playground",
] {
assert!(
matches!(dispatch(&db, name), Some(ResolvedTarget::Keyword(_))),
"`{name}` should resolve to a keyword topic"
Expand Down
62 changes: 61 additions & 1 deletion baml_language/crates/baml_cli/src/playground_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,47 @@ pub struct PlaygroundArgs {
/// Project search starting point. Ignored when `--file` is set.
#[arg(long, value_name = "PATH")]
pub from: Option<PathBuf>,

/// Listen on exactly this port (errors if unavailable).
/// Default: the first free port from 4265.
#[arg(long, value_name = "PORT")]
pub port: Option<u16>,

/// Do not open a browser. Opening is also skipped automatically in
/// headless sessions (SSH, or no display on Linux).
#[arg(long)]
pub no_open: bool,
}

impl PlaygroundArgs {
pub fn run(&self) -> Result<crate::ExitCode> {
let playground_dir = resolve_playground_assets()?;

let roots = workspace_roots(self.from.as_deref(), self.file.as_deref())?;
baml_lsp_server::run_playground_server(roots, playground_dir)?;
let options = baml_lsp_server::PlaygroundServerOptions {
port: self.port,
open_browser: !self.no_open
&& !is_headless_session(|key| std::env::var_os(key).is_some()),
};
baml_lsp_server::run_playground_server(roots, playground_dir, options)?;
Ok(crate::ExitCode::Success)
}
}

/// A browser can't usefully open here: an SSH session, or a Linux/BSD session
/// with no display server. `webbrowser` would fall back to text-mode browsers
/// (lynx/w3m) that hijack the terminal. macOS/Windows sessions are never
/// display-less in practice, so only the SSH signal applies there.
fn is_headless_session(has_env: impl Fn(&str) -> bool) -> bool {
if has_env("SSH_CONNECTION") || has_env("SSH_TTY") {
return true;
}
if cfg!(all(unix, not(target_os = "macos"))) {
return !has_env("DISPLAY") && !has_env("WAYLAND_DISPLAY");
}
false
}

fn workspace_roots(from: Option<&Path>, file: Option<&Path>) -> Result<Vec<PathBuf>> {
let location = resolve_source_location(from, file, None)?;
match location {
Expand Down Expand Up @@ -102,6 +131,37 @@ mod tests {
"[package]\nname = \"test-project\"\n"
}

fn env_with(present: &'static [&'static str]) -> impl Fn(&str) -> bool {
move |key| present.contains(&key)
}

#[test]
fn ssh_connection_implies_headless() {
assert!(is_headless_session(env_with(&[
"SSH_CONNECTION",
"DISPLAY"
])));
}

#[test]
fn ssh_tty_implies_headless() {
assert!(is_headless_session(env_with(&["SSH_TTY", "DISPLAY"])));
}

#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn display_present_is_not_headless() {
assert!(!is_headless_session(env_with(&["DISPLAY"])));
assert!(!is_headless_session(env_with(&["WAYLAND_DISPLAY"])));
assert!(is_headless_session(env_with(&[])));
}

#[cfg(target_os = "macos")]
#[test]
fn no_signals_is_not_headless_on_macos() {
assert!(!is_headless_session(env_with(&[])));
}

#[test]
fn project_mode_errors_without_project_marker() {
let tmp = TempDir::new().unwrap();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
source: crates/baml_cli/src/describe_command_tests.rs
expression: output
---
playground — Serves the BAML playground web UI for a project and opens it in a browser.

Syntax:
baml playground # current project, first free port from 4265
baml playground --from ./my-app # explicit project directory
baml playground --file ./one.baml # standalone single file
baml playground --port 4265 # pin the port (errors if taken)
baml playground --no-open # don't launch a browser (automatic in SSH/headless sessions)

`baml playground` starts a loopback-only web server hosting the playground UI
and an LSP backend for the loaded project. It binds the first free port from
4265 (scanning up to 4364) unless `--port` pins one, prints the URL to stdout,
and opens a browser unless `--no-open` is set or the session is headless
(SSH, or no display on Linux).

On a remote machine, forward the port and open the printed URL locally:

ssh -L 4265:localhost:4265 user@host

The printed URL carries a `?token=...` query parameter that authorizes the
session — open it verbatim and treat it as a per-session secret. API requests
without the token are rejected.

See also: `baml describe check`.
1 change: 1 addition & 0 deletions baml_language/crates/baml_lsp_server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ tokio-tungstenite = { workspace = true }
tower-http = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
uuid = { workspace = true }
vfs = { workspace = true }
webbrowser = { workspace = true }

Expand Down
Loading
Loading