Skip to content

feat: tmuxinator/teamocil feature parity#1025

Open
tony wants to merge 1 commit into
masterfrom
parity
Open

feat: tmuxinator/teamocil feature parity#1025
tony wants to merge 1 commit into
masterfrom
parity

Conversation

@tony

@tony tony commented Mar 17, 2026

Copy link
Copy Markdown
Member

Summary

Bring tmuxp to feature parity with tmuxinator and teamocil. This adds the missing CLI commands, config keys, lifecycle hooks, config templating, and importer improvements that users migrating from those tools expect — plus a full feature comparison page and documentation for every new command, flag, and config key.

New CLI commands

tmuxp stop — kill a session with cleanup

$ tmuxp stop mysession

Runs the on_project_stop lifecycle hook before killing the session, giving projects a chance to tear down background services, save state, etc.

tmuxp new — create a workspace config

$ tmuxp new myproject

Creates a new workspace config from a minimal template and opens it in $EDITOR.

tmuxp copy — copy a workspace config

$ tmuxp copy myproject myproject-backup

Copies an existing workspace config to a new name. Source is resolved using the same logic as tmuxp load.

tmuxp delete — delete workspace configs

$ tmuxp delete old-project

Deletes workspace config files. Prompts for confirmation unless -y is passed.

Lifecycle hooks

Workspace configs now support four hooks, matching tmuxinator's hook system:

session_name: myproject
on_project_start: docker compose up -d
on_project_exit: docker compose down
on_project_stop: docker compose down -v
on_project_restart: echo "Reattaching..."
windows:
  - window_name: editor
    panes:
      - vim
Hook When it runs
on_project_start Before session build (new session creation only)
on_project_restart When reattaching to an existing session (confirmed attach only)
on_project_exit When the last client detaches (via tmux client-detached hook)
on_project_stop Before tmuxp stop kills the session

Config templating

Workspace configs now support {{ variable }} placeholders with values passed via --set. Given mytemplate.yaml:

session_name: "{{ project }}"
start_directory: "~/code/{{ project }}"
windows:
  - window_name: editor
    panes:
      - vim
$ tmuxp load --set project=myapp mytemplate.yaml

New config keys

Pane titles

session_name: dashboard
enable_pane_titles: true
pane_title_position: top
pane_title_format: "#{pane_title}"
windows:
  - window_name: main
    panes:
      - title: logs
        shell_command: tail -f /var/log/syslog
      - title: editor
        shell_command: vim

synchronize shorthand

windows:
  - window_name: multi-server
    synchronize: before    # or: after, true
    panes:
      - ssh server1
      - ssh server2

Desugars synchronize: beforeoptions: {synchronize-panes: on} and synchronize: afteroptions_after: {synchronize-panes: on}. true is equivalent to before.

shell_command_after and clear

windows:
  - window_name: dev
    shell_command_after:
      - echo "Window ready"
    clear: true
    panes:
      - vim
      - npm run dev

New tmuxp load flags

Flag Description
--here Reuse the current tmux window instead of creating a new session
--no-shell-command-before Skip all shell_command_before entries
--debug Show tmux commands as they execute (disables progress spinner)
--set KEY=VALUE Pass template variables for config templating

Importer improvements

tmuxinator

  • preon_project_start, pre_windowshell_command_before
  • cli_args (-f, -S, -L) parsed into tmuxp equivalents
  • synchronize window key converted
  • startup_window / startup_panefocus: true on the target
  • Named panes (hash-key syntax) → title on the pane
  • Window names coerced to str (fixes numeric/emoji YAML keys)

teamocil

  • v1.x format support (windows at top level, commands key in panes)
  • focus: true on windows and panes converted
  • Window options passed through

Design decisions

  • Template syntax is plain {{ var }} substitution, not Jinja2 — no
    conditionals or loops. Keeps configs declarative and avoids a template-engine
    dependency; --set values are validated against YAML-unsafe characters.
  • --here provisions the reused pane with respawn-pane -k rather than
    typing cd/export into the running shell — no POSIX-shell assumption, no
    keystrokes landing in foreground programs. tmuxp warns first if the pane has
    running child processes, since -k kills them.
  • on_project_exit rides tmux's client-detached hook, so it fires on any
    client detach — not only tmuxp-initiated ones.

