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
10 changes: 10 additions & 0 deletions .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,16 @@ Ruby, YAML and JSON blocks in a README are excerpts of programs under
prints, and the packaging test runs it from the installed gems and compares
that output.

An API example lists the tmux capabilities it needs in `api.capabilities`,
using the ids in `capabilities.json`. The check reads the example's documented
symbols: when one is a method the library refuses on every call below a
capability's release, such as `Server#open_control`, the example must list
that capability. A program that sends a gated command through `run`, or
needs a capability through a method it does not document, may list it too.
The packaging test expects the program to be refused on a tmux that lacks a
listed capability and to succeed on one that has them all, and the docs export
gives each page the release it needs in `tmux_releases`.

Check unstaged changes for whitespace errors:

```console
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,17 @@ at one version, so a release has a section in each changelog.
- When a run ends, a tmux server left anywhere in the sandbox is stopped with
`tmux -S <socket> kill-server` and the run fails, even if every test passed.
The failure names the sockets and never an environment value. (#80)
- The API example `examples/api/workspace_apply.rb` writes the workspace file it
loads, so it runs when saved alone. (#93)
- An API example lists the tmux capabilities it needs in `api.capabilities`, in
version 2 of `examples/manifest.json`, checked against the capability
registry. The docs export gives each API example the tmux release its page
states; the control examples need tmux 3.7. A version 1 manifest from an
earlier release is read and exported as before. (#93)
- `capabilities.json` lists more of the methods each capability and workaround
can refuse: the Async server's `open_control`, `delete_buffer`,
`new_session`, `run` and `run_group`, `Server#run_group`,
`ControlConnection.new` and `Async::ControlConnection#exchange`. (#93)

## 0.1.1 (2026-10-31)

Expand Down
49 changes: 40 additions & 9 deletions capabilities.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
"error": "LibTmux::UnsupportedFeatureError",
"apis": [
"LibTmux::Server#open_control",
"LibTmux::ControlConnection.open"
"LibTmux::Async::Server#open_control",
"LibTmux::ControlConnection.open",
"LibTmux::ControlConnection.new"
],
"commands": [
{
Expand All @@ -31,7 +33,11 @@
"error": "LibTmux::UnsupportedFeatureError",
"apis": [
"LibTmux::Server#delete_buffer",
"LibTmux::Server#run"
"LibTmux::Async::Server#delete_buffer",
"LibTmux::Server#run",
"LibTmux::Server#run_group",
"LibTmux::Async::Server#run",
"LibTmux::Async::Server#run_group"
],
"commands": [
{
Expand All @@ -50,7 +56,10 @@
"handling": "refuse",
"error": "LibTmux::UnsupportedFeatureError",
"apis": [
"LibTmux::Server#run"
"LibTmux::Server#run",
"LibTmux::Server#run_group",
"LibTmux::Async::Server#run",
"LibTmux::Async::Server#run_group"
],
"commands": [
{
Expand All @@ -71,7 +80,10 @@
"error": "LibTmux::UnsupportedFeatureError",
"apis": [
"LibTmux::Window#select_layout",
"LibTmux::Server#run"
"LibTmux::Server#run",
"LibTmux::Server#run_group",
"LibTmux::Async::Server#run",
"LibTmux::Async::Server#run_group"
],
"commands": [
{
Expand Down Expand Up @@ -105,7 +117,11 @@
"apis": [
"LibTmux::Window#select_layout",
"LibTmux::Server#run",
"LibTmux::ControlConnection#exchange"
"LibTmux::Server#run_group",
"LibTmux::Async::Server#run",
"LibTmux::Async::Server#run_group",
"LibTmux::ControlConnection#exchange",
"LibTmux::Async::ControlConnection#exchange"
],
"commands": [
{
Expand All @@ -122,7 +138,11 @@
"error": "LibTmux::UnsupportedFeatureError",
"apis": [
"LibTmux::Server#run",
"LibTmux::ControlConnection#exchange"
"LibTmux::Server#run_group",
"LibTmux::Async::Server#run",
"LibTmux::Async::Server#run_group",
"LibTmux::ControlConnection#exchange",
"LibTmux::Async::ControlConnection#exchange"
],
"commands": [
{
Expand All @@ -146,7 +166,11 @@
"apis": [
"LibTmux::Pane#break_out",
"LibTmux::Server#run",
"LibTmux::ControlConnection#exchange"
"LibTmux::Server#run_group",
"LibTmux::Async::Server#run",
"LibTmux::Async::Server#run_group",
"LibTmux::ControlConnection#exchange",
"LibTmux::Async::ControlConnection#exchange"
],
"commands": [
{
Expand All @@ -163,7 +187,10 @@
],
"handling": "refuse",
"apis": [
"LibTmux::Server#run"
"LibTmux::Server#run",
"LibTmux::Server#run_group",
"LibTmux::Async::Server#run",
"LibTmux::Async::Server#run_group"
],
"commands": [
{
Expand All @@ -184,10 +211,14 @@
"handling": "refuse",
"apis": [
"LibTmux::Server#new_session",
"LibTmux::Async::Server#new_session",
"LibTmux::Session#new_window",
"LibTmux::Session#rename",
"LibTmux::Window#rename",
"LibTmux::Server#run"
"LibTmux::Server#run",
"LibTmux::Server#run_group",
"LibTmux::Async::Server#run",
"LibTmux::Async::Server#run_group"
],
"commands": [
{
Expand Down
32 changes: 25 additions & 7 deletions examples/api/workspace_apply.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,32 @@
# docs:begin main
require "libtmux"
require "libtmux/workspace"
require "tmpdir"

LibTmux::Server.start do |server|
workspace = LibTmux::Workspace.load(File.expand_path("../workspace.yaml", __dir__))
plan = workspace.plan(snapshot: server.snapshot)
puts "planned steps: #{plan.steps.length}"
result = plan.apply(server: server)
puts "applied: #{result.success?}"
puts "sessions: #{server.list_sessions.map { |session| session.snapshot.name }.join(', ')}"
document = <<~'YAML'
session_name: work
environment:
PROJECT_MODE: development
windows:
- window_name: editor
window_index: 1
layout: tiled
panes:
- shell_command: printf 'editor ready\n'
- {}
YAML

Dir.mktmpdir("workspace-") do |directory|
path = File.join(directory, "workspace.yaml")
File.write(path, document)
LibTmux::Server.start do |server|
workspace = LibTmux::Workspace.load(path)
plan = workspace.plan(snapshot: server.snapshot)
puts "planned steps: #{plan.steps.length}"
result = plan.apply(server: server)
puts "applied: #{result.success?}"
puts "sessions: #{server.list_sessions.map { |session| session.snapshot.name }.join(', ')}"
end
end
# docs:end main
rescue StandardError => error
Expand Down
6 changes: 4 additions & 2 deletions examples/manifest.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": 1,
"version": 2,
"programs": [
{
"id": "quickstart",
Expand Down Expand Up @@ -217,6 +217,7 @@
"LibTmux::ControlConnection#subscribe",
"LibTmux::ControlSubscription#next"
],
"capabilities": ["control-mode"],
"description": "Open a control connection, run one command through it, and read the notification tmux sends when a window is renamed.",
"output": "reply: ready\nevent: %window-renamed @0 renamed\n"
}
Expand Down Expand Up @@ -269,6 +270,7 @@
"LibTmux::Async::ControlConnection#events",
"LibTmux::Async::ControlSubscription#next"
],
"capabilities": ["control-mode"],
"description": "Open a control connection inside an Async scope and read the notification tmux sends when a window is renamed.",
"output": "reply: ready\nevent: %window-renamed @0 renamed\n"
}
Expand All @@ -284,7 +286,7 @@
"LibTmux::Workspace::Plan#steps",
"LibTmux::Workspace::Plan#apply"
],
"description": "Load the example workspace file, plan it against a snapshot, and apply the plan to an empty server.",
"description": "Write a workspace file, load it, plan it against a snapshot, and apply the plan to an empty server.",
"output": "planned steps: 11\napplied: true\nsessions: work\n"
}
},
Expand Down
39 changes: 29 additions & 10 deletions gems/libtmux-workspace/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,21 +187,40 @@ and never reconciles an existing one.
`apply` creates the session. Calling `apply` authorizes the configuration's
shell commands.

The path below belongs to [the example file](../../examples/api/workspace_apply.rb)
under `examples/api/`; it loads `examples/workspace.yaml` in this checkout:
[The example file](../../examples/api/workspace_apply.rb) writes the same
document to a temporary `workspace.yaml` before loading it, so it runs saved on
its own:

<!-- example: api_workspace_apply/main -->
```ruby
require "libtmux"
require "libtmux/workspace"

LibTmux::Server.start do |server|
workspace = LibTmux::Workspace.load(File.expand_path("../workspace.yaml", __dir__))
plan = workspace.plan(snapshot: server.snapshot)
puts "planned steps: #{plan.steps.length}"
result = plan.apply(server: server)
puts "applied: #{result.success?}"
puts "sessions: #{server.list_sessions.map { |session| session.snapshot.name }.join(', ')}"
require "tmpdir"

document = <<~'YAML'
session_name: work
environment:
PROJECT_MODE: development
windows:
- window_name: editor
window_index: 1
layout: tiled
panes:
- shell_command: printf 'editor ready\n'
- {}
YAML

Dir.mktmpdir("workspace-") do |directory|
path = File.join(directory, "workspace.yaml")
File.write(path, document)
LibTmux::Server.start do |server|
workspace = LibTmux::Workspace.load(path)
plan = workspace.plan(snapshot: server.snapshot)
puts "planned steps: #{plan.steps.length}"
result = plan.apply(server: server)
puts "applied: #{result.success?}"
puts "sessions: #{server.list_sessions.map { |session| session.snapshot.name }.join(', ')}"
end
end
```
<!-- /example -->
Expand Down
31 changes: 21 additions & 10 deletions gems/libtmux/lib/libtmux/capabilities.rb
Original file line number Diff line number Diff line change
Expand Up @@ -68,23 +68,34 @@ def to_h
end
end

# Public methods that send a tmux command line the caller wrote. The
# command guard reads each line, so a capability it consults can refuse
# a command through any of them, and only for the command it gates.
RUN_CHANNELS = %w[
LibTmux::Server#run LibTmux::Server#run_group LibTmux::Async::Server#run LibTmux::Async::Server#run_group
].freeze
# The control-client channels, which reach a capability only on a tmux
# that accepts control clients.
CONTROL_CHANNELS = %w[LibTmux::ControlConnection#exchange LibTmux::Async::ControlConnection#exchange].freeze

CAPABILITIES = [
Capability.new(id: "control-mode", name: "Control mode", since: "3.7", handling: :refuse, error: ERROR,
summary: "Attach a library-managed control client and stream events.",
apis: ["LibTmux::Server#open_control", "LibTmux::ControlConnection.open"],
apis: ["LibTmux::Server#open_control", "LibTmux::Async::Server#open_control",
"LibTmux::ControlConnection.open", "LibTmux::ControlConnection.new"],
commands: [Command.new(command: "attach-session")]),
Capability.new(id: "buffer-delete-named", name: "Named buffer deletion", since: "3.4", handling: :refuse, error: ERROR,
summary: "Delete one paste buffer by name without risking another buffer.",
apis: ["LibTmux::Server#delete_buffer", "LibTmux::Server#run"],
apis: ["LibTmux::Server#delete_buffer", "LibTmux::Async::Server#delete_buffer", *RUN_CHANNELS],
commands: [Command.new(command: "delete-buffer", flags: ["-b"])]),
Capability.new(id: "buffer-rename", name: "Buffer rename", since: "3.4", handling: :refuse, error: ERROR,
summary: "Rename a paste buffer without risking another buffer.",
apis: ["LibTmux::Server#run"],
apis: RUN_CHANNELS,
commands: [Command.new(command: "set-buffer", flags: ["-b", "-n"])]),
Capability.new(id: "layout-without-checksum", name: "Layouts without a checksum", since: "3.4",
handling: :refuse, error: ERROR,
summary: "Apply a layout string or a named layout that tmux validates before use.",
apis: ["LibTmux::Window#select_layout", "LibTmux::Server#run"],
apis: ["LibTmux::Window#select_layout", *RUN_CHANNELS],
commands: [Command.new(command: "select-layout")]),
Capability.new(id: "layout-mirrored-main", name: "Mirrored main layouts", since: "3.5",
handling: :refuse, error: ERROR,
Expand All @@ -94,30 +105,30 @@ def to_h
Capability.new(id: "layout-json", name: "JSON layout strings", since: "3.8",
handling: :refuse, error: ERROR,
summary: "Apply a layout saved in the JSON form tmux reports.",
apis: ["LibTmux::Window#select_layout", "LibTmux::Server#run", "LibTmux::ControlConnection#exchange"],
apis: ["LibTmux::Window#select_layout", *RUN_CHANNELS, *CONTROL_CHANNELS],
commands: [Command.new(command: "select-layout")]),
Capability.new(id: "clipboard-query", name: "Clipboard query", since: "3.6a", handling: :refuse, error: ERROR,
summary: "Ask an attached client's terminal for its clipboard with refresh-client -l.",
apis: ["LibTmux::Server#run", "LibTmux::ControlConnection#exchange"],
apis: [*RUN_CHANNELS, *CONTROL_CHANNELS],
commands: [Command.new(command: "refresh-client", flags: ["-l"])])
].freeze

WORKAROUNDS = [
Workaround.new(id: "break-pane-misnames-windows", name: "break-pane window naming", releases: ["3.7"],
handling: :repair,
summary: "break-pane gets the new window wrong on exactly tmux 3.7; Pane#break_out names it again, and raw break-pane is refused. A nested break-pane is not checked and stops an exact 3.7 daemon: name every one with -n, which avoids the crash while 3.7 still ignores the name, or upgrade to 3.7a.",
apis: ["LibTmux::Pane#break_out", "LibTmux::Server#run", "LibTmux::ControlConnection#exchange"],
apis: ["LibTmux::Pane#break_out", *RUN_CHANNELS, *CONTROL_CHANNELS],
commands: [Command.new(command: "break-pane")]),
Workaround.new(id: "split-window-percent-size", name: "split-window -p", releases: ["3.4"],
handling: :refuse,
summary: "split-window -p is broken on tmux 3.4; use -l N%.",
apis: ["LibTmux::Server#run"],
apis: RUN_CHANNELS,
commands: [Command.new(command: "split-window", flags: ["-p"])]),
Workaround.new(id: "name-separators-rejected", name: "Colon and dot in names", releases: ["3.7"],
handling: :refuse,
summary: "tmux 3.7 rejects ':' and '.' in session and window names; the library refuses them before dispatch, typed or raw.",
apis: ["LibTmux::Server#new_session", "LibTmux::Session#new_window", "LibTmux::Session#rename",
"LibTmux::Window#rename", "LibTmux::Server#run"],
apis: ["LibTmux::Server#new_session", "LibTmux::Async::Server#new_session", "LibTmux::Session#new_window", "LibTmux::Session#rename",
"LibTmux::Window#rename", *RUN_CHANNELS],
commands: [Command.new(command: "new-session", flags: ["-s"]), Command.new(command: "new-window", flags: ["-n"]),
Command.new(command: "rename-session"), Command.new(command: "rename-window")])
].freeze
Expand Down
Loading
Loading