Documentation

  • Feature comparison page (docs/comparison.md): Side-by-side of tmuxp vs tmuxinator vs teamocil — architecture, config keys, CLI flags, hooks
  • Top-level config docs (docs/configuration/top-level.md): New keys, lifecycle hooks, synchronize, pane titles
  • Config examples (docs/configuration/examples.md): Working examples for each new feature
  • CLI docs: Pages for stop, new, copy, delete, plus updated load docs
  • Import docs: Updated with notes on new importer capabilities

Related issues

Test plan

  • uv run py.test passes
  • Tests for every new feature: stop, new, copy, delete, load flags, lifecycle hooks, config templating, builder, importers
  • Importer fixtures for edge cases (numeric names, YAML aliases, named panes, v1.x teamocil format)
  • Manual: tmuxp load with lifecycle hooks, --here, --debug, --set
  • Manual: tmuxp stop, tmuxp new, tmuxp copy, tmuxp delete
  • Manual: Import from tmuxinator/teamocil configs with new features

@codecov

codecov Bot commented Mar 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.83861% with 74 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.35%. Comparing base (91ac851) to head (0c96d76).

Files with missing lines Patch % Lines
src/tmuxp/cli/load.py 74.10% 23 Missing and 6 partials ⚠️
src/tmuxp/workspace/builder/classic.py 86.88% 9 Missing and 7 partials ⚠️
src/tmuxp/workspace/importers.py 90.90% 3 Missing and 10 partials ⚠️
src/tmuxp/cli/copy.py 87.80% 3 Missing and 2 partials ⚠️
src/tmuxp/cli/delete.py 92.10% 2 Missing and 1 partial ⚠️
src/tmuxp/util.py 90.32% 1 Missing and 2 partials ⚠️
src/tmuxp/cli/import_config.py 90.00% 2 Missing ⚠️
src/tmuxp/cli/new.py 96.29% 1 Missing and 1 partial ⚠️
src/tmuxp/workspace/loader.py 97.95% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1025      +/-   ##
==========================================
+ Coverage   82.56%   84.35%   +1.78%     
==========================================
  Files          31       35       +4     
  Lines        2770     3342     +572     
  Branches      518      662     +144     
==========================================
+ Hits         2287     2819     +532     
- Misses        346      361      +15     
- Partials      137      162      +25     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@tony tony force-pushed the parity branch 3 times, most recently from d64904e to db303db Compare March 21, 2026 10:53
@tony

tony commented Mar 21, 2026

Copy link
Copy Markdown
Member Author

Code review

Found 4 issues:

  1. Shell command injection via start_directory in --here mode. The --here path constructs f'cd "{start_directory}"' and sends it as keystrokes via send_keys. A start_directory containing " or ; can inject arbitrary shell commands into the pane. The non---here path safely passes start_directory as a structured argument to new_window(). Consider using shlex.quote() or passing the directory through tmux's -c flag instead.

if start_directory:
active_pane = window.active_pane
if active_pane is not None:
active_pane.send_keys(
f'cd "{start_directory}"',
enter=True,
)

  1. Missing doctests on command_copy, command_delete, command_new, command_stop (CLAUDE.md says "All functions and methods MUST have working doctests.")

def command_copy(
source: str,
destination: str,
parser: argparse.ArgumentParser | None = None,
color: CLIColorModeLiteral | None = None,
) -> None:
"""Entrypoint for ``tmuxp copy``, copy a workspace config to a new name."""

def command_delete(
workspace_names: list[str],
answer_yes: bool = False,
parser: argparse.ArgumentParser | None = None,
color: CLIColorModeLiteral | None = None,
) -> None:
"""Entrypoint for ``tmuxp delete``, remove workspace config files."""
color_mode = get_color_mode(color)

def command_new(
workspace_name: str,
parser: argparse.ArgumentParser | None = None,
color: CLIColorModeLiteral | None = None,
) -> None:
"""Entrypoint for ``tmuxp new``, create a new workspace config from template."""
color_mode = get_color_mode(color)
colors = Colors(color_mode)

def command_stop(
args: CLIStopNamespace,
parser: argparse.ArgumentParser | None = None,
) -> None:
"""Entrypoint for ``tmuxp stop``, kill a tmux session."""
color_mode = get_color_mode(args.color)
colors = Colors(color_mode)

  1. Missing doctest on _load_here_in_current_session (CLAUDE.md says "All functions and methods MUST have working doctests.")

tmuxp/src/tmuxp/cli/load.py

Lines 326 to 334 in db303db

def _load_here_in_current_session(builder: WorkspaceBuilder) -> None:
"""Load workspace reusing current window for first window.
Parameters
----------
builder: :class:`workspace.builder.WorkspaceBuilder`
"""
current_attached_session = builder.find_current_attached_session()
builder.build(current_attached_session, here=True)

  1. socket_name extracted from cli_args: "-L mysocket" is silently overwritten if the tmuxinator config also has an explicit socket_name key. The -L value is parsed at line 103, then unconditionally replaced at lines 105-106. Consider only overwriting if the explicit key exists and differs, or logging a warning about the conflict.

raw_args = workspace_dict.get("cli_args") or workspace_dict.get("tmux_options")
if raw_args:
tokens = shlex.split(raw_args)
flag_map = {"-f": "config", "-L": "socket_name", "-S": "socket_path"}
it = iter(tokens)
for token in it:
if token in flag_map:
value = next(it, None)
if value is not None:
tmuxp_workspace[flag_map[token]] = value
if "socket_name" in workspace_dict:
tmuxp_workspace["socket_name"] = workspace_dict["socket_name"]

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@tony tony force-pushed the parity branch 3 times, most recently from 60cd8fc to 92ba6f4 Compare March 23, 2026 01:28
@tony

tony commented Mar 23, 2026

Copy link
Copy Markdown
Member Author

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

Review scope (5 parallel agents):

  1. CLAUDE.md compliance audit — 4 minor findings, all below confidence threshold after scoring
  2. Shallow bug scan — 3 candidates; 1 debunked (shlex.quote + replace is valid POSIX quoting), 1 confirmed as correct tmuxinator parity, 1 edge case with adequate logging
  3. Git history context — no regressions found vs. prior commits
  4. Prior PR comments — color hierarchy and prompt patterns from PRs CLI Colors #1006/feat(load): animated progress spinner for tmuxp load #1020 verified compliant
  5. Code comments compliance — 1 stale docstring (pre-existing, not introduced by this PR)

Prior review rounds: 3 rounds of 3-model (Claude/Gemini/GPT) loom reviews found 16 issues, all fixed in subsequent commits on this branch.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@tony

tony commented Mar 28, 2026

Copy link
Copy Markdown
Member Author

Code review

Found 1 issue:

  1. _load_here_in_current_session and _dispatch_build use callable() as a doctest placeholder, providing zero documentation or testing value (CLAUDE.md says "All functions and methods MUST have working doctests. Doctests serve as both documentation and tests." and "If you cannot create a working doctest, STOP and ask for help")

tmuxp/src/tmuxp/cli/load.py

Lines 334 to 337 in aa69986

--------
>>> from tmuxp.cli.load import _load_here_in_current_session
>>> callable(_load_here_in_current_session)
True

tmuxp/src/tmuxp/cli/load.py

Lines 403 to 406 in aa69986

--------
>>> from tmuxp.cli.load import _dispatch_build
>>> callable(_dispatch_build)
True

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@tony

tony commented Mar 29, 2026

Copy link
Copy Markdown
Member Author

Code review

Found 2 issues:

  1. on_project_start fires unconditionally in --here mode even when the session already exists. The guard at line 793 (not here) causes the session-exists block to be skipped entirely, so execution always falls through to line 815 which runs on_project_start unconditionally. This contradicts commit 54afda7 which deliberately tightened on_project_start to fire on new session creation only.

tmuxp/src/tmuxp/cli/load.py

Lines 792 to 819 in 395b1e1

# Session-exists check — outside spinner so prompt_yes_no is safe
if builder.session_exists(session_name) and not append and not here:
_confirmed = not detached and (
answer_yes
or prompt_yes_no(
f"{cli_colors.highlight(session_name)} is already running. Attach?",
default=True,
color_mode=cli_colors.mode,
)
)
# Run on_project_restart hook — only when actually reattaching
if _confirmed:
if "on_project_restart" in expanded_workspace:
_hook_cwd = expanded_workspace.get("start_directory")
util.run_hook_commands(
expanded_workspace["on_project_restart"],
cwd=_hook_cwd,
)
_reattach(builder, cli_colors)
_cleanup_debug()
return None
# Run on_project_start hook — fires before new session build
if "on_project_start" in expanded_workspace:
_hook_cwd = expanded_workspace.get("start_directory")
util.run_hook_commands(
expanded_workspace["on_project_start"],
cwd=_hook_cwd,

  1. _validate_template_values doctest uses ... in the expected ValueError output without # doctest: +ELLIPSIS. It passes under pytest (global ELLIPSIS in pyproject.toml) but fails under python3 -m doctest. CLAUDE.md requires "Ellipsis for variable output: # doctest: +ELLIPSIS".

>>> _validate_template_values({"key": "foo: bar"})
Traceback (most recent call last):
...
ValueError: --set value for 'key' contains YAML-unsafe characters ...
"""

Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@tony tony force-pushed the parity branch 2 times, most recently from c8dae37 to 3246cb1 Compare April 4, 2026 18:53
@tony tony force-pushed the parity branch 5 times, most recently from 067bc1f to ac94a3d Compare June 6, 2026 16:02
@tony

tony commented Jun 6, 2026

Copy link
Copy Markdown
Member Author

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

@tony

tony commented Jun 6, 2026

Copy link
Copy Markdown
Member Author

Code review

Found 1 issue:

  1. _running_inside_pane compares the TMUX env socket (always str) against server.socket_path, which libtmux types as str | pathlib.Path | None. When a programmatic caller constructs Server(socket_path=pathlib.Path(...)), str != Path is always True, so the genuine self-pane is misclassified as not-self and the code falls through to respawn-pane -k — killing the tmuxp process mid-build, the exact failure this guard exists to prevent. Fix: compare against str(socket_path) (and/or normalize at the call site).

tmux = environ.get("TMUX")
tmux_pane = environ.get("TMUX_PANE")
if not tmux or not tmux_pane or pane_id is None:
return False
env_socket = tmux.split(",")[0]
if socket_path is not None and env_socket != socket_path:
return False
return tmux_pane == pane_id

Call site passing the un-normalized attribute:

if _here_pane is not None and _running_inside_pane(
_here_pane.pane_id,
socket_path=getattr(self.server, "socket_path", None),
environ=os.environ,
):

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@tony

tony commented Jun 6, 2026

Copy link
Copy Markdown
Member Author

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

why: tmuxp lacked several tmuxinator and teamocil workflows, so configs
from those tools lost behavior on import and users missed the lifecycle
and session-management commands they relied on.

what:
- Add session lifecycle commands: tmuxp stop / new / copy / delete.
- Add project lifecycle hooks: on_project_start / restart / exit / stop.
- Add {{ variable }} config templating, filled via --set at load time.
- Add pane titles and window keys (synchronize, shell_command_after,
  clear).
- Add tmuxp load flags: --here, --no-shell-command-before, --debug.
- Broaden tmuxinator/teamocil imports: hooks, named panes, startup
  focus, synchronized windows, tmux CLI args, teamocil v1.x format.
- Extend the builder protocol build() with here= for current-window
  loads so all builders share the contract.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

here mode level 1: match teamocil — use set_environment + respawn-pane, keep send_keys cd only

1 participant